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() { // Die Absicht ist unverändert: wer die Notch nur streift, bekommt kein // Panel. Der Weg dorthin führt seit der Toleranz für zitternde Hände // über die Abbruchfrist statt über einen sofortigen Abbruch. var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) _ = machine.handle(.pointerExited) let effects = machine.handle(.closeTimerFired) #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) // Worauf es ankommt, ist das Schließen. Dass dabei ein womöglich // laufender Schließtimer abbestellt wird, gehört dazu — aus `closing` // heraus läuft er, und ein verspäteter Zeitgeber hat nichts mehr zu // melden. #expect(effects.contains(.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.contains(.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() { // Worauf es ankommt: die Entprellzeit setzt nicht neu an. Dass dabei // eine ohnehin nicht laufende Abbruchfrist gestoppt wird, ist folgenlos. var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) let effects = machine.handle(.pointerEntered) #expect(machine.phase == .arming) #expect(!effects.contains(.startArmTimer(NotchStateMachine.armDelay))) } @Test("Verlassen im Ruhezustand tut nichts") func exitWhileIdleIsNoop() { var machine = NotchStateMachine() let effects = machine.handle(.pointerExited) #expect(machine.phase == .idle) #expect(effects.isEmpty) } @Test("Entprellzeit läuft ab, während der Zeiger draußen ist → kein Öffnen") func armTimerWhileOutsideDoesNotOpen() { // Der Fall, der ohne Zeigerkenntnis das Panel aufblitzen ließe: die // Notch beiläufig streifen. Entprellzeit läuft ab, obwohl der Zeiger // längst weg ist — geöffnet wird trotzdem nicht. var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) _ = machine.handle(.pointerExited) let effects = machine.handle(.armTimerFired) #expect(!effects.contains(.show)) #expect(machine.phase != .open) } @Test("Kommt der Zeiger nach abgelaufener Entprellzeit zurück, geht es sofort auf") func returnAfterDebounceOpensAtOnce() { // Sonst müsste eine zitternde Hand die volle Entprellzeit noch einmal // durchstehen, obwohl sie längst gewartet hat. var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) _ = machine.handle(.pointerExited) _ = machine.handle(.armTimerFired) let effects = machine.handle(.pointerEntered) #expect(machine.phase == .open) #expect(effects.contains(.show)) } @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) } } // Nachgetragen, nachdem sich das Panel „manchmal nicht öffnete". Ein Zeiger, // der von Hand an den oberen Rand geführt wird, steht dort nicht still — er // wandert um ein paar Punkte. Jedes Abtastbild außerhalb hat das Aufziehen // sofort abgebrochen, und beim nächsten fing es von vorn an. Bei 220 ms // Entprellzeit kam es so nie ans Ziel. // // Automatisierte Proben haben das nie gezeigt: `CGWarpMouseCursorPosition` // setzt den Zeiger auf einen Punkt und hält ihn absolut still. @Suite("Toleranz beim Aufziehen") struct NotchArmingToleranceTests { @Test("Kurz herausrutschen bricht das Aufziehen nicht ab") func briefExitKeepsArming() { var machine = NotchStateMachine() machine.handle(.pointerEntered) #expect(machine.phase == .arming) machine.handle(.pointerExited) // Immer noch am Aufziehen — es läuft nur eine Frist. #expect(machine.phase == .arming) } @Test("Kommt der Zeiger zurück, läuft es weiter") func returningResumes() { var machine = NotchStateMachine() machine.handle(.pointerEntered) machine.handle(.pointerExited) machine.handle(.pointerEntered) machine.handle(.armTimerFired) #expect(machine.phase == .open) } @Test("Bleibt er draußen, wird abgebrochen") func lastingExitCancels() { var machine = NotchStateMachine() machine.handle(.pointerEntered) machine.handle(.pointerExited) machine.handle(.closeTimerFired) #expect(machine.phase == .idle) } @Test("Beim Abbrechen wird auch der Aufziehzeitgeber gestoppt") func cancelStopsArmTimer() { // Sonst fährt das Panel aus, nachdem der Zeiger längst weg ist. var machine = NotchStateMachine() machine.handle(.pointerEntered) machine.handle(.pointerExited) let effects = machine.handle(.closeTimerFired) #expect(effects.contains(.cancelArmTimer)) } @Test("Das Aufziehen setzt beim Herausrutschen nicht neu an") func exitDoesNotRestartArmTimer() { // Sonst dauert es bei zitternder Hand endlos, weil die Entprellzeit // bei jedem Grenzübertritt von vorn beginnt. var machine = NotchStateMachine() machine.handle(.pointerEntered) machine.handle(.pointerExited) let effects = machine.handle(.pointerEntered) #expect(!effects.contains(.startArmTimer(NotchStateMachine.armDelay))) #expect(effects.contains(.cancelCloseTimer)) } } // Ein fixiertes Panel ließ sich nur durch einen erneuten Klick in die Notch // oder mit Escape lösen. Wer nicht weiß, dass ein Klick fixiert, sitzt vor // einem Panel, das offen steht und auf nichts reagiert — und sucht den Fehler // in der App statt in seiner eigenen Mausbewegung. @Suite("Klick daneben") struct NotchOutsideClickTests { @Test("Ein Klick daneben schließt das fixierte Panel") func outsideClickReleasesPinned() { var machine = NotchStateMachine() machine.forcePhase(.pinned) let effects = machine.handle(.clickedOutside) #expect(machine.phase == .idle) #expect(effects.contains(.hide)) } @Test("Auch ein offenes Panel schließt damit") func outsideClickClosesOpen() { var machine = NotchStateMachine() machine.forcePhase(.open) #expect(machine.handle(.clickedOutside).contains(.hide)) #expect(machine.phase == .idle) } @Test("Im Ruhezustand passiert nichts") func outsideClickWhileIdleIsNoop() { var machine = NotchStateMachine() #expect(machine.handle(.clickedOutside).isEmpty) #expect(machine.phase == .idle) } @Test("Beim Aufziehen bricht ein Klick daneben ab") func outsideClickCancelsArming() { // Sonst fährt das Panel aus, nachdem man längst woanders geklickt hat. var machine = NotchStateMachine() _ = machine.handle(.pointerEntered) let effects = machine.handle(.clickedOutside) #expect(machine.phase == .idle) #expect(effects.contains(.cancelArmTimer)) } }