diff --git a/Tests/UntoldEditorTests/CreateProjectViewTests.swift b/Tests/UntoldEditorTests/CreateProjectViewTests.swift new file mode 100644 index 0000000..031415a --- /dev/null +++ b/Tests/UntoldEditorTests/CreateProjectViewTests.swift @@ -0,0 +1,603 @@ +// +// CreateProjectViewTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Foundation +import SwiftUI +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class CreateProjectViewTests: XCTestCase { + // MARK: - Helper for extracting build settings + + /// Extract BuildSettings from CreateProjectView using reflection + private func extractBuildSettings(from view: CreateProjectView) -> BuildSettings? { + let mirror = Mirror(reflecting: view) + + // Extract state values + guard let projectName = mirror.descendant("_projectName", "wrappedValue") as? String, + let bundleIdentifier = mirror.descendant("_bundleIdentifier", "wrappedValue") as? String, + let selectedTarget = mirror.descendant("_selectedTarget", "wrappedValue") as? Int, + let macOSVersion = mirror.descendant("_macOSVersion", "wrappedValue") as? Int, + let includeDebugInfo = mirror.descendant("_includeDebugInfo", "wrappedValue") as? Bool, + let optimizationLevel = mirror.descendant("_optimizationLevel", "wrappedValue") as? Int, + let teamID = mirror.descendant("_teamID", "wrappedValue") as? String, + let outputPath = mirror.descendant("_outputPath", "wrappedValue") as? String + else { + return nil + } + + // Recreate the build settings logic from the view + let target: BuildTarget + let isIOSAR = (selectedTarget == 2) + + switch selectedTarget { + case 0: // macOS + let version: MacOSVersion + switch macOSVersion { + case 0: version = .v13 + case 1: version = .v14 + case 2: version = .v15 + default: version = .v15 + } + target = .macOS(deployment: version) + case 1: // iOS + target = .iOS(deployment: .v17) + case 2: // iOS AR + target = .iOS(deployment: .v17) + case 3: // visionOS + target = .visionOS(deployment: .v26) + default: + target = .macOS(deployment: .v15) + } + + let optimization: OptimizationLevel + switch optimizationLevel { + case 0: optimization = .none + case 1: optimization = .speed + case 2: optimization = .size + default: optimization = .none + } + + return BuildSettings( + projectName: projectName, + bundleIdentifier: bundleIdentifier, + outputPath: URL(fileURLWithPath: outputPath), + target: target, + scenes: [], + includeDebugInfo: includeDebugInfo, + optimizationLevel: optimization, + teamID: teamID.isEmpty ? nil : teamID, + isIOSAR: isIOSAR + ) + } + + // MARK: - Default State Tests + + func test_createProjectView_hasDefaultProjectName() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let projectName = mirror.descendant("_projectName", "wrappedValue") as? String { + XCTAssertEqual(projectName, "MyGame", "Default project name should be 'MyGame'") + } + } + + func test_createProjectView_hasDefaultBundleIdentifier() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let bundleIdentifier = mirror.descendant("_bundleIdentifier", "wrappedValue") as? String { + XCTAssertEqual(bundleIdentifier, "com.yourcompany.mygame", "Default bundle identifier should be set") + } + } + + func test_createProjectView_defaultTargetIsMacOS() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let selectedTarget = mirror.descendant("_selectedTarget", "wrappedValue") as? Int { + XCTAssertEqual(selectedTarget, 0, "Default target should be macOS (index 0)") + } + } + + func test_createProjectView_defaultMacOSVersion() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let macOSVersion = mirror.descendant("_macOSVersion", "wrappedValue") as? Int { + XCTAssertEqual(macOSVersion, 2, "Default macOS version should be index 2 (v15)") + } + } + + func test_createProjectView_debugInfoEnabledByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let includeDebugInfo = mirror.descendant("_includeDebugInfo", "wrappedValue") as? Bool { + XCTAssertTrue(includeDebugInfo, "Debug info should be enabled by default") + } + } + + func test_createProjectView_optimizationLevelNoneByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let optimizationLevel = mirror.descendant("_optimizationLevel", "wrappedValue") as? Int { + XCTAssertEqual(optimizationLevel, 0, "Default optimization should be none (index 0)") + } + } + + func test_createProjectView_emptyTeamIDByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let teamID = mirror.descendant("_teamID", "wrappedValue") as? String { + XCTAssertTrue(teamID.isEmpty, "Team ID should be empty by default") + } + } + + func test_createProjectView_notBuildingByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let isBuilding = mirror.descendant("_isBuilding", "wrappedValue") as? Bool { + XCTAssertFalse(isBuilding, "Should not be building by default") + } + } + + func test_createProjectView_buildResultNotShownByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let showBuildResult = mirror.descendant("_showBuildResult", "wrappedValue") as? Bool { + XCTAssertFalse(showBuildResult, "Build result should not be shown by default") + } + } + + // MARK: - Build Settings Tests - macOS + + func test_buildSettings_macOSv13() { + // Arrange + // Since we can't modify @State directly in tests, we verify the logic is correct + let target: BuildTarget = .macOS(deployment: .v13) + + // Assert + if case let .macOS(deployment) = target { + XCTAssertEqual(deployment, .v13, "Should create macOS v13 target") + } else { + XCTFail("Should be macOS target") + } + } + + func test_buildSettings_macOSv14() { + // Arrange + let target: BuildTarget = .macOS(deployment: .v14) + + // Assert + if case let .macOS(deployment) = target { + XCTAssertEqual(deployment, .v14, "Should create macOS v14 target") + } else { + XCTFail("Should be macOS target") + } + } + + func test_buildSettings_macOSv15() { + // Arrange + let target: BuildTarget = .macOS(deployment: .v15) + + // Assert + if case let .macOS(deployment) = target { + XCTAssertEqual(deployment, .v15, "Should create macOS v15 target") + } else { + XCTFail("Should be macOS target") + } + } + + // MARK: - Build Settings Tests - iOS + + func test_buildSettings_iOS() { + // Arrange + let target: BuildTarget = .iOS(deployment: .v17) + + // Assert + if case let .iOS(deployment) = target { + XCTAssertEqual(deployment, .v17, "Should create iOS v17 target") + } else { + XCTFail("Should be iOS target") + } + } + + func test_buildSettings_iOSAR() { + // Arrange: iOS AR is selectedTarget == 2 + let isIOSAR = true + let target: BuildTarget = .iOS(deployment: .v17) + + // Assert + if case .iOS = target { + XCTAssertTrue(isIOSAR, "iOS AR should set isIOSAR flag") + } else { + XCTFail("Should be iOS target") + } + } + + // MARK: - Build Settings Tests - visionOS + + func test_buildSettings_visionOS() { + // Arrange + let target: BuildTarget = .visionOS(deployment: .v26) + + // Assert + if case let .visionOS(deployment) = target { + XCTAssertEqual(deployment, .v26, "Should create visionOS v26 target") + } else { + XCTFail("Should be visionOS target") + } + } + + // MARK: - Optimization Level Tests + + func test_buildSettings_optimizationNone() { + // Arrange + let optimization: OptimizationLevel = .none + + // Assert + XCTAssertEqual(optimization, .none, "Should set optimization to none") + } + + func test_buildSettings_optimizationSpeed() { + // Arrange + let optimization: OptimizationLevel = .speed + + // Assert + XCTAssertEqual(optimization, .speed, "Should set optimization to speed") + } + + func test_buildSettings_optimizationSize() { + // Arrange + let optimization: OptimizationLevel = .size + + // Assert + XCTAssertEqual(optimization, .size, "Should set optimization to size") + } + + // MARK: - TeamID Tests + + func test_buildSettings_emptyTeamIDBecomesNil() { + // Arrange + let teamID = "" + + // Act + let settings = BuildSettings( + projectName: "TestProject", + bundleIdentifier: "com.test.app", + outputPath: URL(fileURLWithPath: "/tmp"), + target: .macOS(deployment: .v15), + scenes: [], + includeDebugInfo: true, + optimizationLevel: .none, + teamID: teamID.isEmpty ? nil : teamID, + isIOSAR: false + ) + + // Assert + XCTAssertNil(settings.teamID, "Empty team ID should become nil") + } + + func test_buildSettings_nonEmptyTeamIDIsPreserved() { + // Arrange + let teamID = "ABC123XYZ" + + // Act + let settings = BuildSettings( + projectName: "TestProject", + bundleIdentifier: "com.test.app", + outputPath: URL(fileURLWithPath: "/tmp"), + target: .macOS(deployment: .v15), + scenes: [], + includeDebugInfo: true, + optimizationLevel: .none, + teamID: teamID.isEmpty ? nil : teamID, + isIOSAR: false + ) + + // Assert + XCTAssertEqual(settings.teamID, teamID, "Non-empty team ID should be preserved") + } + + // MARK: - Target Platform Tests + + func test_targetPlatforms_arrayContainsFourOptions() { + // Arrange + let view = CreateProjectView() + let mirror = Mirror(reflecting: view) + + // Act: Get targets array + if let targets = mirror.descendant("targets") as? [String] { + // Assert + XCTAssertEqual(targets.count, 4, "Should have 4 platform options") + XCTAssertEqual(targets[0], "macOS", "First target should be macOS") + XCTAssertEqual(targets[1], "iOS", "Second target should be iOS") + XCTAssertEqual(targets[2], "iOS AR", "Third target should be iOS AR") + XCTAssertEqual(targets[3], "visionOS", "Fourth target should be visionOS") + } + } + + func test_macOSVersions_arrayContainsThreeOptions() { + // Arrange + let view = CreateProjectView() + let mirror = Mirror(reflecting: view) + + // Act: Get macOSVersions array + if let versions = mirror.descendant("macOSVersions") as? [String] { + // Assert + XCTAssertEqual(versions.count, 3, "Should have 3 macOS version options") + XCTAssertEqual(versions[0], "13.0", "First version should be 13.0") + XCTAssertEqual(versions[1], "14.0", "Second version should be 14.0") + XCTAssertEqual(versions[2], "15.0", "Third version should be 15.0") + } + } + + func test_optimizationLevels_arrayContainsThreeOptions() { + // Arrange + let view = CreateProjectView() + let mirror = Mirror(reflecting: view) + + // Act: Get optimizationLevels array + if let levels = mirror.descendant("optimizationLevels") as? [String] { + // Assert + XCTAssertEqual(levels.count, 3, "Should have 3 optimization level options") + XCTAssertEqual(levels[0], "None", "First level should be None") + XCTAssertEqual(levels[1], "Speed", "Second level should be Speed") + XCTAssertEqual(levels[2], "Size", "Third level should be Size") + } + } + + // MARK: - Build Settings Integration Tests + + func test_buildSettings_fullConfiguration() { + // Arrange + let projectName = "MyAwesomeGame" + let bundleIdentifier = "com.mycompany.awesomegame" + let outputPath = URL(fileURLWithPath: "/Users/test/Projects") + let target: BuildTarget = .iOS(deployment: .v17) + let includeDebugInfo = false + let optimizationLevel: OptimizationLevel = .speed + let teamID = "TEAM123" + let isIOSAR = true + + // Act + let settings = BuildSettings( + projectName: projectName, + bundleIdentifier: bundleIdentifier, + outputPath: outputPath, + target: target, + scenes: [], + includeDebugInfo: includeDebugInfo, + optimizationLevel: optimizationLevel, + teamID: teamID, + isIOSAR: isIOSAR + ) + + // Assert + XCTAssertEqual(settings.projectName, projectName, "Should set project name") + XCTAssertEqual(settings.bundleIdentifier, bundleIdentifier, "Should set bundle identifier") + XCTAssertEqual(settings.outputPath, outputPath, "Should set output path") + XCTAssertEqual(settings.includeDebugInfo, includeDebugInfo, "Should set debug info flag") + XCTAssertEqual(settings.optimizationLevel, optimizationLevel, "Should set optimization level") + XCTAssertEqual(settings.teamID, teamID, "Should set team ID") + XCTAssertEqual(settings.isIOSAR, isIOSAR, "Should set iOS AR flag") + + if case let .iOS(deployment) = settings.target { + XCTAssertEqual(deployment, .v17, "Should set iOS v17 target") + } else { + XCTFail("Target should be iOS") + } + } + + func test_buildSettings_scenesArrayIsEmpty() { + // Arrange + let settings = BuildSettings( + projectName: "TestProject", + bundleIdentifier: "com.test.app", + outputPath: URL(fileURLWithPath: "/tmp"), + target: .macOS(deployment: .v15), + scenes: [], + includeDebugInfo: true, + optimizationLevel: .none, + teamID: nil, + isIOSAR: false + ) + + // Assert + XCTAssertEqual(settings.scenes.count, 0, "Scenes array should be empty (populated by BuildSystem)") + } + + // MARK: - State Management Tests + + func test_createProjectView_initialBuildProgressIsEmpty() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let buildProgress = mirror.descendant("_buildProgress", "wrappedValue") as? String { + XCTAssertTrue(buildProgress.isEmpty, "Build progress should be empty initially") + } + } + + func test_createProjectView_initialBuildResultMessageIsEmpty() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let buildResultMessage = mirror.descendant("_buildResultMessage", "wrappedValue") as? String { + XCTAssertTrue(buildResultMessage.isEmpty, "Build result message should be empty initially") + } + } + + func test_createProjectView_buildNotSucceededByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let buildSucceeded = mirror.descendant("_buildSucceeded", "wrappedValue") as? Bool { + XCTAssertFalse(buildSucceeded, "Build should not be succeeded by default") + } + } + + func test_createProjectView_resultProjectPathIsNilByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + // Check if resultProjectPath exists and is nil + let hasResultProjectPath = mirror.children.contains { child in + child.label == "_resultProjectPath" + } + XCTAssertTrue(hasResultProjectPath, "Should have resultProjectPath state variable") + } + + // MARK: - Target Switch Logic Tests + + func test_targetSwitch_macOS_index0() { + // Arrange + let selectedTarget = 0 + + // Act + let target: BuildTarget + switch selectedTarget { + case 0: target = .macOS(deployment: .v15) + default: target = .macOS(deployment: .v15) + } + + // Assert + if case .macOS = target { + XCTAssertTrue(true, "Target 0 should map to macOS") + } else { + XCTFail("Target 0 should be macOS") + } + } + + func test_targetSwitch_iOS_index1() { + // Arrange + let selectedTarget = 1 + + // Act + let target: BuildTarget + switch selectedTarget { + case 1: target = .iOS(deployment: .v17) + default: target = .macOS(deployment: .v15) + } + + // Assert + if case .iOS = target { + XCTAssertTrue(true, "Target 1 should map to iOS") + } else { + XCTFail("Target 1 should be iOS") + } + } + + func test_targetSwitch_iOSAR_index2() { + // Arrange + let selectedTarget = 2 + let isIOSAR = (selectedTarget == 2) + + // Act + let target: BuildTarget + switch selectedTarget { + case 2: target = .iOS(deployment: .v17) + default: target = .macOS(deployment: .v15) + } + + // Assert + if case .iOS = target { + XCTAssertTrue(isIOSAR, "Target 2 should map to iOS AR") + } else { + XCTFail("Target 2 should be iOS with AR flag") + } + } + + func test_targetSwitch_visionOS_index3() { + // Arrange + let selectedTarget = 3 + + // Act + let target: BuildTarget + switch selectedTarget { + case 3: target = .visionOS(deployment: .v26) + default: target = .macOS(deployment: .v15) + } + + // Assert + if case .visionOS = target { + XCTAssertTrue(true, "Target 3 should map to visionOS") + } else { + XCTFail("Target 3 should be visionOS") + } + } + + func test_targetSwitch_defaultFallback() { + // Arrange + let selectedTarget = 999 // Invalid index + + // Act + let target: BuildTarget + switch selectedTarget { + case 0: target = .macOS(deployment: .v15) + case 1: target = .iOS(deployment: .v17) + case 2: target = .iOS(deployment: .v17) + case 3: target = .visionOS(deployment: .v26) + default: target = .macOS(deployment: .v15) + } + + // Assert + if case .macOS = target { + XCTAssertTrue(true, "Invalid target should default to macOS") + } else { + XCTFail("Invalid target should default to macOS") + } + } + + // MARK: - Output Path Tests + + func test_outputPath_emptyByDefault() { + // Act + let view = CreateProjectView() + + // Assert + let mirror = Mirror(reflecting: view) + if let outputPath = mirror.descendant("_outputPath", "wrappedValue") as? String { + XCTAssertEqual(outputPath, "", "Output path should be empty initially (set in onAppear)") + } + } +} diff --git a/Tests/UntoldEditorTests/EditorControllerTests.swift b/Tests/UntoldEditorTests/EditorControllerTests.swift new file mode 100644 index 0000000..c4e4645 --- /dev/null +++ b/Tests/UntoldEditorTests/EditorControllerTests.swift @@ -0,0 +1,550 @@ +// +// EditorControllerTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Combine +import Foundation +import SwiftUI +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class EditorControllerTests: XCTestCase { + private var controller: EditorController! + private var selectionManager: SelectionManager! + private var tempBaseURL: URL! + private var originalBasePath: URL? + + override func setUp() { + super.setUp() + + // Create temporary directory + let tempDir = FileManager.default.temporaryDirectory + tempBaseURL = tempDir.appendingPathComponent("EditorControllerTests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempBaseURL, withIntermediateDirectories: true) + + // Save original base path + originalBasePath = EditorAssetBasePath.shared.basePath + + // Create fresh scene + scene = Scene() + + // Initialize controller + selectionManager = SelectionManager() + controller = EditorController(selectionManager: selectionManager) + } + + override func tearDown() { + // Clean up temp directory + try? FileManager.default.removeItem(at: tempBaseURL) + + // Restore original base path + EditorAssetBasePath.shared.basePath = originalBasePath + + super.tearDown() + } + + // MARK: - EditorController Initialization Tests + + func test_editorController_initializes() { + // Assert + XCTAssertNotNil(controller, "Controller should initialize") + XCTAssertNotNil(controller.selectionManager, "Should have selection manager") + XCTAssertTrue(controller.isEnabled, "Should be enabled by default") + } + + func test_editorController_hasInitialState() { + // Assert + XCTAssertEqual(controller.activeMode, .none, "Active mode should be none initially") + XCTAssertEqual(controller.activeAxis, .none, "Active axis should be none initially") + XCTAssertNil(controller.currentSceneURL, "Current scene URL should be nil initially") + } + + // MARK: - Active Mode Tests + + func test_editorController_activeMode_isPublished() { + // Arrange + var receivedModes: [TransformManipulationMode] = [] + let cancellable = controller.$activeMode.sink { mode in + receivedModes.append(mode) + } + + // Act + controller.activeMode = .translate + controller.activeMode = .rotate + + // Assert + XCTAssertEqual(receivedModes.count, 3, "Should receive initial + 2 updates") + XCTAssertEqual(receivedModes[0], .none, "Initial mode should be none") + XCTAssertEqual(receivedModes[1], .translate, "First update should be translate") + XCTAssertEqual(receivedModes[2], .rotate, "Second update should be rotate") + + cancellable.cancel() + } + + func test_editorController_activeAxis_isPublished() { + // Arrange + var receivedAxes: [TransformAxis] = [] + let cancellable = controller.$activeAxis.sink { axis in + receivedAxes.append(axis) + } + + // Act + controller.activeAxis = .x + controller.activeAxis = .y + + // Assert + XCTAssertEqual(receivedAxes.count, 3, "Should receive initial + 2 updates") + XCTAssertEqual(receivedAxes[1], .x, "First update should be x") + XCTAssertEqual(receivedAxes[2], .y, "Second update should be y") + + cancellable.cancel() + } + + func test_editorController_resetActiveAxis() { + // Arrange + controller.activeAxis = .x + + // Act + controller.resetActiveAxis() + + // Assert + XCTAssertEqual(controller.activeAxis, .none, "Active axis should be reset to none") + } + + // MARK: - SelectionDelegate Tests + + func test_editorController_implementsSelectionDelegate() { + // Assert + XCTAssertNotNil(controller as? SelectionDelegate, "Should implement SelectionDelegate protocol") + } + + func test_editorController_didSelectEntity_updatesSelectionManager() { + // Arrange + let entity = createEntity() + + // Act + controller.didSelectEntity(entity) + + // Wait for main queue dispatch + let expectation = expectation(description: "Selection update") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Assert + XCTAssertEqual(selectionManager.selectedEntity, entity, "Selection manager should be updated") + } + + func test_editorController_refreshInspector_triggersPublisher() { + // Arrange + var updateCount = 0 + let cancellable = selectionManager.objectWillChange.sink { _ in + updateCount += 1 + } + + // Act + controller.refreshInspector() + + // Wait for main queue dispatch + let expectation = expectation(description: "Refresh update") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Assert + XCTAssertGreaterThan(updateCount, 0, "Should trigger selection manager update") + + cancellable.cancel() + } + + // MARK: - EditorAssetBasePath Tests + + func test_editorAssetBasePath_initializes() { + // Arrange + let basePath = EditorAssetBasePath.shared + + // Assert + XCTAssertNotNil(basePath, "EditorAssetBasePath should initialize") + } + + func test_editorAssetBasePath_canSetBasePath() { + // Act + EditorAssetBasePath.shared.basePath = tempBaseURL + + // Assert + XCTAssertEqual(EditorAssetBasePath.shared.basePath, tempBaseURL, "Base path should be set") + } + + func test_editorAssetBasePath_canClearBasePath() { + // Arrange + EditorAssetBasePath.shared.basePath = tempBaseURL + + // Act + EditorAssetBasePath.shared.basePath = nil + + // Assert + XCTAssertNil(EditorAssetBasePath.shared.basePath, "Base path should be cleared") + } + + func test_editorAssetBasePath_syncsWithEngineGlobal() { + // Act + EditorAssetBasePath.shared.basePath = tempBaseURL + + // Assert + XCTAssertEqual(assetBasePath, tempBaseURL, "Engine global should sync with EditorAssetBasePath") + } + + func test_editorAssetBasePath_projectName_extractsCorrectly() { + // Arrange: Create path structure: ProjectRoot/Sources/ProjectName/GameData + let projectRoot = tempBaseURL.appendingPathComponent("MyGame", isDirectory: true) + let sources = projectRoot.appendingPathComponent("Sources", isDirectory: true) + let projectName = sources.appendingPathComponent("MyGame", isDirectory: true) + let gameData = projectName.appendingPathComponent("GameData", isDirectory: true) + + try? FileManager.default.createDirectory(at: gameData, withIntermediateDirectories: true) + + // Act + EditorAssetBasePath.shared.basePath = gameData + + // Assert + XCTAssertEqual(EditorAssetBasePath.shared.projectName, "MyGame", "Should extract project name correctly") + } + + func test_editorAssetBasePath_projectName_returnsNilWhenNoBasePath() { + // Arrange + EditorAssetBasePath.shared.basePath = nil + + // Act + let projectName = EditorAssetBasePath.shared.projectName + + // Assert + XCTAssertNil(projectName, "Should return nil when no base path") + } + + func test_editorAssetBasePath_isPublished() { + // Arrange + var receivedPaths: [URL?] = [] + let cancellable = EditorAssetBasePath.shared.$basePath.sink { path in + receivedPaths.append(path) + } + + // Act + EditorAssetBasePath.shared.basePath = tempBaseURL + + // Assert + XCTAssertEqual(receivedPaths.count, 2, "Should receive initial + 1 update") + XCTAssertEqual(receivedPaths[1], tempBaseURL, "Should receive updated path") + + cancellable.cancel() + } + + // MARK: - Scene Save/Load Helper Tests + + func test_saveSceneDirect_createsFile() throws { + // Arrange + let sceneData = SceneData(entities: []) + let saveURL = tempBaseURL.appendingPathComponent("test_scene.json") + + // Act + saveSceneDirect(sceneData: sceneData, to: saveURL) + + // Assert + XCTAssertTrue(FileManager.default.fileExists(atPath: saveURL.path), "Scene file should be created") + + // Verify content + let data = try Data(contentsOf: saveURL) + let decoded = try JSONDecoder().decode(SceneData.self, from: data) + XCTAssertEqual(decoded.entities.count, 0, "Should match saved scene data") + } + + func test_saveSceneDirect_createsJSON() throws { + // Arrange + let sceneData = SceneData(entities: []) + let saveURL = tempBaseURL.appendingPathComponent("test_scene.json") + + // Act + saveSceneDirect(sceneData: sceneData, to: saveURL) + + // Assert + let content = try String(contentsOf: saveURL) + XCTAssertTrue(content.contains("{"), "Should be valid JSON") + XCTAssertTrue(content.contains("\"entities\""), "Should contain entities key") + } + + func test_saveSceneDirect_copiesIntoScenesFolder() throws { + // Arrange + EditorAssetBasePath.shared.basePath = tempBaseURL + + let scenesFolder = tempBaseURL.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenesFolder, withIntermediateDirectories: true) + + let saveURL = tempBaseURL.appendingPathComponent("test_scene.json") + let sceneData = SceneData(entities: []) + + // Act + saveSceneDirect(sceneData: sceneData, to: saveURL) + + // Assert + let copiedURL = scenesFolder.appendingPathComponent("test_scene.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: copiedURL.path), "Scene should be copied to Scenes folder") + } + + func test_saveSceneDirect_doesNotDuplicateWhenAlreadyInScenesFolder() throws { + // Arrange + EditorAssetBasePath.shared.basePath = tempBaseURL + + let scenesFolder = tempBaseURL.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenesFolder, withIntermediateDirectories: true) + + let saveURL = scenesFolder.appendingPathComponent("test_scene.json") + let sceneData = SceneData(entities: []) + + // Act + saveSceneDirect(sceneData: sceneData, to: saveURL) + + // Assert + let files = try FileManager.default.contentsOfDirectory(at: scenesFolder, includingPropertiesForKeys: nil) + let sceneFiles = files.filter { $0.lastPathComponent == "test_scene.json" } + XCTAssertEqual(sceneFiles.count, 1, "Should not create duplicate when already in Scenes folder") + } + + func test_saveSceneDirect_overwritesExistingFile() throws { + // Arrange + EditorAssetBasePath.shared.basePath = tempBaseURL + + let scenesFolder = tempBaseURL.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenesFolder, withIntermediateDirectories: true) + + let saveURL = scenesFolder.appendingPathComponent("test_scene.json") + + // Create initial file + let initialData = SceneData(entities: []) + saveSceneDirect(sceneData: initialData, to: saveURL) + + // Act: Overwrite with different data + let newData = SceneData(entities: []) + saveSceneDirect(sceneData: newData, to: saveURL) + + // Assert + XCTAssertTrue(FileManager.default.fileExists(atPath: saveURL.path), "File should still exist") + + let files = try FileManager.default.contentsOfDirectory(at: scenesFolder, includingPropertiesForKeys: nil) + let sceneFiles = files.filter { $0.lastPathComponent == "test_scene.json" } + XCTAssertEqual(sceneFiles.count, 1, "Should have exactly one file") + } + + func test_loadGameScene_decodesCorrectly() throws { + // Arrange: Create a scene file manually + let sceneData = SceneData(entities: []) + let saveURL = tempBaseURL.appendingPathComponent("test_load.json") + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let jsonData = try encoder.encode(sceneData) + try jsonData.write(to: saveURL) + + // Act: Load the scene (note: loadGameScene() uses NSOpenPanel, so we can't test it directly) + // Instead, we test that the saved file can be decoded + let loadedData = try Data(contentsOf: saveURL) + let decoder = JSONDecoder() + let decoded = try decoder.decode(SceneData.self, from: loadedData) + + // Assert + XCTAssertEqual(decoded.entities.count, 0, "Should decode correctly") + } + + // MARK: - EditorComponentsState Tests + + func test_editorComponentsState_initializes() { + // Arrange + let state = EditorComponentsState.shared + + // Assert + XCTAssertNotNil(state, "EditorComponentsState should initialize") + XCTAssertTrue(state.components.isEmpty, "Components should be empty initially") + } + + func test_editorComponentsState_canAddComponent() { + // Arrange + let state = EditorComponentsState.shared + let entity = createEntity() + let componentKey = ObjectIdentifier(RenderComponent.self) + + // Act + state.components[entity] = [componentKey: ComponentOption_Editor( + id: 1, + name: "Render Component", + type: RenderComponent.self, + view: { _, _, _ in AnyView(EmptyView()) } + )] + + // Assert + XCTAssertEqual(state.components.count, 1, "Should have one entity") + XCTAssertNotNil(state.components[entity]?[componentKey], "Should have component for entity") + } + + func test_editorComponentsState_canClear() { + // Arrange + let state = EditorComponentsState.shared + let entity = createEntity() + let componentKey = ObjectIdentifier(RenderComponent.self) + + state.components[entity] = [componentKey: ComponentOption_Editor( + id: 1, + name: "Render Component", + type: RenderComponent.self, + view: { _, _, _ in AnyView(EmptyView()) } + )] + + // Act + state.clear() + + // Assert + XCTAssertTrue(state.components.isEmpty, "Components should be cleared") + } + + func test_editorComponentsState_isPublished() { + // Arrange + let state = EditorComponentsState.shared + var updateCount = 0 + let cancellable = state.objectWillChange.sink { _ in + updateCount += 1 + } + + let entity = createEntity() + let componentKey = ObjectIdentifier(RenderComponent.self) + + // Act + state.components[entity] = [componentKey: ComponentOption_Editor( + id: 1, + name: "Render Component", + type: RenderComponent.self, + view: { _, _, _ in AnyView(EmptyView()) } + )] + + // Assert + XCTAssertGreaterThan(updateCount, 0, "Should publish updates") + + cancellable.cancel() + } + + // MARK: - Notification Tests + + func test_assetBrowserReloadNotification_exists() { + // Assert + XCTAssertNotNil(Notification.Name.assetBrowserReload, "assetBrowserReload notification should exist") + } + + func test_assetBrowserReloadNotification_canPost() { + // Arrange + var notificationReceived = false + let observer = NotificationCenter.default.addObserver( + forName: .assetBrowserReload, + object: nil, + queue: .main + ) { _ in + notificationReceived = true + } + + // Act + NotificationCenter.default.post(name: .assetBrowserReload, object: nil) + + // Wait briefly + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + + // Assert + XCTAssertTrue(notificationReceived, "Should receive notification") + + NotificationCenter.default.removeObserver(observer) + } + + // MARK: - Integration Tests + + func test_editorController_fullWorkflow() { + // 1. Initialize with selection manager + XCTAssertNotNil(controller.selectionManager, "Should have selection manager") + + // 2. Select entity + let entity = createEntity() + controller.didSelectEntity(entity) + + let expectation = expectation(description: "Selection update") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(selectionManager.selectedEntity, entity, "Entity should be selected") + + // 3. Set manipulation mode + controller.activeMode = .translate + XCTAssertEqual(controller.activeMode, .translate, "Mode should be set") + + // 4. Set active axis + controller.activeAxis = .x + XCTAssertEqual(controller.activeAxis, .x, "Axis should be set") + + // 5. Reset axis + controller.resetActiveAxis() + XCTAssertEqual(controller.activeAxis, .none, "Axis should be reset") + } + + func test_editorAssetBasePath_fullWorkflow() { + // 1. Set base path + EditorAssetBasePath.shared.basePath = tempBaseURL + XCTAssertEqual(EditorAssetBasePath.shared.basePath, tempBaseURL, "Base path should be set") + + // 2. Verify engine global sync + XCTAssertEqual(assetBasePath, tempBaseURL, "Engine global should sync") + + // 3. Create project structure + let projectRoot = tempBaseURL.appendingPathComponent("TestProject", isDirectory: true) + let sources = projectRoot.appendingPathComponent("Sources", isDirectory: true) + let projectName = sources.appendingPathComponent("TestProject", isDirectory: true) + let gameData = projectName.appendingPathComponent("GameData", isDirectory: true) + + try? FileManager.default.createDirectory(at: gameData, withIntermediateDirectories: true) + + EditorAssetBasePath.shared.basePath = gameData + + // 4. Extract project name + XCTAssertEqual(EditorAssetBasePath.shared.projectName, "TestProject", "Should extract project name") + + // 5. Clear base path + EditorAssetBasePath.shared.basePath = nil + XCTAssertNil(EditorAssetBasePath.shared.basePath, "Base path should be cleared") + } + + func test_saveAndLoadSceneWorkflow() throws { + // 1. Set up base path + EditorAssetBasePath.shared.basePath = tempBaseURL + + let scenesFolder = tempBaseURL.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenesFolder, withIntermediateDirectories: true) + + // 2. Create and save scene + let sceneData = SceneData(entities: []) + let saveURL = scenesFolder.appendingPathComponent("workflow_test.json") + + saveSceneDirect(sceneData: sceneData, to: saveURL) + + // 3. Verify file exists + XCTAssertTrue(FileManager.default.fileExists(atPath: saveURL.path), "Scene file should exist") + + // 4. Load and verify + let loadedData = try Data(contentsOf: saveURL) + let decoded = try JSONDecoder().decode(SceneData.self, from: loadedData) + + XCTAssertEqual(decoded.entities.count, 0, "Loaded scene should match saved scene") + } +} diff --git a/Tests/UntoldEditorTests/EditorFuncUtilsTests.swift b/Tests/UntoldEditorTests/EditorFuncUtilsTests.swift new file mode 100644 index 0000000..26fb99e --- /dev/null +++ b/Tests/UntoldEditorTests/EditorFuncUtilsTests.swift @@ -0,0 +1,461 @@ +// +// EditorFuncUtilsTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Foundation +import ModelIO +import SwiftUI +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class EditorFuncUtilsTests: XCTestCase { + override func setUp() { + super.setUp() + + // Create fresh scene + scene = Scene() + } + + override func tearDown() { + super.tearDown() + } + + // MARK: - Binding for WrapMode Tests + + func test_bindingForWrapMode_returnsBinding() { + // Arrange + let entity = createEntity() + + // Act + let binding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: {} + ) + + // Assert + XCTAssertNotNil(binding, "Should return a binding") + } + + func test_bindingForWrapMode_fallsBackToClampToEdgeForInvalidEntity() { + // Arrange + let entity = createEntity() + // Entity has no RenderComponent + + // Act + let binding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: {} + ) + + // Assert + XCTAssertEqual(binding.wrappedValue, .clampToEdge, "Should fallback to clampToEdge when no material") + } + + func test_bindingForWrapMode_triggersOnChangeWhenSet() { + // Arrange + let entity = createEntity() + var onChangeCalled = false + + let binding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: { onChangeCalled = true } + ) + + // Act + binding.wrappedValue = .repeat + + // Assert + XCTAssertTrue(onChangeCalled, "Should trigger onChange callback") + } + + func test_bindingForWrapMode_worksForAllTextureTypes() { + // Arrange + let entity = createEntity() + + let textureTypes: [TextureType] = [.baseColor, .normal, .roughness, .metallic] + + // Act & Assert + for textureType in textureTypes { + let binding = bindingForWrapMode( + entityId: entity, + textureType: textureType, + onChange: {} + ) + + XCTAssertNotNil(binding, "Should create binding for \(textureType)") + XCTAssertEqual(binding.wrappedValue, .clampToEdge, "Should have default value for \(textureType)") + } + } + + // MARK: - Binding for ST Scale Tests + + func test_bindingForSTScale_returnsBinding() { + // Arrange + let entity = createEntity() + + // Act + let binding = bindingForSTScale( + entityId: entity, + onChange: {} + ) + + // Assert + XCTAssertNotNil(binding, "Should return a binding") + } + + func test_bindingForSTScale_fallsBackToOneForInvalidEntity() { + // Arrange + let entity = createEntity() + // Entity has no RenderComponent + + // Act + let binding = bindingForSTScale(entityId: entity, onChange: {}) + + // Assert + XCTAssertEqual(binding.wrappedValue, 1.0, "Should fallback to 1.0 when no material") + } + + func test_bindingForSTScale_triggersOnChangeWhenSet() { + // Arrange + let entity = createEntity() + var onChangeCalled = false + + let binding = bindingForSTScale( + entityId: entity, + onChange: { onChangeCalled = true } + ) + + // Act + binding.wrappedValue = 2.0 + + // Assert + XCTAssertTrue(onChangeCalled, "Should trigger onChange callback") + } + + // MARK: - Binding for Material Roughness Tests + + func test_bindingForMaterialRoughness_returnsBinding() { + // Arrange + let entity = createEntity() + + // Act + let binding = bindingForMaterialRoughness( + entityId: entity, + onChange: {} + ) + + // Assert + XCTAssertNotNil(binding, "Should return a binding") + } + + func test_bindingForMaterialRoughness_triggersOnChangeWhenSet() { + // Arrange + let entity = createEntity() + var onChangeCalled = false + + let binding = bindingForMaterialRoughness( + entityId: entity, + onChange: { onChangeCalled = true } + ) + + // Act + binding.wrappedValue = 0.5 + + // Assert + XCTAssertTrue(onChangeCalled, "Should trigger onChange callback") + } + + func test_bindingForMaterialRoughness_acceptsValidRange() { + // Arrange + let entity = createEntity() + + let binding = bindingForMaterialRoughness(entityId: entity, onChange: {}) + + // Act & Assert: Test various valid values + let validValues: [Float] = [0.0, 0.25, 0.5, 0.75, 1.0] + + for value in validValues { + binding.wrappedValue = value + // Should not crash + XCTAssertTrue(true, "Should accept value \(value)") + } + } + + // MARK: - Get Material Texture Image Tests + + func test_getMaterialTextureImage_returnsNilForInvalidEntity() { + // Arrange + let entity = createEntity() + // Entity has no RenderComponent + + // Act + let image = getMaterialTextureImage(entityId: entity, type: .baseColor) + + // Assert + XCTAssertNil(image, "Should return nil when entity has no material") + } + + func test_getMaterialTextureImage_returnsNilForAllTextureTypesWithoutMaterial() { + // Arrange + let entity = createEntity() + + let textureTypes: [TextureType] = [.baseColor, .normal, .roughness, .metallic] + + // Act & Assert + for textureType in textureTypes { + let image = getMaterialTextureImage(entityId: entity, type: textureType) + XCTAssertNil(image, "Should return nil for \(textureType) without material") + } + } + + // MARK: - NSImage from MDLTexture Tests + + func test_nsImageFromMDLTexture_returnsNilForTextureWithoutData() { + // Arrange: Create a minimal MDLTexture without proper data + // Note: We can't easily create a valid MDLTexture without external dependencies, + // so we test the error paths that are accessible + + // This test is primarily documentation of the expected behavior + // In practice, nsImageFromMDLTexture requires a valid MDLTexture with texel data + XCTAssertTrue(true, "nsImageFromMDLTexture requires valid MDLTexture - tested via integration") + } + + // MARK: - Binding Behavior Tests + + func test_bindingForWrapMode_getAndSet() { + // Arrange + let entity = createEntity() + var changeCount = 0 + + let binding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: { changeCount += 1 } + ) + + // Act: Get initial value + let initialValue = binding.wrappedValue + + // Set new value + binding.wrappedValue = .repeat + + // Assert + XCTAssertEqual(initialValue, .clampToEdge, "Initial value should be clampToEdge") + XCTAssertEqual(changeCount, 1, "onChange should be called once") + // Note: wrappedValue may still be clampToEdge because the entity has no material + // The setter still calls updateTextureSampler which may fail gracefully + } + + func test_bindingForSTScale_getAndSet() { + // Arrange + let entity = createEntity() + var changeCount = 0 + + let binding = bindingForSTScale( + entityId: entity, + onChange: { changeCount += 1 } + ) + + // Act: Get initial value + let initialValue = binding.wrappedValue + + // Set new value + binding.wrappedValue = 2.5 + + // Assert + XCTAssertEqual(initialValue, 1.0, "Initial value should be 1.0") + XCTAssertEqual(changeCount, 1, "onChange should be called once") + // Note: wrappedValue may still be 1.0 because the entity has no material + } + + func test_bindingForMaterialRoughness_getAndSet() { + // Arrange + let entity = createEntity() + var changeCount = 0 + + let binding = bindingForMaterialRoughness( + entityId: entity, + onChange: { changeCount += 1 } + ) + + // Act: Set new value + binding.wrappedValue = 0.7 + + // Assert + XCTAssertEqual(changeCount, 1, "onChange should be called once") + } + + // MARK: - Multiple Bindings Tests + + func test_multipleBindings_canCoexist() { + // Arrange + let entity = createEntity() + var wrapModeChanged = false + var stScaleChanged = false + var roughnessChanged = false + + // Act: Create multiple bindings for the same entity + let wrapBinding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: { wrapModeChanged = true } + ) + + let stScaleBinding = bindingForSTScale( + entityId: entity, + onChange: { stScaleChanged = true } + ) + + let roughnessBinding = bindingForMaterialRoughness( + entityId: entity, + onChange: { roughnessChanged = true } + ) + + // Assert: All bindings exist independently + XCTAssertNotNil(wrapBinding, "WrapMode binding should exist") + XCTAssertNotNil(stScaleBinding, "STScale binding should exist") + XCTAssertNotNil(roughnessBinding, "Roughness binding should exist") + + // Act: Trigger changes + wrapBinding.wrappedValue = .repeat + stScaleBinding.wrappedValue = 2.0 + roughnessBinding.wrappedValue = 0.5 + + // Assert: Each callback is independent + XCTAssertTrue(wrapModeChanged, "WrapMode onChange should be called") + XCTAssertTrue(stScaleChanged, "STScale onChange should be called") + XCTAssertTrue(roughnessChanged, "Roughness onChange should be called") + } + + // MARK: - Edge Cases + + func test_bindingForWrapMode_withInvalidEntity() { + // Arrange + let invalidEntity = EntityID(99999) + + // Act + let binding = bindingForWrapMode( + entityId: invalidEntity, + textureType: .baseColor, + onChange: {} + ) + + // Assert: Should not crash + XCTAssertEqual(binding.wrappedValue, .clampToEdge, "Should handle invalid entity gracefully") + } + + func test_bindingForSTScale_withInvalidEntity() { + // Arrange + let invalidEntity = EntityID(99999) + + // Act + let binding = bindingForSTScale(entityId: invalidEntity, onChange: {}) + + // Assert: Should not crash + XCTAssertEqual(binding.wrappedValue, 1.0, "Should handle invalid entity gracefully") + } + + func test_bindingForMaterialRoughness_withInvalidEntity() { + // Arrange + let invalidEntity = EntityID(99999) + + // Act + let binding = bindingForMaterialRoughness(entityId: invalidEntity, onChange: {}) + + // Assert: Should not crash and return some value + _ = binding.wrappedValue + XCTAssertTrue(true, "Should handle invalid entity without crashing") + } + + func test_getMaterialTextureImage_withInvalidEntity() { + // Arrange + let invalidEntity = EntityID(99999) + + // Act + let image = getMaterialTextureImage(entityId: invalidEntity, type: .baseColor) + + // Assert: Should not crash + XCTAssertNil(image, "Should return nil for invalid entity") + } + + // MARK: - Callback Behavior Tests + + func test_onChange_calledMultipleTimes() { + // Arrange + let entity = createEntity() + var callCount = 0 + + let binding = bindingForSTScale( + entityId: entity, + onChange: { callCount += 1 } + ) + + // Act: Set value multiple times + binding.wrappedValue = 1.5 + binding.wrappedValue = 2.0 + binding.wrappedValue = 2.5 + + // Assert + XCTAssertEqual(callCount, 3, "onChange should be called for each set") + } + + func test_onChange_notCalledOnGet() { + // Arrange + let entity = createEntity() + var callCount = 0 + + let binding = bindingForSTScale( + entityId: entity, + onChange: { callCount += 1 } + ) + + // Act: Only get value + _ = binding.wrappedValue + _ = binding.wrappedValue + _ = binding.wrappedValue + + // Assert + XCTAssertEqual(callCount, 0, "onChange should not be called on get") + } + + // MARK: - Integration Tests + + func test_bindingsWorkWithSwiftUIEnvironment() { + // This test validates that the bindings are compatible with SwiftUI's Binding type + // and can be used in SwiftUI views + + let entity = createEntity() + + let wrapModeBinding: Binding = bindingForWrapMode( + entityId: entity, + textureType: .baseColor, + onChange: {} + ) + + let stScaleBinding: Binding = bindingForSTScale( + entityId: entity, + onChange: {} + ) + + let roughnessBinding: Binding = bindingForMaterialRoughness( + entityId: entity, + onChange: {} + ) + + // Assert: All bindings are SwiftUI-compatible + XCTAssertTrue(type(of: wrapModeBinding) == Binding.self, "Should be SwiftUI Binding") + XCTAssertTrue(type(of: stScaleBinding) == Binding.self, "Should be SwiftUI Binding") + XCTAssertTrue(type(of: roughnessBinding) == Binding.self, "Should be SwiftUI Binding") + } +} diff --git a/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift new file mode 100644 index 0000000..5b95edc --- /dev/null +++ b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift @@ -0,0 +1,474 @@ +// +// SceneHierarchyViewTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Foundation +import SwiftUI +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class SceneHierarchyViewTests: XCTestCase { + var selectionManager: SelectionManager! + var sceneGraphModel: SceneGraphModel! + + override func setUp() { + super.setUp() + + // Create fresh scene + scene = Scene() + + selectionManager = SelectionManager() + sceneGraphModel = SceneGraphModel() + } + + override func tearDown() { + selectionManager = nil + sceneGraphModel = nil + super.tearDown() + } + + // MARK: - EntityRow Tests + + func test_entityRow_isNotSelectedByDefault() { + // Arrange + let entity = createEntity() + + // Act + let row = EntityRow( + entityid: entity, + entityName: "TestEntity", + selectionManager: selectionManager + ) + + // Assert: Use reflection to check isSelected property + let mirror = Mirror(reflecting: row) + if let isSelected = mirror.descendant("isSelected") as? Bool { + XCTAssertFalse(isSelected, "Entity should not be selected by default") + } + } + + func test_entityRow_isSelectedWhenMatchesSelectionManager() { + // Arrange + let entity = createEntity() + selectionManager.selectedEntity = entity + + // Act + let row = EntityRow( + entityid: entity, + entityName: "TestEntity", + selectionManager: selectionManager + ) + + // Assert: Use reflection to check isSelected property + let mirror = Mirror(reflecting: row) + if let isSelected = mirror.descendant("isSelected") as? Bool { + XCTAssertTrue(isSelected, "Entity should be selected when it matches selection manager") + } + } + + func test_entityRow_displaysEntityName() { + // Arrange + let entity = createEntity() + let expectedName = "MyEntity" + + // Act + let row = EntityRow( + entityid: entity, + entityName: expectedName, + selectionManager: selectionManager + ) + + // Assert: Verify the entityName is stored correctly + let mirror = Mirror(reflecting: row) + if let entityName = mirror.descendant("entityName") as? String { + XCTAssertEqual(entityName, expectedName, "Should store the entity name") + } + } + + func test_entityRow_tracksEntityID() { + // Arrange + let entity = createEntity() + + // Act + let row = EntityRow( + entityid: entity, + entityName: "TestEntity", + selectionManager: selectionManager + ) + + // Assert: Verify the entityid is stored correctly + let mirror = Mirror(reflecting: row) + if let entityid = mirror.descendant("entityid") as? EntityID { + XCTAssertEqual(entityid, entity, "Should track the entity ID") + } + } + + // MARK: - HierarchyNode Tests + + func test_hierarchyNode_storesEntityData() { + // Arrange + let entity = createEntity() + let expectedName = "NodeEntity" + let expectedDepth = 2 + + // Act + let node = HierarchyNode( + entityId: entity, + entityName: expectedName, + depth: expectedDepth, + sceneGraphModel: sceneGraphModel, + selectionManager: selectionManager + ) + + // Assert + let mirror = Mirror(reflecting: node) + if let entityId = mirror.descendant("entityId") as? EntityID { + XCTAssertEqual(entityId, entity, "Should store entity ID") + } + if let entityName = mirror.descendant("entityName") as? String { + XCTAssertEqual(entityName, expectedName, "Should store entity name") + } + if let depth = mirror.descendant("depth") as? Int { + XCTAssertEqual(depth, expectedDepth, "Should store depth") + } + } + + func test_hierarchyNode_withZeroDepth() { + // Arrange + let entity = createEntity() + + // Act + let node = HierarchyNode( + entityId: entity, + entityName: "RootEntity", + depth: 0, + sceneGraphModel: sceneGraphModel, + selectionManager: selectionManager + ) + + // Assert + let mirror = Mirror(reflecting: node) + if let depth = mirror.descendant("depth") as? Int { + XCTAssertEqual(depth, 0, "Should handle zero depth for root nodes") + } + } + + func test_hierarchyNode_withNestedDepth() { + // Arrange + let entity = createEntity() + + // Act: Create nodes at various depths + let depths = [1, 2, 3, 5, 10] + + for depth in depths { + let node = HierarchyNode( + entityId: entity, + entityName: "Entity_Depth\(depth)", + depth: depth, + sceneGraphModel: sceneGraphModel, + selectionManager: selectionManager + ) + + // Assert + let mirror = Mirror(reflecting: node) + if let storedDepth = mirror.descendant("depth") as? Int { + XCTAssertEqual(storedDepth, depth, "Should correctly store depth \(depth)") + } + } + } + + // MARK: - SceneGraphModel Integration Tests + + func test_hierarchyNode_queriesSceneGraphForChildren() { + // Arrange + let parent = createEntity() + + // Refresh the scene graph to cache hierarchy + sceneGraphModel.refreshHierarchy() + + // Act + let children = sceneGraphModel.getChildren(entityId: parent) + + // Assert: Entity with no children should return empty array + XCTAssertEqual(children.count, 0, "Entity with no children should return empty array") + } + + func test_sceneGraphModel_returnsRootEntitiesForNilParent() { + // Arrange + createEntity() + createEntity() + + // Refresh to cache the hierarchy + sceneGraphModel.refreshHierarchy() + + // Act + let rootEntities = sceneGraphModel.getChildren(entityId: nil) + + // Assert: Should return array of root entities (entities without parents) + XCTAssertNotNil(rootEntities, "Should return a valid array for root entities") + } + + func test_sceneGraphModel_entityWithNoChildren() { + // Arrange + let entity = createEntity() + + // Refresh hierarchy + sceneGraphModel.refreshHierarchy() + + // Act + let children = sceneGraphModel.getChildren(entityId: entity) + + // Assert + XCTAssertEqual(children.count, 0, "Entity with no children should return empty array") + } + + // MARK: - SelectionManager Integration Tests + + func test_selectionManager_updatesSelectedEntity() { + // Arrange + let entity1 = createEntity() + let entity2 = createEntity() + + // Act: Select first entity + selectionManager.selectedEntity = entity1 + XCTAssertEqual(selectionManager.selectedEntity, entity1, "Should select first entity") + + // Act: Select second entity + selectionManager.selectedEntity = entity2 + XCTAssertEqual(selectionManager.selectedEntity, entity2, "Should update to second entity") + } + + func test_selectionManager_handlesNilSelection() { + // Arrange + let entity = createEntity() + selectionManager.selectedEntity = entity + + // Act: Clear selection + selectionManager.selectedEntity = nil + + // Assert + XCTAssertNil(selectionManager.selectedEntity, "Should handle nil selection") + } + + // MARK: - SceneHierarchyView Structure Tests + + func test_sceneHierarchyView_storesCallbacks() { + // Arrange + var addEntityCalled = false + var removeEntityCalled = false + var addCubeCalled = false + var addSphereCalled = false + var addPlaneCalled = false + var addDirLightCalled = false + var addPointLightCalled = false + var addSpotLightCalled = false + var addAreaLightCalled = false + + // Act + let view = SceneHierarchyView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + entityList: [], + onAddEntity_Editor: { addEntityCalled = true }, + onRemoveEntity_Editor: { removeEntityCalled = true }, + onAddCube: { addCubeCalled = true }, + onAddSphere: { addSphereCalled = true }, + onAddPlane: { addPlaneCalled = true }, + onAddDirLight: { addDirLightCalled = true }, + onAddPointLight: { addPointLightCalled = true }, + onAddSpotLight: { addSpotLightCalled = true }, + onAddAreaLight: { addAreaLightCalled = true } + ) + + // Assert: Test that callbacks can be invoked via reflection + let mirror = Mirror(reflecting: view) + + if let onAddEntity = mirror.descendant("onAddEntity_Editor") as? () -> Void { + onAddEntity() + XCTAssertTrue(addEntityCalled, "onAddEntity_Editor should be callable") + } + + if let onRemoveEntity = mirror.descendant("onRemoveEntity_Editor") as? () -> Void { + onRemoveEntity() + XCTAssertTrue(removeEntityCalled, "onRemoveEntity_Editor should be callable") + } + + if let onAddCube = mirror.descendant("onAddCube") as? () -> Void { + onAddCube() + XCTAssertTrue(addCubeCalled, "onAddCube should be callable") + } + + if let onAddSphere = mirror.descendant("onAddSphere") as? () -> Void { + onAddSphere() + XCTAssertTrue(addSphereCalled, "onAddSphere should be callable") + } + + if let onAddPlane = mirror.descendant("onAddPlane") as? () -> Void { + onAddPlane() + XCTAssertTrue(addPlaneCalled, "onAddPlane should be callable") + } + + if let onAddDirLight = mirror.descendant("onAddDirLight") as? () -> Void { + onAddDirLight() + XCTAssertTrue(addDirLightCalled, "onAddDirLight should be callable") + } + + if let onAddPointLight = mirror.descendant("onAddPointLight") as? () -> Void { + onAddPointLight() + XCTAssertTrue(addPointLightCalled, "onAddPointLight should be callable") + } + + if let onAddSpotLight = mirror.descendant("onAddSpotLight") as? () -> Void { + onAddSpotLight() + XCTAssertTrue(addSpotLightCalled, "onAddSpotLight should be callable") + } + + if let onAddAreaLight = mirror.descendant("onAddAreaLight") as? () -> Void { + onAddAreaLight() + XCTAssertTrue(addAreaLightCalled, "onAddAreaLight should be callable") + } + } + + func test_sceneHierarchyView_storesEntityList() { + // Arrange + let entity1 = createEntity() + let entity2 = createEntity() + let entity3 = createEntity() + let entityList = [entity1, entity2, entity3] + + // Act + let view = SceneHierarchyView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + entityList: entityList, + onAddEntity_Editor: {}, + onRemoveEntity_Editor: {}, + onAddCube: {}, + onAddSphere: {}, + onAddPlane: {}, + onAddDirLight: {}, + onAddPointLight: {}, + onAddSpotLight: {}, + onAddAreaLight: {} + ) + + // Assert + let mirror = Mirror(reflecting: view) + if let storedList = mirror.descendant("entityList") as? [EntityID] { + XCTAssertEqual(storedList.count, 3, "Should store entity list") + XCTAssertEqual(storedList, entityList, "Should match original entity list") + } + } + + func test_sceneHierarchyView_withEmptyEntityList() { + // Act + let view = SceneHierarchyView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + entityList: [], + onAddEntity_Editor: {}, + onRemoveEntity_Editor: {}, + onAddCube: {}, + onAddSphere: {}, + onAddPlane: {}, + onAddDirLight: {}, + onAddPointLight: {}, + onAddSpotLight: {}, + onAddAreaLight: {} + ) + + // Assert + let mirror = Mirror(reflecting: view) + if let storedList = mirror.descendant("entityList") as? [EntityID] { + XCTAssertEqual(storedList.count, 0, "Should handle empty entity list") + } + } + + // MARK: - Entity Hierarchy Tests + + func test_sceneGraphModel_refreshHierarchy() { + // Arrange: Create some entities + createEntity() + createEntity() + createEntity() + + // Act: Refresh hierarchy + sceneGraphModel.refreshHierarchy() + + // Assert: The childrenMap should be populated + let mirror = Mirror(reflecting: sceneGraphModel) + if let childrenMap = mirror.descendant("childrenMap") as? [EntityID: [EntityID]] { + // The map should exist (though it might be empty or contain root entities) + XCTAssertNotNil(childrenMap, "Children map should be populated after refresh") + } + } + + func test_sceneGraphModel_getChildrenForInvalidEntity() { + // Arrange + sceneGraphModel.refreshHierarchy() + + // Act: Query children for an invalid/non-existent entity + let invalidEntity = EntityID(99999) + let children = sceneGraphModel.getChildren(entityId: invalidEntity) + + // Assert: Should return empty array for non-existent entity + XCTAssertEqual(children.count, 0, "Non-existent entity should have no children") + } + + // MARK: - Selection State Tests + + func test_entityRow_reactsToSelectionChanges() { + // Arrange + let entity = createEntity() + + let row = EntityRow( + entityid: entity, + entityName: "TestEntity", + selectionManager: selectionManager + ) + + // Act: Select the entity + selectionManager.selectedEntity = entity + + // Assert: The row should reflect selection state through @ObservedObject + // In a real SwiftUI environment, this would trigger a view update + XCTAssertEqual(selectionManager.selectedEntity, entity, "Selection manager should update") + } + + func test_multipleEntityRows_onlyOneSelected() { + // Arrange + let entity1 = createEntity() + let entity2 = createEntity() + let entity3 = createEntity() + + let row1 = EntityRow(entityid: entity1, entityName: "Entity1", selectionManager: selectionManager) + let row2 = EntityRow(entityid: entity2, entityName: "Entity2", selectionManager: selectionManager) + let row3 = EntityRow(entityid: entity3, entityName: "Entity3", selectionManager: selectionManager) + + // Act: Select entity2 + selectionManager.selectedEntity = entity2 + + // Assert: Only entity2 should be selected + let mirror1 = Mirror(reflecting: row1) + let mirror2 = Mirror(reflecting: row2) + let mirror3 = Mirror(reflecting: row3) + + if let isSelected1 = mirror1.descendant("isSelected") as? Bool { + XCTAssertFalse(isSelected1, "Entity1 should not be selected") + } + if let isSelected2 = mirror2.descendant("isSelected") as? Bool { + XCTAssertTrue(isSelected2, "Entity2 should be selected") + } + if let isSelected3 = mirror3.descendant("isSelected") as? Bool { + XCTAssertFalse(isSelected3, "Entity3 should not be selected") + } + } +} diff --git a/Tests/UntoldEditorTests/ScriptProjectManagerTests.swift b/Tests/UntoldEditorTests/ScriptProjectManagerTests.swift new file mode 100644 index 0000000..87076e1 --- /dev/null +++ b/Tests/UntoldEditorTests/ScriptProjectManagerTests.swift @@ -0,0 +1,572 @@ +// +// ScriptProjectManagerTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Foundation +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class ScriptProjectManagerTests: XCTestCase { + private var manager: ScriptProjectManager! + private var tempBaseURL: URL! + + override func setUp() { + super.setUp() + + manager = ScriptProjectManager.shared + + // Create a temporary directory for testing + let tempDir = FileManager.default.temporaryDirectory + tempBaseURL = tempDir.appendingPathComponent("ScriptProjectManagerTests-\(UUID().uuidString)", isDirectory: true) + + try? FileManager.default.createDirectory(at: tempBaseURL, withIntermediateDirectories: true) + + // Set the asset base path to our temp directory + EditorAssetBasePath.shared.basePath = tempBaseURL + } + + override func tearDown() { + // Clean up temp directory + try? FileManager.default.removeItem(at: tempBaseURL) + + // Clear asset base path + EditorAssetBasePath.shared.basePath = nil + + super.tearDown() + } + + // MARK: - Path Resolution Tests + + func test_scriptsDirectory_returnsNilWhenNoBasePath() { + // Arrange + EditorAssetBasePath.shared.basePath = nil + + // Act + let scriptsDir = manager.scriptsDirectory() + + // Assert + XCTAssertNil(scriptsDir, "Should return nil when no asset base path is set") + } + + func test_scriptsDirectory_returnsCorrectPath() { + // Act + let scriptsDir = manager.scriptsDirectory() + + // Assert + XCTAssertNotNil(scriptsDir, "Should return a path") + XCTAssertEqual(scriptsDir?.lastPathComponent, "Scripts", "Should point to Scripts folder") + XCTAssertTrue(scriptsDir?.path.contains(tempBaseURL.path) ?? false, "Should be under temp base path") + } + + func test_sourcesDirectory_returnsCorrectPath() { + // Act + let sourcesDir = manager.sourcesDirectory() + + // Assert + XCTAssertNotNil(sourcesDir, "Should return a path") + XCTAssertTrue(sourcesDir?.path.contains("Sources/GenerateScripts") ?? false, "Should include Sources/GenerateScripts") + } + + func test_generatedDirectory_returnsCorrectPath() { + // Act + let generatedDir = manager.generatedDirectory() + + // Assert + XCTAssertNotNil(generatedDir, "Should return a path") + XCTAssertTrue(generatedDir?.path.contains("Generated") ?? false, "Should include Generated") + } + + // MARK: - Project Status Tests + + func test_isProjectInitialized_returnsFalseForEmptyDirectory() { + // Act + let isInitialized = manager.isProjectInitialized() + + // Assert + XCTAssertFalse(isInitialized, "Should return false when no project files exist") + } + + func test_isProjectInitialized_returnsTrueWhenFilesExist() throws { + // Arrange: Initialize a project + try manager.initializeProject() + + // Act + let isInitialized = manager.isProjectInitialized() + + // Assert + XCTAssertTrue(isInitialized, "Should return true after initialization") + } + + // MARK: - Project Initialization Tests + + func test_initializeProject_createsPackageSwift() throws { + // Act + try manager.initializeProject() + + // Assert + guard let scriptsDir = manager.scriptsDirectory() else { + XCTFail("Scripts directory should exist") + return + } + + let packageSwift = scriptsDir.appendingPathComponent("Package.swift") + XCTAssertTrue(FileManager.default.fileExists(atPath: packageSwift.path), "Package.swift should be created") + + let content = try String(contentsOf: packageSwift) + XCTAssertTrue(content.contains("swift-tools-version"), "Package.swift should contain swift-tools-version") + XCTAssertTrue(content.contains("GameScripts"), "Package.swift should contain package name") + } + + func test_initializeProject_createsGenerateScriptsSwift() throws { + // Act + try manager.initializeProject() + + // Assert + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let generateScripts = sourcesDir.appendingPathComponent("GenerateScripts.swift") + XCTAssertTrue(FileManager.default.fileExists(atPath: generateScripts.path), "GenerateScripts.swift should be created") + + let content = try String(contentsOf: generateScripts) + XCTAssertTrue(content.contains("@main"), "GenerateScripts.swift should contain @main") + XCTAssertTrue(content.contains("struct GenerateScripts"), "Should contain GenerateScripts struct") + } + + func test_initializeProject_createsGitignore() throws { + // Act + try manager.initializeProject() + + // Assert + guard let scriptsDir = manager.scriptsDirectory() else { + XCTFail("Scripts directory should exist") + return + } + + let gitignore = scriptsDir.appendingPathComponent(".gitignore") + XCTAssertTrue(FileManager.default.fileExists(atPath: gitignore.path), ".gitignore should be created") + + let content = try String(contentsOf: gitignore) + XCTAssertTrue(content.contains(".build/"), ".gitignore should contain .build/") + XCTAssertTrue(content.contains("Generated/"), ".gitignore should contain Generated/") + } + + func test_initializeProject_createsDirectoryStructure() throws { + // Act + try manager.initializeProject() + + // Assert + guard let sourcesDir = manager.sourcesDirectory(), + let generatedDir = manager.generatedDirectory() + else { + XCTFail("Directories should exist") + return + } + + var isSourcesDir: ObjCBool = false + var isGeneratedDir: ObjCBool = false + + XCTAssertTrue(FileManager.default.fileExists(atPath: sourcesDir.path, isDirectory: &isSourcesDir), "Sources directory should exist") + XCTAssertTrue(isSourcesDir.boolValue, "Sources should be a directory") + + XCTAssertTrue(FileManager.default.fileExists(atPath: generatedDir.path, isDirectory: &isGeneratedDir), "Generated directory should exist") + XCTAssertTrue(isGeneratedDir.boolValue, "Generated should be a directory") + } + + func test_initializeProject_throwsWhenNoBasePath() { + // Arrange + EditorAssetBasePath.shared.basePath = nil + + // Act & Assert + XCTAssertThrowsError(try manager.initializeProject()) { error in + guard let scriptError = error as? ScriptProjectError else { + XCTFail("Should throw ScriptProjectError") + return + } + XCTAssertEqual(scriptError, ScriptProjectError.noAssetBasePath, "Should throw noAssetBasePath error") + } + } + + func test_initializeProject_throwsWhenAlreadyInitialized() throws { + // Arrange + try manager.initializeProject() + + // Act & Assert + XCTAssertThrowsError(try manager.initializeProject()) { error in + guard let scriptError = error as? ScriptProjectError else { + XCTFail("Should throw ScriptProjectError") + return + } + XCTAssertEqual(scriptError, ScriptProjectError.projectAlreadyExists, "Should throw projectAlreadyExists error") + } + } + + // MARK: - Script Creation Tests + + func test_createNewScript_createsScriptFile() throws { + // Arrange + try manager.initializeProject() + + // Act + try manager.createNewScript(name: "PlayerController") + + // Assert + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let scriptPath = sourcesDir.appendingPathComponent("PlayerController.swift") + XCTAssertTrue(FileManager.default.fileExists(atPath: scriptPath.path), "Script file should be created") + + let content = try String(contentsOf: scriptPath) + XCTAssertTrue(content.contains("PlayerController"), "Script should contain the script name") + XCTAssertTrue(content.contains("extension GenerateScripts"), "Script should contain extension") + XCTAssertTrue(content.contains("generatePlayerController"), "Script should contain generate function") + } + + func test_createNewScript_addsInvocationToMain() throws { + // Arrange + try manager.initializeProject() + + // Act + try manager.createNewScript(name: "EnemyAI") + + // Assert + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let mainScript = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let content = try String(contentsOf: mainScript) + + XCTAssertTrue(content.contains("generateEnemyAI(to: outputDir)"), "Main script should contain invocation") + } + + func test_createNewScript_throwsWhenNotInitialized() { + // Act & Assert + XCTAssertThrowsError(try manager.createNewScript(name: "Test")) { error in + guard let scriptError = error as? ScriptProjectError else { + XCTFail("Should throw ScriptProjectError") + return + } + XCTAssertEqual(scriptError, ScriptProjectError.projectNotInitialized, "Should throw projectNotInitialized error") + } + } + + func test_createNewScript_throwsForEmptyName() throws { + // Arrange + try manager.initializeProject() + + // Act & Assert + XCTAssertThrowsError(try manager.createNewScript(name: "")) { error in + guard let scriptError = error as? ScriptProjectError else { + XCTFail("Should throw ScriptProjectError") + return + } + XCTAssertEqual(scriptError, ScriptProjectError.invalidScriptName, "Should throw invalidScriptName error") + } + } + + func test_createNewScript_throwsForInvalidName() throws { + // Arrange + try manager.initializeProject() + + let invalidNames = [ + "123Invalid", // Starts with number + "Invalid Name", // Contains space + "Invalid-Name", // Contains hyphen + "Invalid.Name", // Contains dot + ] + + // Act & Assert + for name in invalidNames { + XCTAssertThrowsError(try manager.createNewScript(name: name), "Should throw for invalid name: \(name)") { error in + guard let scriptError = error as? ScriptProjectError else { + XCTFail("Should throw ScriptProjectError for \(name)") + return + } + XCTAssertEqual(scriptError, ScriptProjectError.invalidScriptName, "Should throw invalidScriptName for \(name)") + } + } + } + + func test_createNewScript_acceptsValidNames() throws { + // Arrange + try manager.initializeProject() + + let validNames = [ + "PlayerController", + "EnemyAI", + "Item123", + "WeaponSystem", + ] + + // Act & Assert + for name in validNames { + XCTAssertNoThrow(try manager.createNewScript(name: name), "Should not throw for valid name: \(name)") + + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let scriptPath = sourcesDir.appendingPathComponent("\(name).swift") + XCTAssertTrue(FileManager.default.fileExists(atPath: scriptPath.path), "Script should be created for \(name)") + } + } + + func test_createNewScript_doesNotDuplicateInvocation() throws { + // Arrange + try manager.initializeProject() + try manager.createNewScript(name: "Player") + + // Act: Try to create again + try manager.createNewScript(name: "Player") + + // Assert + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let mainScript = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let content = try String(contentsOf: mainScript) + + let matches = content.components(separatedBy: "generatePlayer(to: outputDir)").count - 1 + XCTAssertEqual(matches, 1, "Invocation should only appear once") + } + + // MARK: - List Scripts Tests + + func test_listScriptFiles_returnsEmptyForNewProject() throws { + // Arrange + try manager.initializeProject() + + // Act + let scripts = manager.listScriptFiles() + + // Assert + XCTAssertTrue(scripts.isEmpty, "Should return empty array for new project") + } + + func test_listScriptFiles_returnsCreatedScripts() throws { + // Arrange + try manager.initializeProject() + try manager.createNewScript(name: "Player") + try manager.createNewScript(name: "Enemy") + + // Act + let scripts = manager.listScriptFiles() + + // Assert + XCTAssertEqual(scripts.count, 2, "Should return 2 scripts") + + let names = scripts.map(\.lastPathComponent) + XCTAssertTrue(names.contains("Player.swift"), "Should contain Player.swift") + XCTAssertTrue(names.contains("Enemy.swift"), "Should contain Enemy.swift") + } + + func test_listScriptFiles_excludesGenerateScriptsSwift() throws { + // Arrange + try manager.initializeProject() + try manager.createNewScript(name: "Test") + + // Act + let scripts = manager.listScriptFiles() + + // Assert + let names = scripts.map(\.lastPathComponent) + XCTAssertFalse(names.contains("GenerateScripts.swift"), "Should exclude GenerateScripts.swift") + } + + func test_listScriptFiles_returnsEmptyWhenNoBasePath() { + // Arrange + EditorAssetBasePath.shared.basePath = nil + + // Act + let scripts = manager.listScriptFiles() + + // Assert + XCTAssertTrue(scripts.isEmpty, "Should return empty when no base path") + } + + // MARK: - Remove Script Invocation Tests + + func test_removeScriptInvocationFromMain_removesInvocation() throws { + // Arrange + try manager.initializeProject() + try manager.createNewScript(name: "TestScript") + + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let mainScript = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let contentBefore = try String(contentsOf: mainScript) + XCTAssertTrue(contentBefore.contains("generateTestScript(to: outputDir)"), "Invocation should exist before removal") + + // Act + manager.removeScriptInvocationFromMain(name: "TestScript") + + // Assert + let contentAfter = try String(contentsOf: mainScript) + XCTAssertFalse(contentAfter.contains("generateTestScript(to: outputDir)"), "Invocation should be removed") + } + + func test_removeScriptInvocationFromMain_doesNothingWhenScriptNotFound() throws { + // Arrange + try manager.initializeProject() + + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let mainScript = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let contentBefore = try String(contentsOf: mainScript) + + // Act + manager.removeScriptInvocationFromMain(name: "NonExistent") + + // Assert + let contentAfter = try String(contentsOf: mainScript) + XCTAssertEqual(contentBefore, contentAfter, "Content should remain unchanged") + } + + // MARK: - Template Content Tests + + func test_packageSwiftTemplate_containsRequiredContent() throws { + // Arrange + try manager.initializeProject() + + guard let scriptsDir = manager.scriptsDirectory() else { + XCTFail("Scripts directory should exist") + return + } + + // Act + let packageSwift = scriptsDir.appendingPathComponent("Package.swift") + let content = try String(contentsOf: packageSwift) + + // Assert + XCTAssertTrue(content.contains("swift-tools-version"), "Should contain swift-tools-version") + XCTAssertTrue(content.contains("Package("), "Should contain Package declaration") + XCTAssertTrue(content.contains("name: \"GameScripts\""), "Should contain package name") + XCTAssertTrue(content.contains("platforms:"), "Should contain platforms") + XCTAssertTrue(content.contains(".macOS"), "Should contain macOS platform") + XCTAssertTrue(content.contains("dependencies:"), "Should contain dependencies") + XCTAssertTrue(content.contains("UntoldEngine"), "Should depend on UntoldEngine") + XCTAssertTrue(content.contains("targets:"), "Should contain targets") + XCTAssertTrue(content.contains(".executableTarget"), "Should contain executable target") + } + + func test_generateScriptsTemplate_containsRequiredContent() throws { + // Arrange + try manager.initializeProject() + + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + // Act + let generateScripts = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let content = try String(contentsOf: generateScripts) + + // Assert + XCTAssertTrue(content.contains("@main"), "Should contain @main attribute") + XCTAssertTrue(content.contains("struct GenerateScripts"), "Should contain struct declaration") + XCTAssertTrue(content.contains("static func main()"), "Should contain main function") + XCTAssertTrue(content.contains("#filePath"), "Should use #filePath for output directory") + XCTAssertTrue(content.contains("outputDir"), "Should define outputDir variable") + } + + func test_scriptTemplate_containsRequiredContent() throws { + // Arrange + try manager.initializeProject() + + // Act + try manager.createNewScript(name: "TestController") + + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let scriptFile = sourcesDir.appendingPathComponent("TestController.swift") + let content = try String(contentsOf: scriptFile) + + // Assert + XCTAssertTrue(content.contains("extension GenerateScripts"), "Should extend GenerateScripts") + XCTAssertTrue(content.contains("static func generateTestController"), "Should contain generate function") + XCTAssertTrue(content.contains("buildScript"), "Should use buildScript") + XCTAssertTrue(content.contains("onUpdate()"), "Should contain onUpdate") + XCTAssertTrue(content.contains("saveUSCScript"), "Should save USC script") + XCTAssertTrue(content.contains("TestController.uscript"), "Should reference .uscript output file") + } + + func test_gitignoreTemplate_containsRequiredContent() throws { + // Arrange + try manager.initializeProject() + + guard let scriptsDir = manager.scriptsDirectory() else { + XCTFail("Scripts directory should exist") + return + } + + // Act + let gitignore = scriptsDir.appendingPathComponent(".gitignore") + let content = try String(contentsOf: gitignore) + + // Assert + XCTAssertTrue(content.contains(".build/"), "Should ignore .build directory") + XCTAssertTrue(content.contains("Generated/"), "Should ignore Generated directory") + XCTAssertTrue(content.contains(".DS_Store"), "Should ignore .DS_Store") + } + + // MARK: - Integration Tests + + func test_fullWorkflow_initializeCreateListRemove() throws { + // 1. Initialize + try manager.initializeProject() + XCTAssertTrue(manager.isProjectInitialized(), "Project should be initialized") + + // 2. Create scripts + try manager.createNewScript(name: "Player") + try manager.createNewScript(name: "Enemy") + try manager.createNewScript(name: "Weapon") + + // 3. List scripts + let scripts = manager.listScriptFiles() + XCTAssertEqual(scripts.count, 3, "Should have 3 scripts") + + // 4. Remove one script invocation + manager.removeScriptInvocationFromMain(name: "Enemy") + + // Verify removal + guard let sourcesDir = manager.sourcesDirectory() else { + XCTFail("Sources directory should exist") + return + } + + let mainScript = sourcesDir.appendingPathComponent("GenerateScripts.swift") + let content = try String(contentsOf: mainScript) + + XCTAssertTrue(content.contains("generatePlayer(to: outputDir)"), "Player invocation should remain") + XCTAssertFalse(content.contains("generateEnemy(to: outputDir)"), "Enemy invocation should be removed") + XCTAssertTrue(content.contains("generateWeapon(to: outputDir)"), "Weapon invocation should remain") + } +} diff --git a/Tests/UntoldEditorTests/SelectionManagerTests.swift b/Tests/UntoldEditorTests/SelectionManagerTests.swift new file mode 100644 index 0000000..c96bd41 --- /dev/null +++ b/Tests/UntoldEditorTests/SelectionManagerTests.swift @@ -0,0 +1,377 @@ +// +// SelectionManagerTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. + +import Combine +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class SelectionManagerTests: XCTestCase { + private var selectionManager: SelectionManager! + private var sceneGraphModel: SceneGraphModel! + + override func setUp() { + super.setUp() + + // Create fresh scene + scene = Scene() + + // Initialize managers + selectionManager = SelectionManager() + sceneGraphModel = SceneGraphModel() + } + + override func tearDown() { + super.tearDown() + } + + // MARK: - Helper Methods + + private func createEntityWithTransform(name: String = "Test Entity") -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerComponent(entityId: entity, componentType: LocalTransformComponent.self) + registerComponent(entityId: entity, componentType: WorldTransformComponent.self) + return entity + } + + // MARK: - SceneGraphModel Tests + + func test_sceneGraphModel_refreshHierarchy_buildsCorrectMapping() { + // Arrange + let root1 = createEntityWithTransform(name: "Root1") + let root2 = createEntityWithTransform(name: "Root2") + registerComponent(entityId: root1, componentType: ScenegraphComponent.self) + registerComponent(entityId: root2, componentType: ScenegraphComponent.self) + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert: Both entities should appear at root level + let rootChildren = sceneGraphModel.getChildren(entityId: nil) + XCTAssertTrue(rootChildren.contains(root1), "Root1 should be at root level") + XCTAssertTrue(rootChildren.contains(root2), "Root2 should be at root level") + } + + func test_sceneGraphModel_refreshHierarchy_handlesEntitiesWithoutScenegraphComponent() { + // Arrange: Create entity without ScenegraphComponent (like cameras) + let camera = createEntityWithTransform(name: "Camera") + // Don't register ScenegraphComponent + + let regularEntity = createEntityWithTransform(name: "Regular") + registerComponent(entityId: regularEntity, componentType: ScenegraphComponent.self) + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert: Both should appear at root level + let rootChildren = sceneGraphModel.getChildren(entityId: nil) + XCTAssertTrue(rootChildren.contains(camera), "Camera without ScenegraphComponent should be at root") + XCTAssertTrue(rootChildren.contains(regularEntity), "Regular entity should be at root") + } + + func test_sceneGraphModel_getChildren_returnsEmptyForNonexistentParent() { + // Arrange + sceneGraphModel.refreshHierarchy() + + // Act + let children = sceneGraphModel.getChildren(entityId: EntityID(9999)) + + // Assert + XCTAssertTrue(children.isEmpty, "Should return empty array for nonexistent parent") + } + + func test_sceneGraphModel_refreshHierarchy_handlesEmptyScene() { + // Act + sceneGraphModel.refreshHierarchy() + + // Assert + let rootChildren = sceneGraphModel.getChildren(entityId: nil) + XCTAssertTrue(rootChildren.isEmpty, "Empty scene should have no root children") + } + + func test_sceneGraphModel_refreshHierarchy_withMultipleEntities() { + // Arrange: Create several entities + let entities = (0 ..< 10).map { i in + let e = createEntityWithTransform(name: "Entity\(i)") + registerComponent(entityId: e, componentType: ScenegraphComponent.self) + return e + } + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert + let rootChildren = sceneGraphModel.getChildren(entityId: nil) + XCTAssertEqual(rootChildren.count, entities.count, "All entities should be at root level") + } + + func test_sceneGraphModel_refreshHierarchy_afterEntityDestruction() { + // Arrange + let entity1 = createEntityWithTransform(name: "Entity1") + let entity2 = createEntityWithTransform(name: "Entity2") + registerComponent(entityId: entity1, componentType: ScenegraphComponent.self) + registerComponent(entityId: entity2, componentType: ScenegraphComponent.self) + + sceneGraphModel.refreshHierarchy() + let beforeCount = sceneGraphModel.getChildren(entityId: nil).count + + // Act: Destroy one entity and refresh + destroyEntity(entityId: entity1) + sceneGraphModel.refreshHierarchy() + + // Assert + let afterCount = sceneGraphModel.getChildren(entityId: nil).count + XCTAssertEqual(afterCount, beforeCount - 1, "Destroyed entity should not appear in hierarchy") + } + + func test_sceneGraphModel_childrenMap_isPublished() { + // Arrange + var mapUpdateCount = 0 + let cancellable = sceneGraphModel.$childrenMap.sink { _ in + mapUpdateCount += 1 + } + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert + XCTAssertGreaterThan(mapUpdateCount, 0, "childrenMap should publish updates") + + cancellable.cancel() + } + + // MARK: - SelectionManager Basic State Tests (No Gizmo Creation) + + func test_selectionManager_selectedEntity_canBeSet() { + // Arrange + let entity = createEntityWithTransform(name: "TestEntity") + + // Act + selectionManager.selectedEntity = entity + + // Assert + XCTAssertEqual(selectionManager.selectedEntity, entity, "Selected entity should be set") + } + + func test_selectionManager_selectedEntity_canBeCleared() { + // Arrange + let entity = createEntityWithTransform(name: "TestEntity") + selectionManager.selectedEntity = entity + + // Act + selectionManager.selectedEntity = nil + + // Assert + XCTAssertNil(selectionManager.selectedEntity, "Selected entity should be cleared") + } + + func test_selectionManager_selectedEntity_isPublished() { + // Arrange + var receivedValues: [EntityID?] = [] + let cancellable = selectionManager.$selectedEntity.sink { entityId in + receivedValues.append(entityId) + } + + let entity = createEntityWithTransform(name: "Test") + + // Act + selectionManager.selectedEntity = entity + + // Assert + XCTAssertTrue(receivedValues.contains(entity), "Should receive entity ID in published updates") + + cancellable.cancel() + } + + func test_selectionManager_selectedEntity_publishes_objectWillChange() { + // Arrange + var changeCount = 0 + let cancellable = selectionManager.objectWillChange.sink { _ in + changeCount += 1 + } + + let entity = createEntityWithTransform(name: "Test") + + // Act + selectionManager.selectedEntity = entity + + // Assert + XCTAssertGreaterThan(changeCount, 0, "Should publish objectWillChange") + + cancellable.cancel() + } + + func test_selectionManager_multipleSelections_updatesPublisher() { + // Arrange + var receivedValues: [EntityID?] = [] + let cancellable = selectionManager.$selectedEntity.sink { entityId in + receivedValues.append(entityId) + } + + let entity1 = createEntityWithTransform(name: "First") + let entity2 = createEntityWithTransform(name: "Second") + + // Act + selectionManager.selectedEntity = entity1 + selectionManager.selectedEntity = entity2 + + // Assert + XCTAssertEqual(receivedValues.count, 3, "Should receive initial + 2 updates") + XCTAssertEqual(receivedValues[1], entity1, "First update should be entity1") + XCTAssertEqual(receivedValues[2], entity2, "Second update should be entity2") + + cancellable.cancel() + } + + func test_selectionManager_invalidEntity_canBeSelected() { + // Act + selectionManager.selectedEntity = .invalid + + // Assert + XCTAssertEqual(selectionManager.selectedEntity, .invalid, "Invalid entity can be set") + } + + func test_selectionManager_selectingDestroyedEntity_doesNotCrash() { + // Arrange + let entity = createEntityWithTransform(name: "ToBeDestroyed") + destroyEntity(entityId: entity) + + // Act & Assert: Should not crash + selectionManager.selectedEntity = entity + XCTAssertEqual(selectionManager.selectedEntity, entity, "Destroyed entity can be set") + } + + // MARK: - Integration Tests + + func test_selectionManager_withSceneGraphModel_bothPublishUpdates() { + // Arrange + var selectionUpdates = 0 + var hierarchyUpdates = 0 + + let selectionCancellable = selectionManager.objectWillChange.sink { _ in + selectionUpdates += 1 + } + + let hierarchyCancellable = sceneGraphModel.$childrenMap.sink { _ in + hierarchyUpdates += 1 + } + + let entity = createEntityWithTransform(name: "Entity") + registerComponent(entityId: entity, componentType: ScenegraphComponent.self) + + // Act + sceneGraphModel.refreshHierarchy() + selectionManager.selectedEntity = entity + + // Assert + XCTAssertGreaterThan(selectionUpdates, 0, "Selection should publish updates") + XCTAssertGreaterThan(hierarchyUpdates, 0, "Hierarchy should publish updates") + + selectionCancellable.cancel() + hierarchyCancellable.cancel() + } + + // MARK: - SceneGraphModel Edge Cases + + func test_sceneGraphModel_getChildren_withNilEntityId_returnsRootEntities() { + // Arrange + let entity = createEntityWithTransform(name: "Root") + registerComponent(entityId: entity, componentType: ScenegraphComponent.self) + + sceneGraphModel.refreshHierarchy() + + // Act + let rootChildren = sceneGraphModel.getChildren(entityId: nil) + + // Assert + XCTAssertTrue(rootChildren.contains(entity), "Nil entity ID should return root entities") + } + + func test_sceneGraphModel_refreshHierarchy_idempotent() { + // Arrange + let entity = createEntityWithTransform(name: "Entity") + registerComponent(entityId: entity, componentType: ScenegraphComponent.self) + + // Act: Refresh multiple times + sceneGraphModel.refreshHierarchy() + let firstResult = sceneGraphModel.getChildren(entityId: nil) + + sceneGraphModel.refreshHierarchy() + let secondResult = sceneGraphModel.getChildren(entityId: nil) + + // Assert: Results should be the same + XCTAssertEqual(firstResult.count, secondResult.count, "Multiple refreshes should produce same results") + XCTAssertEqual(Set(firstResult), Set(secondResult), "Entity sets should match") + } + + // MARK: - SelectionManager State Transitions + + func test_selectionManager_fromNilToEntity() { + // Arrange + XCTAssertEqual(selectionManager.selectedEntity, .invalid, "Should start as invalid") + + let entity = createEntityWithTransform(name: "Selected") + + // Act + selectionManager.selectedEntity = entity + + // Assert + XCTAssertEqual(selectionManager.selectedEntity, entity, "Should transition to selected entity") + } + + func test_selectionManager_fromEntityToNil() { + // Arrange + let entity = createEntityWithTransform(name: "Selected") + selectionManager.selectedEntity = entity + + // Act + selectionManager.selectedEntity = nil + + // Assert + XCTAssertNil(selectionManager.selectedEntity, "Should transition to nil") + } + + func test_selectionManager_fromEntityToEntity() { + // Arrange + let entity1 = createEntityWithTransform(name: "First") + let entity2 = createEntityWithTransform(name: "Second") + selectionManager.selectedEntity = entity1 + + // Act + selectionManager.selectedEntity = entity2 + + // Assert + XCTAssertEqual(selectionManager.selectedEntity, entity2, "Should transition to second entity") + } + + func test_selectionManager_selectingSameEntityTwice_stillPublishes() { + // Arrange + var updateCount = 0 + let cancellable = selectionManager.objectWillChange.sink { _ in + updateCount += 1 + } + + let entity = createEntityWithTransform(name: "Same") + + // Act + selectionManager.selectedEntity = entity + let afterFirstSelect = updateCount + + selectionManager.selectedEntity = entity + let afterSecondSelect = updateCount + + // Assert + XCTAssertGreaterThan(afterSecondSelect, afterFirstSelect, "Should publish even when selecting same entity") + + cancellable.cancel() + } +}