import XCTest import Combine @testable import CommandNotch @MainActor final class NotchOrchestratorTests: XCTestCase { func testHoverOpenSchedulesOpenAfterDelay() { let screenID = "screen-a" let screen = makeScreenContext(screenID: screenID) let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen]) let host = TestNotchPresentationHost() let scheduler = TestScheduler() let orchestrator = NotchOrchestrator( screenRegistry: registry, host: host, settingsController: makeSettingsController(), scheduler: scheduler ) orchestrator.handleHoverChange(true, for: screenID) XCTAssertEqual(screen.notchState, .closed) scheduler.runScheduledActions() XCTAssertEqual(screen.notchState, .open) XCTAssertEqual(host.openedScreenIDs, [screenID]) } func testHoverExitCancelsPendingOpen() { let screenID = "screen-a" let screen = makeScreenContext(screenID: screenID) let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen]) let host = TestNotchPresentationHost() let scheduler = TestScheduler() let orchestrator = NotchOrchestrator( screenRegistry: registry, host: host, settingsController: makeSettingsController(), scheduler: scheduler ) orchestrator.handleHoverChange(true, for: screenID) orchestrator.handleHoverChange(false, for: screenID) scheduler.runScheduledActions() XCTAssertEqual(screen.notchState, .closed) XCTAssertTrue(host.openedScreenIDs.isEmpty) } func testCloseWhileHoveringSuppressesReopenUntilHoverExit() { let screenID = "screen-a" let screen = makeScreenContext(screenID: screenID) let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen]) let host = TestNotchPresentationHost() let scheduler = TestScheduler() let orchestrator = NotchOrchestrator( screenRegistry: registry, host: host, settingsController: makeSettingsController(), scheduler: scheduler ) orchestrator.handleHoverChange(true, for: screenID) scheduler.runScheduledActions() XCTAssertEqual(screen.notchState, .open) orchestrator.handleHoverChange(true, for: screenID) orchestrator.close(screenID: screenID) scheduler.runScheduledActions() XCTAssertEqual(screen.notchState, .closed) XCTAssertFalse(screen.isCloseTransitionActive) XCTAssertTrue(screen.suppressHoverOpenUntilHoverExit) XCTAssertEqual(host.closedScreenIDs, [screenID]) scheduler.runScheduledActions() XCTAssertEqual(screen.notchState, .closed) XCTAssertEqual(host.openedScreenIDs, [screenID]) orchestrator.handleHoverChange(false, for: screenID) XCTAssertFalse(screen.suppressHoverOpenUntilHoverExit) } func testOpeningSharedWorkspaceOnAnotherScreenClosesPreviousPresenter() { let workspaceID = UUID() let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: workspaceID) let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: workspaceID) let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen]) let host = TestNotchPresentationHost() let orchestrator = NotchOrchestrator( screenRegistry: registry, host: host, settingsController: makeSettingsController(), scheduler: TestScheduler() ) orchestrator.open(screenID: "screen-a") orchestrator.open(screenID: "screen-b") XCTAssertEqual(firstScreen.notchState, .closed) XCTAssertEqual(secondScreen.notchState, .open) XCTAssertEqual(host.closedScreenIDs, ["screen-a"]) XCTAssertEqual(registry.presentingScreenID(for: workspaceID), "screen-b") } func testOpeningDifferentWorkspaceDoesNotCloseOtherOpenScreen() { let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: UUID()) let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: UUID()) let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen]) let host = TestNotchPresentationHost() let orchestrator = NotchOrchestrator( screenRegistry: registry, host: host, settingsController: makeSettingsController(), scheduler: TestScheduler() ) orchestrator.open(screenID: "screen-a") orchestrator.open(screenID: "screen-b") XCTAssertEqual(firstScreen.notchState, .open) XCTAssertEqual(secondScreen.notchState, .open) XCTAssertTrue(host.closedScreenIDs.isEmpty) XCTAssertEqual(registry.presentingScreenID(for: firstScreen.workspaceID), "screen-a") XCTAssertEqual(registry.presentingScreenID(for: secondScreen.workspaceID), "screen-b") } private func makeScreenContext(screenID: ScreenID, workspaceID: WorkspaceID = UUID()) -> ScreenContext { ScreenContext( id: screenID, workspaceID: workspaceID, settingsController: makeSettingsController(), screenProvider: { _ in nil } ) } private func makeSettingsController() -> AppSettingsController { let store = TestOrchestratorSettingsStore() var settings = AppSettings.default settings.behavior.openNotchOnHover = true settings.behavior.minimumHoverDuration = 0.3 store.storedSettings = settings return AppSettingsController(store: store) } } @MainActor private final class TestScreenRegistry: ScreenRegistryType { private let activeID: ScreenID private var screensByID: [ScreenID: ScreenContext] private var workspacePresenters: [WorkspaceID: ScreenID] = [:] init(activeScreenID: ScreenID, screens: [ScreenContext]) { self.activeID = activeScreenID self.screensByID = Dictionary(uniqueKeysWithValues: screens.map { ($0.id, $0) }) } func allScreens() -> [ScreenContext] { Array(screensByID.values) } func screenContext(for id: ScreenID) -> ScreenContext? { screensByID[id] } func activeScreenID() -> ScreenID? { activeID } func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? { workspacePresenters[workspaceID] } func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? { guard let workspaceID = screensByID[screenID]?.workspaceID else { return nil } let previous = workspacePresenters[workspaceID] workspacePresenters[workspaceID] = screenID return previous == screenID ? nil : previous } func releaseWorkspacePresentation(for screenID: ScreenID) { workspacePresenters = workspacePresenters.filter { $0.value != screenID } } } @MainActor private final class TestNotchPresentationHost: NotchPresentationHost { var openedScreenIDs: [ScreenID] = [] var closedScreenIDs: [ScreenID] = [] func canPresentNotch(for screenID: ScreenID) -> Bool { true } func performOpenPresentation(for screenID: ScreenID) { openedScreenIDs.append(screenID) } func performClosePresentation(for screenID: ScreenID) { closedScreenIDs.append(screenID) } } private final class TestScheduler: SchedulerType { private final class ScheduledAction { let action: @MainActor () -> Void var isCancelled = false init(action: @escaping @MainActor () -> Void) { self.action = action } } private var scheduledActions: [ScheduledAction] = [] @MainActor func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable { let scheduledAction = ScheduledAction(action: action) scheduledActions.append(scheduledAction) return AnyCancellable { scheduledAction.isCancelled = true } } @MainActor func runScheduledActions() { let actions = scheduledActions scheduledActions.removeAll() for scheduledAction in actions where !scheduledAction.isCancelled { scheduledAction.action() } } } private final class TestOrchestratorSettingsStore: AppSettingsStoreType { var storedSettings = AppSettings.default func load() -> AppSettings { storedSettings } func save(_ settings: AppSettings) { storedSettings = settings } }