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()
+ }
+}