import Testing @testable import OnyxNotch // Die Zustandsmaschine ist die Spezifikation des Panelverhaltens. Sie ist bewusst // frei von AppKit: keine Fenster, keine Timer, keine Uhr. Zeit kommt ausschließlich // als Eingabe herein (`.armTimerFired` / `.closeTimerFired`), damit jeder Übergang // ohne Warten prüfbar ist. @Suite("Notch-Zustandsmaschine") struct NotchStateMachineTests { // MARK: - Öffnen @Test("Zeiger betritt die Notch → Entprellung startet, Panel bleibt zu") func pointerEntersStartsArming() { var machine = NotchStateMachine() let effects = machine.handle(.pointerEntered) #expect(machine.phase == .arming) #expect(effects == [.startArmTimer(NotchStateMachine.armDelay)]) } @Test("Entprellzeit abgelaufen → Panel öffnet") func armTimerOpens() { var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) let effects = machine.handle(.armTimerFired) #expect(machine.phase == .open) #expect(effects == [.show]) } @Test("Zeiger verlässt die Notch vor Ablauf → kein Öffnen") func leavingDuringArmingCancels() { var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) let effects = machine.handle(.pointerExited) #expect(machine.phase == .idle) #expect(effects == [.cancelArmTimer]) } // MARK: - Schließen @Test("Zeiger verlässt geöffnetes Panel → Nachlauffrist, noch kein Schließen") func leavingOpenStartsGrace() { var machine = NotchStateMachine() machine.forcePhase(.open) let effects = machine.handle(.pointerExited) #expect(machine.phase == .closing) #expect(effects == [.startCloseTimer(NotchStateMachine.closeDelay)]) } @Test("Rückkehr innerhalb der Nachlauffrist → Panel bleibt offen") func returningDuringGraceKeepsOpen() { var machine = NotchStateMachine() machine.forcePhase(.open) _ = machine.handle(.pointerExited) let effects = machine.handle(.pointerEntered) #expect(machine.phase == .open) #expect(effects == [.cancelCloseTimer]) } @Test("Nachlauffrist abgelaufen → Panel schließt") func graceExpiryCloses() { var machine = NotchStateMachine() machine.forcePhase(.open) _ = machine.handle(.pointerExited) let effects = machine.handle(.closeTimerFired) #expect(machine.phase == .idle) #expect(effects == [.hide]) } // MARK: - Fixieren @Test("Klick fixiert das offene Panel") func clickPins() { var machine = NotchStateMachine() machine.forcePhase(.open) let effects = machine.handle(.click) #expect(machine.phase == .pinned) #expect(effects == [.cancelCloseTimer]) } @Test("Fixiertes Panel bleibt offen, wenn der Zeiger weggeht — das ist der Zweck") func pinnedIgnoresPointerExit() { var machine = NotchStateMachine() machine.forcePhase(.pinned) let effects = machine.handle(.pointerExited) #expect(machine.phase == .pinned) #expect(effects.isEmpty) } @Test("Zweiter Klick löst die Fixierung, Panel bleibt offen solange der Zeiger drin ist") func secondClickUnpins() { var machine = NotchStateMachine() machine.forcePhase(.pinned) let effects = machine.handle(.click) #expect(machine.phase == .open) #expect(effects.isEmpty) } @Test("esc löst die Fixierung und schließt") func escapeClosesPinned() { var machine = NotchStateMachine() machine.forcePhase(.pinned) let effects = machine.handle(.escape) #expect(machine.phase == .idle) #expect(effects == [.hide]) } @Test("esc schließt auch ein nur geöffnetes Panel") func escapeClosesOpen() { var machine = NotchStateMachine() machine.forcePhase(.open) let effects = machine.handle(.escape) #expect(machine.phase == .idle) #expect(effects == [.hide]) } @Test("esc im Ruhezustand tut nichts") func escapeIdleIsNoop() { var machine = NotchStateMachine() let effects = machine.handle(.escape) #expect(machine.phase == .idle) #expect(effects.isEmpty) } // MARK: - Unterdrückung (Vollbildvideo, „Nicht stören") @Test("Unterdrückung schließt ein offenes Panel und räumt die Timer ab") func suppressionClosesOpen() { var machine = NotchStateMachine() machine.forcePhase(.open) let effects = machine.handle(.suppressed(true)) #expect(machine.phase == .idle) #expect(effects == [.cancelArmTimer, .cancelCloseTimer, .hide]) } @Test("Unterdrückung löst auch die Fixierung — sonst klebt das Panel im Vollbild") func suppressionBeatsPinning() { var machine = NotchStateMachine() machine.forcePhase(.pinned) _ = machine.handle(.suppressed(true)) #expect(machine.phase == .idle) } @Test("Während der Unterdrückung öffnet Hovern nicht") func suppressedIgnoresHover() { var machine = NotchStateMachine() _ = machine.handle(.suppressed(true)) let effects = machine.handle(.pointerEntered) #expect(machine.phase == .idle) #expect(effects.isEmpty) } @Test("Nach dem Ende der Unterdrückung öffnet Hovern wieder") func unsuppressingRestoresHover() { var machine = NotchStateMachine() _ = machine.handle(.suppressed(true)) _ = machine.handle(.pointerEntered) _ = machine.handle(.suppressed(false)) _ = machine.handle(.pointerEntered) #expect(machine.phase == .arming) } @Test("Ende der Unterdrückung öffnet nicht von selbst, auch wenn der Zeiger dort steht") func unsuppressingDoesNotAutoOpen() { var machine = NotchStateMachine() _ = machine.handle(.suppressed(true)) let effects = machine.handle(.suppressed(false)) #expect(machine.phase == .idle) #expect(effects.isEmpty) } // MARK: - Robustheit gegen doppelte Ereignisse @Test("Zweimal betreten ohne zwischenzeitliches Verlassen startet keinen zweiten Timer") func repeatedEnterIsIdempotent() { var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) let effects = machine.handle(.pointerEntered) #expect(machine.phase == .arming) #expect(effects.isEmpty) } @Test("Verlassen im Ruhezustand tut nichts") func exitWhileIdleIsNoop() { var machine = NotchStateMachine() let effects = machine.handle(.pointerExited) #expect(machine.phase == .idle) #expect(effects.isEmpty) } @Test("Verspäteter Timer nach dem Schließen öffnet nicht mehr") func staleArmTimerDoesNotOpen() { var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) _ = machine.handle(.pointerExited) // Timer wurde abbestellt … let effects = machine.handle(.armTimerFired) // … feuert aber trotzdem noch #expect(machine.phase == .idle) #expect(effects.isEmpty) } @Test("Verspäteter Schließtimer schließt kein wieder geöffnetes Panel") func staleCloseTimerDoesNotHide() { var machine = NotchStateMachine() machine.forcePhase(.open) _ = machine.handle(.pointerExited) _ = machine.handle(.pointerEntered) // zurück, Timer abbestellt … let effects = machine.handle(.closeTimerFired) // … feuert trotzdem #expect(machine.phase == .open) #expect(effects.isEmpty) } @Test("Klick im Ruhezustand öffnet nicht — sonst öffnet jeder Klick oben am Rand") func clickWhileIdleIsNoop() { var machine = NotchStateMachine() let effects = machine.handle(.click) #expect(machine.phase == .idle) #expect(effects.isEmpty) } // MARK: - Zeitkonstanten @Test("Entprellung und Nachlauf entsprechen dem Plan") func timingConstants() { #expect(NotchStateMachine.armDelay == 0.22) #expect(NotchStateMachine.closeDelay == 0.30) } }