diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 2b7272a4..ae8dba16 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -36,6 +36,9 @@ 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; }; 48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; }; 5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */; }; + 5E1EC110000000000000C002 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; }; + 5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; }; + 5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C101 /* SelectionResolverTests.swift */; }; 5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */; }; 5F0A63032F82D97C00AE4C38 /* LicenseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0A63012F82D97C00AE4C38 /* LicenseManager.swift */; }; 5F0A63042F82D97C00AE4C38 /* LicenseState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0A63022F82D97C00AE4C38 /* LicenseState.swift */; }; @@ -336,6 +339,8 @@ 481FE54624D2D387001032F1 /* alt-tab-macos-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "alt-tab-macos-Bridging-Header.h"; sourceTree = ""; }; 4B4409E4B3D98C491503CE5D /* OverrideClickResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideClickResolverTests.swift; sourceTree = ""; }; 5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadEvents.swift; sourceTree = ""; }; + 5E1EC110000000000000C001 /* SelectionResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionResolver.swift; sourceTree = ""; }; + 5E1EC110000000000000C101 /* SelectionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionResolverTests.swift; sourceTree = ""; }; 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = ""; }; 5F05D0E62F31441A004A95F9 /* AGENTS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = AGENTS.md; sourceTree = ""; }; 5F05D0E72F31441A004A95F9 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; @@ -835,6 +840,7 @@ 5F791E4A2F1C65E100B6419C /* ApplicationDiscriminator.swift */, 5F906A332F16A18000B99924 /* Screens.swift */, D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */, + 5E1EC110000000000000C001 /* SelectionResolver.swift */, ); path = state; sourceTree = ""; @@ -905,6 +911,7 @@ BF0CBBBB0000000000000001 /* OnActionExtensionTests.swift */, 25966EE691FC6871588D796B /* ProBadgeViewSegmentTests.swift */, 4B4409E4B3D98C491503CE5D /* OverrideClickResolverTests.swift */, + 5E1EC110000000000000C101 /* SelectionResolverTests.swift */, BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */, BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */, BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */, @@ -1748,6 +1755,8 @@ A053BAD6681629878E72B1F7 /* ProBadgeViewSegmentTests.swift in Sources */, DC8CF4F01C999497352736F1 /* OverrideClickResolverTests.swift in Sources */, DC06402457ECCE6C902FEAE4 /* OverrideClickResolver.swift in Sources */, + 5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */, + 5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */, 8B4A0B173EEB0648424219B5 /* ProBadgeView.swift in Sources */, 6D42BE46F595D567EDBF559A /* DynamicColorTextField.swift in Sources */, BF0C8C5FA4B401A8C0F75B7A /* KeyboardEventsTestable.swift in Sources */, @@ -1789,6 +1798,7 @@ 5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */, D04BA8480A8FF466CA89DA5B /* main.swift in Sources */, D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */, + 5E1EC110000000000000C002 /* SelectionResolver.swift in Sources */, F00DCAFE0000000000001002 /* Search.swift in Sources */, F00DCAFE0000000000001003 /* SearchTestable.swift in Sources */, D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */, diff --git a/src/switcher/state/SelectionResolver.swift b/src/switcher/state/SelectionResolver.swift new file mode 100644 index 00000000..59568ec1 --- /dev/null +++ b/src/switcher/state/SelectionResolver.swift @@ -0,0 +1,157 @@ +import Foundation + +/// One window as seen by the selection kernel — just the fields selection logic needs. +/// Decouples the pure decision from the `Window` AppKit class so this is unit-testable. +struct SelectionWindow: Equatable { + let id: String + let visible: Bool + let lastFocusOrder: Int + let isMinimized: Bool + let isWindowlessApp: Bool +} + +/// Snapshot of everything the kernel needs to pick the next selection. No globals, no AppKit. +struct SelectionInputs: Equatable { + let list: [SelectionWindow] + let selectedIndex: Int + let selectedTarget: String? + /// True iff `Preferences.windowOrder[shortcutIndex] != .recentlyFocused` + /// AND `Applications.frontmostPid != nil`. Gates the alpha/space-ordering initial-pick path. + let useLastFocusedRule: Bool + let restoreDefaultOnSearchClear: Bool + let bestMatchOnSearchChange: Bool +} + +/// What the kernel recommends. Wrapper translates this into side effects (highlight redraws, +/// scroll-to-visible, thumbnail preview, etc.). +enum SelectionDecision: Equatable { + /// Post-firstVisible-guard empty case. Wrapper: clear `selectedTarget` and `hoveredIndex`. + case clearTargetAndHover + + /// "From scratch" initial-pick path with a valid pick. Wrapper: clear hovered + reset + /// `selectedTarget` first, then move selection to `index`. + case resetThenSelect(Int) + + /// "From scratch" but list has nothing to land on (e.g. search-clear with no visible). + /// Wrapper: clear hovered + reset `selectedTarget`; leave `selectedIndex`. + case resetWithoutSelection + + /// Move selection to `index`. `selectedTarget` follows to `list[index].id`. + case selectAt(Int) + + /// `selectedIndex` is fine; just ensure `selectedTarget == list[index].id` (backfill). + case ensureTargetSet(Int) +} + +enum SelectionResolver { + /// Pure port of `Windows.updateSelectedWindow`. Branching order matches the original so the + /// behavior-preserving extraction can be verified against the running app before we touch + /// the logic. + static func decide(_ i: SelectionInputs) -> SelectionDecision { + // 1) Search-clear path takes precedence — runs even when no visible windows. + if i.restoreDefaultOnSearchClear { + return resetInitialPick(i) + } + // 2) Empty visible list. + let visibleIndexes = i.list.indices.filter { i.list[$0].visible } + guard let firstVisibleIndex = visibleIndexes.first else { + return .clearTargetAndHover + } + // 3) Search-best-match path. + if i.bestMatchOnSearchChange { + return .selectAt(firstVisibleIndex) + } + // 4) "From scratch" only when there's no user selection yet — first refresh of the + // session. Previously this also fired whenever the MRU-focused window changed mid-show, + // which short-circuited past target preservation and made the highlight jump (#5665). + // Removed: AX events that reorder the list during display now fall through to + // `findTarget` below, keeping the user's pick stable. + if i.selectedTarget == nil { + return resetInitialPick(i) + } + // 5) Try to restore the user's chosen target by id. + if let targetIndex = findTarget(i.list, i.selectedTarget) { + return .selectAt(targetIndex) + } + // 6) Target gone — adapt to the closest visible. + return adapt(i, visibleIndexes: visibleIndexes, firstVisible: firstVisibleIndex, lastVisible: visibleIndexes.last!) + } + + /// Mirrors `setInitialSelectedAndHoveredWindowIndex` — picks the index, defers reset to wrapper. + static func initialPickIndex(_ i: SelectionInputs) -> Int? { + if i.useLastFocusedRule, let idx = getLastFocusedOrderWindowIndex(i.list) { + return idx + } + // Edge case: top two windows both minimized — land on index 0 rather than cycling past. + if i.list.count >= 2 && i.list[0].isMinimized && i.list[1].isMinimized { + return i.list[0].visible ? 0 : nil + } + return cycleFromZero(i.list) + } + + /// Cycles from index 0 by step +1, wrapping around the list, stopping at the first visible + /// window. Returns 0 if only index 0 is visible (cycle wraps back). Returns nil if no + /// visible window exists. + static func cycleFromZero(_ list: [SelectionWindow]) -> Int? { + guard !list.isEmpty, list.contains(where: { $0.visible }) else { return nil } + var idx = 0 + for _ in 0.. Int? { + var bestIndex: Int? = nil + var bestOrder = Int.max + for (idx, w) in list.enumerated() { + if !w.isWindowlessApp && w.visible && w.lastFocusOrder < bestOrder { + bestOrder = w.lastFocusOrder + bestIndex = idx + } + } + return bestIndex + } + + /// Find the user's chosen window by id, returning its current index if visible. + /// Mirrors the lookup in the old `Windows.restoreSelectionTargetIfVisible`. + static func findTarget(_ list: [SelectionWindow], _ targetId: String?) -> Int? { + guard let targetId else { return nil } + return list.firstIndex { $0.id == targetId && $0.visible } + } + + // MARK: - Internal + + private static func resetInitialPick(_ i: SelectionInputs) -> SelectionDecision { + if let idx = initialPickIndex(i) { + return .resetThenSelect(idx) + } + return .resetWithoutSelection + } + + /// Mirrors `adaptSelectionToVisibleIndexes`. `visibleIndexes` is non-empty by caller's guard. + private static func adapt(_ i: SelectionInputs, visibleIndexes: [Int], firstVisible: Int, lastVisible: Int) -> SelectionDecision { + if !visibleIndexes.contains(i.selectedIndex) { + let closest = visibleIndexes.last(where: { $0 < i.selectedIndex }) ?? lastVisible + return .selectAt(closest) + } + if i.selectedIndex > lastVisible { + return .selectAt(lastVisible) + } + if i.selectedIndex < firstVisible { + return .selectAt(firstVisible) + } + if i.selectedTarget == nil { + return .ensureTargetSet(i.selectedIndex) + } + // selectedIndex valid AND target already set — nothing to do. + // We still return `ensureTargetSet` (idempotent: same target value) to keep the enum + // surface minimal. The wrapper treats a no-change as a no-op. + return .ensureTargetSet(i.selectedIndex) + } +} diff --git a/src/switcher/state/Windows.swift b/src/switcher/state/Windows.swift index bec59ee1..9d9c8d3e 100644 --- a/src/switcher/state/Windows.swift +++ b/src/switcher/state/Windows.swift @@ -3,8 +3,6 @@ import Cocoa class Windows { static var list = [Window]() private(set) static var byWindowId = [CGWindowID: Window]() - // we use this to track if the focused window changed while alt-tab was open - private static var lastFocusedWindowTarget: String? private static var lastWindowActivityType = WindowActivityType.none private static var shouldSelectBestMatchOnSearchChange = false private static var shouldRestoreDefaultSelectionOnSearchClear = false @@ -187,6 +185,69 @@ class Windows { static func setInitialSelectedAndHoveredWindowIndex() { guard let session = SwitcherSession.current else { return } + let snapshot = selectionSnapshot() + let inputs = makeSelectionInputs(snapshot, session: session) + let pickIndex = SelectionResolver.initialPickIndex(inputs) + resetForInitialPick(session) + if let idx = pickIndex { + updateSelectedAndHoveredWindowIndex(idx) + } + } + + static func updateSelectedWindow() { + guard let session = SwitcherSession.current else { return } + let snapshot = selectionSnapshot() + let inputs = makeSelectionInputs(snapshot, session: session) + let decision = SelectionResolver.decide(inputs) + shouldRestoreDefaultSelectionOnSearchClear = false + shouldSelectBestMatchOnSearchChange = false + applySelectionDecision(decision, session: session) + } + + /// Project `list` into the kernel's window view (just the fields selection needs). + private static func selectionSnapshot() -> [SelectionWindow] { + list.map { + SelectionWindow(id: $0.id, + visible: shouldDisplay($0), + lastFocusOrder: $0.lastFocusOrder, + isMinimized: $0.isMinimized, + isWindowlessApp: $0.isWindowlessApp) + } + } + + private static func makeSelectionInputs(_ snapshot: [SelectionWindow], session: SwitcherSession) -> SelectionInputs { + SelectionInputs( + list: snapshot, + selectedIndex: session.selectedIndex, + selectedTarget: session.selectedTarget, + useLastFocusedRule: Applications.frontmostPid != nil + && Preferences.windowOrder[session.shortcutIndex] != .recentlyFocused, + restoreDefaultOnSearchClear: shouldRestoreDefaultSelectionOnSearchClear, + bestMatchOnSearchChange: shouldSelectBestMatchOnSearchChange) + } + + private static func applySelectionDecision(_ decision: SelectionDecision, session: SwitcherSession) { + switch decision { + case .clearTargetAndHover: + session.selectedTarget = nil + session.hoveredIndex = nil + case .resetThenSelect(let idx): + resetForInitialPick(session) + updateSelectedAndHoveredWindowIndex(idx) + case .resetWithoutSelection: + resetForInitialPick(session) + case .selectAt(let idx): + updateSelectedAndHoveredWindowIndex(idx) + case .ensureTargetSet(let idx): + if session.selectedTarget == nil && idx < list.count { + session.selectedTarget = list[idx].id + } + } + } + + /// Wrapper-side reset that mirrors the first half of the old `setInitialSelectedAndHoveredWindowIndex`: + /// clear `selectedTarget`, reset `selectedIndex` to 0, redraw the old highlight, drop hover. + private static func resetForInitialPick(_ session: SwitcherSession) { let oldIndex = session.selectedIndex session.selectedIndex = 0 session.selectedTarget = nil @@ -195,95 +256,6 @@ class Windows { session.hoveredIndex = nil TilesView.highlight(oldHovered) } - if Applications.frontmostPid != nil, - Preferences.windowOrder[session.shortcutIndex] != .recentlyFocused, - let lastFocusedOrderWindowIndex = getLastFocusedOrderWindowIndex() { - updateSelectedAndHoveredWindowIndex(lastFocusedOrderWindowIndex) - } else { - // edge-case: when the 2 most recently focused windows are both minimized, select the first - if list.count >= 2 && list[0].isMinimized && list[1].isMinimized { - updateSelectedAndHoveredWindowIndex(0) - } else { - cycleSelectedWindowIndex(1) - if session.selectedIndex == 0 { - updateSelectedAndHoveredWindowIndex(0) - } - } - } - } - - static func updateSelectedWindow() { - guard let session = SwitcherSession.current else { return } - let focusedWindowTarget = currentFocusedWindowTarget() - defer { lastFocusedWindowTarget = focusedWindowTarget } - if shouldRestoreDefaultSelectionOnSearchClear { - shouldRestoreDefaultSelectionOnSearchClear = false - setInitialSelectedAndHoveredWindowIndex() - return - } - let visibleIndexes = visibleWindowIndexes() - guard let firstVisibleIndex = visibleIndexes.first else { - session.selectedTarget = nil - session.hoveredIndex = nil - return - } - if shouldSelectBestMatchOnSearchChange { - shouldSelectBestMatchOnSearchChange = false - updateSelectedAndHoveredWindowIndex(firstVisibleIndex) - return - } - if shouldSelectFromScratch(focusedWindowTarget) { - setInitialSelectedAndHoveredWindowIndex() - return - } - if restoreSelectionTargetIfVisible() { return } - adaptSelectionToVisibleIndexes(visibleIndexes, firstVisibleIndex) - } - - private static func visibleWindowIndexes() -> [Int] { - list.indices.filter { shouldDisplay(list[$0]) } - } - - private static func currentFocusedWindowTarget() -> String? { - getLastFocusedOrderWindowIndex().map { list[$0].id } - } - - private static func shouldSelectFromScratch(_ focusedWindowTarget: String?) -> Bool { - SwitcherSession.current?.selectedTarget == nil || focusedWindowChangedWhileShowing(focusedWindowTarget) - } - - private static func focusedWindowChangedWhileShowing(_ focusedWindowTarget: String?) -> Bool { - guard let session = SwitcherSession.current, Search.normalizedQuery(session.searchQuery).isEmpty else { return false } - guard let lastFocusedWindowTarget, let focusedWindowTarget else { return false } - return focusedWindowTarget != lastFocusedWindowTarget - } - - private static func restoreSelectionTargetIfVisible() -> Bool { - guard let session = SwitcherSession.current, let target = session.selectedTarget else { return false } - guard let index = list.firstIndex(where: { $0.id == target && shouldDisplay($0) }) else { return false } - if index == session.selectedIndex { return true } - updateSelectedAndHoveredWindowIndex(index) - return true - } - - private static func adaptSelectionToVisibleIndexes(_ visibleIndexes: [Int], _ firstVisibleIndex: Int) { - guard let session = SwitcherSession.current, let lastVisibleIndex = visibleIndexes.last else { return } - if !visibleIndexes.contains(session.selectedIndex) { - let closest = visibleIndexes.last(where: { $0 < session.selectedIndex }) ?? lastVisibleIndex - updateSelectedAndHoveredWindowIndex(closest) - return - } - if session.selectedIndex > lastVisibleIndex { - updateSelectedAndHoveredWindowIndex(lastVisibleIndex) - return - } - if session.selectedIndex < firstVisibleIndex { - updateSelectedAndHoveredWindowIndex(firstVisibleIndex) - return - } - if session.selectedTarget == nil { - session.selectedTarget = list[session.selectedIndex].id - } } static func updateSelectedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) { @@ -545,7 +517,6 @@ class Windows { if addWindowlessWindowIfNeeded { windows.forEach { $0.application.addWindowlessWindowIfNeeded() } } - lastFocusedWindowTarget = getLastFocusedOrderWindowIndex().map { list[$0].id } App.refreshOpenUiAfterExternalEvent([], windowRemoved: true) } } diff --git a/unit-tests/Mocks.swift b/unit-tests/Mocks.swift index 6a01ffe5..3f958937 100644 --- a/unit-tests/Mocks.swift +++ b/unit-tests/Mocks.swift @@ -19,6 +19,18 @@ extension NSImage { } } +// `LightLabel` and `ProBadgeView` live in the test target's source membership (they're pulled +// in by `ProBadgeViewSegmentTests`) but reference `Appearance` and `SettingsSearchIndex` — +// types not in the test target. Minimal stubs so the test target compiles. Tests never exercise +// these code paths; the stubs just satisfy the type checker. +enum SettingsSearchIndex { + static func registerString(_ s: String?) {} +} + +class Appearance { + static var searchMatchHighlightColor: NSColor = .yellow +} + enum SearchKeyResult { case handled case passToField diff --git a/unit-tests/SelectionResolverTests.swift b/unit-tests/SelectionResolverTests.swift new file mode 100644 index 00000000..eb3aea5f --- /dev/null +++ b/unit-tests/SelectionResolverTests.swift @@ -0,0 +1,315 @@ +import XCTest + +/// Scenarios pinning the selection-state machine that drives which tile is highlighted while +/// the switcher is open. Each test builds a fully explicit `SelectionInputs` and asserts on the +/// `SelectionDecision` the kernel returns — no globals, no AppKit, no async. Future refactors +/// of selection must keep this suite green. +/// +/// Test method names follow `test`. Groups: +/// - InitialPick (A): first call after the switcher opens (`selectedTarget == nil`) +/// - PreserveTarget (B): list reorders but the user's chosen tile stays selected (#5665) +/// - TargetRemoved (C): user's window closed / filtered out +/// - SearchMode (D): search filter / clear interactions +/// - EdgeCases (E): corruption, single-window flips, multi-step sequences +final class SelectionResolverTests: XCTestCase { + + // MARK: - Builders + + /// Concise window builder. Defaults model the common case: visible, non-minimized, non-windowless. + private func w(_ id: String, focusOrder: Int = 0, visible: Bool = true, + minimized: Bool = false, windowless: Bool = false) -> SelectionWindow { + SelectionWindow(id: id, visible: visible, lastFocusOrder: focusOrder, + isMinimized: minimized, isWindowlessApp: windowless) + } + + /// Mid-session refresh with most knobs in their default position. The test customizes only + /// what its scenario cares about. + private func inputs(list: [SelectionWindow], + selectedIndex: Int = 0, + selectedTarget: String? = nil, + useLastFocusedRule: Bool = false, + restoreDefaultOnSearchClear: Bool = false, + bestMatchOnSearchChange: Bool = false) -> SelectionInputs { + SelectionInputs(list: list, + selectedIndex: selectedIndex, + selectedTarget: selectedTarget, + useLastFocusedRule: useLastFocusedRule, + restoreDefaultOnSearchClear: restoreDefaultOnSearchClear, + bestMatchOnSearchChange: bestMatchOnSearchChange) + } + + // MARK: - A. Initial pick (`selectedTarget == nil`) + + /// A1. No windows at all — wrapper clears `selectedTarget` / `hoveredIndex`. + func testInitialPickEmptyList() { + let i = inputs(list: []) + XCTAssertEqual(SelectionResolver.decide(i), .clearTargetAndHover) + } + + /// A2. Single visible window — cycle from 0 wraps back to 0; that's the only choice. + func testInitialPickSingleVisible() { + let i = inputs(list: [w("a")]) + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(0)) + } + + /// A3. Default Cmd-Tab behavior: cycle off slot 0 to slot 1 ("previous app"). + func testInitialPickTwoVisibleDefaultRules() { + let i = inputs(list: [w("a"), w("b")]) + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(1)) + } + + /// A4. Edge case at Windows.swift:204 — both top windows minimized, pick index 0 instead + /// of cycling. Behavior preserved from original (#5665 doesn't touch this). + func testInitialPickTopTwoMinimized() { + let i = inputs(list: [w("a", minimized: true), w("b", minimized: true), w("c")]) + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(0)) + } + + /// A5. Alpha/space ordering with frontmost set — pick the lowest-`lastFocusOrder` visible + /// non-windowless window, regardless of its slot position in the alpha-sorted list. + func testInitialPickUseLastFocusedRule() { + let list = [w("a", focusOrder: 5), w("b", focusOrder: 0), w("c", focusOrder: 3)] + let i = inputs(list: list, useLastFocusedRule: true) + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(1)) + } + + /// A6. All windows filtered out (e.g. by search). Same as A1 — empty visible list. + func testInitialPickAllInvisible() { + let list = [w("a", visible: false), w("b", visible: false), w("c", visible: false)] + let i = inputs(list: list) + XCTAssertEqual(SelectionResolver.decide(i), .clearTargetAndHover) + } + + /// A5 corollary (was E4 in the plan): windowless app entries are skipped when scanning for + /// the lowest `lastFocusOrder` — they're not real windows the user can pick. + func testInitialPickSkipsWindowlessInLastFocusedRule() { + let list = [ + w("dock", focusOrder: 0, windowless: true), + w("real", focusOrder: 1), + w("other", focusOrder: 2), + ] + let i = inputs(list: list, useLastFocusedRule: true) + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(1)) + } + + // MARK: - B. Preserve target across reorders — the #5665 regression cluster + + /// B1. Trivial preservation — target's still at its index after a benign refresh. + func testPreserveTargetSameIndex() { + let list = [w("a", focusOrder: 0), w("b", focusOrder: 1)] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// B2. #5665 regression — user picked "b" at slot 1, then Photoshop ("p") finished launching + /// and stole focus. List reorders so p=0, a=1, b=2. The user's selection must follow "b" to + /// slot 2. Pre-fix, the kernel re-picked from scratch instead, jumping the highlight. + func testPreserveTargetMovedToHigherIndexAfterPhotoshopLaunch() { + let listAfterReorder = [ + w("p", focusOrder: 0), // Photoshop just launched, top of MRU + w("a", focusOrder: 1), // was the front app + w("b", focusOrder: 2), // user's pick, slid down + ] + let i = inputs(list: listAfterReorder, selectedIndex: 1, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(2), + "regression for #5665 — the user picked 'b'; the highlight must follow 'b' " + + "to its new slot, not jump back to a 'previous app' re-pick.") + } + + /// B3. Same as B2 but the target moved DOWN the list — e.g. an app's window closed, + /// shifting indices up. + func testPreserveTargetMovedToLowerIndex() { + // Before refresh: list was [x, a, b, c, target]. Mid-session: "x" closed. + // Now: [a, b, target, c]. Target was at index 4, is now at index 2. + let list = [w("a", focusOrder: 0), w("b", focusOrder: 1), + w("target", focusOrder: 2), w("c", focusOrder: 3)] + let i = inputs(list: list, selectedIndex: 4, selectedTarget: "target") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(2), + "regression for #5665 — target moved to a different slot; highlight must follow.") + } + + /// B4. The list churns but the target happens to land at the same index. + /// (Defensive — kernel shouldn't notice or care.) + func testPreserveTargetIndexUnchangedByCoincidence() { + let list = [w("x", focusOrder: 0), w("target", focusOrder: 1), w("y", focusOrder: 2)] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "target") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// B5. New window appears at the END of the list — target slot is unchanged. + func testPreserveTargetNewWindowAppended() { + let list = [ + w("a", focusOrder: 0), + w("b", focusOrder: 1), + w("newly-launched", focusOrder: 99), // appended via Windows.appendWindow + ] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// B6. Multiple sequential reorders during one session — the target tracks correctly across + /// every refresh. Simulates a chatty Electron app firing focus events repeatedly. + func testPreserveTargetAcrossMultipleReorders() { + // Step 1: user picks "b" at slot 1. + let step1 = inputs( + list: [w("a", focusOrder: 0), w("b", focusOrder: 1), w("c", focusOrder: 2)], + selectedIndex: 1, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(step1), .selectAt(1)) + // Step 2: c steals focus. List reorders: c=0, a=1, b=2. Target now at index 2. + let step2 = inputs( + list: [w("c", focusOrder: 0), w("a", focusOrder: 1), w("b", focusOrder: 2)], + selectedIndex: 1, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(step2), .selectAt(2)) + // Step 3: a steals focus back. List reorders: a=0, c=1, b=2. Target stays at index 2. + let step3 = inputs( + list: [w("a", focusOrder: 0), w("c", focusOrder: 1), w("b", focusOrder: 2)], + selectedIndex: 2, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(step3), .selectAt(2)) + } + + // MARK: - C. Target removed / no longer visible + + /// C1. User's picked window closed externally. The id is no longer in the list. Fall through + /// to `adapt` and end on the previous `selectedIndex` (target backfill). + func testTargetRemovedAdaptToClosestBelow() { + // Originally: [a, b, c, d]; user picked "c" at index 2. Then "c" closed: + let list = [w("a", focusOrder: 0), w("b", focusOrder: 1), w("d", focusOrder: 3)] + let i = inputs(list: list, selectedIndex: 2, selectedTarget: "c") + // visibleIndexes = [0, 1, 2]. selectedIndex (2) is in range and equals lastVisible, + // selectedTarget != nil but lookup fails. Tail branch: ensureTargetSet(2) — list[2] is "d" + // and the wrapper backfills the target to "d". + XCTAssertEqual(SelectionResolver.decide(i), .ensureTargetSet(2)) + } + + /// C1 variant: original `selectedIndex` is now out of bounds for the smaller list. + func testTargetRemovedSelectedIndexOutOfBounds() { + // Originally 4 windows, selectedIndex=3. Then targets+others closed: only [a, b] left. + let list = [w("a", focusOrder: 0), w("b", focusOrder: 1)] + let i = inputs(list: list, selectedIndex: 3, selectedTarget: "d") + // adapt: visibleIndexes=[0,1] doesn't contain 3 → closest visible < 3 is 1 → selectAt(1). + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// C2. Target still in `list` but filtered out by `visible == false` (e.g. search match miss + /// or app went to a non-visible space). Same flow as C1 — target lookup excludes invisible. + func testTargetBecameInvisible() { + let list = [w("a"), w("b", visible: false), w("c")] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "b") + // visibleIndexes = [0, 2]. selectedIndex (1) not in [0,2] → closest visible < 1 is 0. + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(0)) + } + + /// C3. Target removed AND list emptied — `clearTargetAndHover` path takes over. + func testTargetRemovedAndListEmptied() { + let i = inputs(list: [], selectedIndex: 0, selectedTarget: "gone") + XCTAssertEqual(SelectionResolver.decide(i), .clearTargetAndHover) + } + + /// C4. Target was at slot 0, closed; only one visible window remains. + func testTargetRemovedOnlyOneLeft() { + // Originally [other, target]; target closed. Now: [other]. + let list = [w("other")] + let i = inputs(list: list, selectedIndex: 0, selectedTarget: "target") + // visibleIndexes=[0]. selectedIndex=0 in range. selectedTarget != nil but lookup fails. + // Tail returns ensureTargetSet(0); wrapper backfills target to "other". + XCTAssertEqual(SelectionResolver.decide(i), .ensureTargetSet(0)) + } + + // MARK: - D. Search-mode interactions + + /// D1. User types a search query that produces a new best match — jump to firstVisible. + func testSearchBestMatchOnSearchChange() { + let list = [w("a", visible: false), w("b"), w("c")] + let i = inputs(list: list, selectedIndex: 0, selectedTarget: "a", + bestMatchOnSearchChange: true) + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// D2. User cleared a non-empty search query — restore the default initial pick. + func testSearchRestoreDefaultOnClear() { + let list = [w("a"), w("b"), w("c")] + let i = inputs(list: list, selectedIndex: 0, selectedTarget: "a", + restoreDefaultOnSearchClear: true) + // Same flow as A3 — default rules, picks index 1. + XCTAssertEqual(SelectionResolver.decide(i), .resetThenSelect(1)) + } + + /// D3. Target preservation works the same whether or not search is active. (Pre-fix this + /// scenario was specifically interesting because `focusedWindowChangedWhileShowing` had a + /// search-empty guard; with the fix the guard is irrelevant.) + func testTargetPreservedInSearchMode() { + let list = [w("p", focusOrder: 0), w("a", focusOrder: 1), w("b", focusOrder: 2)] + let i = inputs(list: list, selectedIndex: 2, selectedTarget: "b") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(2)) + } + + /// D4. Search filters out the user's pick, but other matches remain — adapt to closest. + func testSearchTargetFilteredOutWithOthersMatching() { + let list = [w("a"), w("b", visible: false), w("c")] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "b") + // visibleIndexes = [0, 2]. selectedIndex=1 not in [0,2] → closest below is 0. + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(0)) + } + + // MARK: - E. Edge cases + + /// E1. Single window in the list that becomes invisible mid-session — clear selection. + func testEdgeSingleWindowBecomesInvisible() { + let list = [w("only", visible: false)] + let i = inputs(list: list, selectedIndex: 0, selectedTarget: "only") + XCTAssertEqual(SelectionResolver.decide(i), .clearTargetAndHover) + } + + /// E2. Target was at index 0; a new window inserts ahead, pushing it to 1. Highlight follows. + func testEdgeNewWindowPushesTargetDown() { + // Before: [target]. Now: [new, target]. + let list = [w("new", focusOrder: 0), w("target", focusOrder: 1)] + let i = inputs(list: list, selectedIndex: 0, selectedTarget: "target") + XCTAssertEqual(SelectionResolver.decide(i), .selectAt(1)) + } + + /// E3. `selectedTarget` points to an id that was never in the list (corrupt state from a + /// stale session, or a window destroyed before refresh ran). + func testEdgeStaleSelectedTarget() { + let list = [w("a"), w("b"), w("c")] + let i = inputs(list: list, selectedIndex: 1, selectedTarget: "ghost") + // target lookup fails → adapt → selectedIndex=1 in [0,1,2], in range, target was non-nil + // so the no-target-set branch doesn't trigger; tail returns ensureTargetSet(1). + XCTAssertEqual(SelectionResolver.decide(i), .ensureTargetSet(1)) + } + + // MARK: - Helper kernel functions + + /// `getLastFocusedOrderWindowIndex` ignores windowless apps and invisible windows. + func testGetLastFocusedOrderWindowIndexIgnoresWindowlessAndInvisible() { + let list = [ + w("dock", focusOrder: 0, windowless: true), // windowless: skip + w("a", focusOrder: 1, visible: false), // invisible: skip + w("b", focusOrder: 2), // candidate + w("c", focusOrder: 3), // candidate (loses on order) + ] + XCTAssertEqual(SelectionResolver.getLastFocusedOrderWindowIndex(list), 2) + } + + /// `cycleFromZero` empty / single / multi-visible behavior. + func testCycleFromZeroBehavior() { + XCTAssertNil(SelectionResolver.cycleFromZero([])) + XCTAssertNil(SelectionResolver.cycleFromZero([w("a", visible: false)])) + // Single visible — wraps back to 0. + XCTAssertEqual(SelectionResolver.cycleFromZero([w("a")]), 0) + // Two visible — advances to 1. + XCTAssertEqual(SelectionResolver.cycleFromZero([w("a"), w("b")]), 1) + // First invisible — skips to next visible. + XCTAssertEqual(SelectionResolver.cycleFromZero([w("a", visible: false), w("b")]), 1) + } + + /// `findTarget` excludes invisible matches even when the id is present. + func testFindTargetSkipsInvisibleMatches() { + let list = [w("a"), w("b", visible: false), w("c")] + XCTAssertEqual(SelectionResolver.findTarget(list, "a"), 0) + XCTAssertNil(SelectionResolver.findTarget(list, "b")) + XCTAssertEqual(SelectionResolver.findTarget(list, "c"), 2) + XCTAssertNil(SelectionResolver.findTarget(list, nil)) + XCTAssertNil(SelectionResolver.findTarget(list, "missing")) + } +}