diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 141cebf6..8a41124d 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + A1B2C3D42E8200000000AA02 /* AppFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8200000000AA01 /* AppFilter.swift */; }; + A1B2C3D42E8200000000BB02 /* AppFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8200000000BB01 /* AppFilterTests.swift */; }; 115AA5BF2DA521C600C17E18 /* ActionIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */; }; 115AA5C22DA546D500C17E18 /* SymbolPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 115AA5C12DA546D500C17E18 /* SymbolPicker */; }; 130196C62D73B3DE0093148B /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130196C52D73B3DC0093148B /* Breadcrumbs.swift */; }; @@ -68,6 +70,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + A1B2C3D42E8200000000AA01 /* AppFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilter.swift; sourceTree = ""; }; + A1B2C3D42E8200000000BB01 /* AppFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilterTests.swift; sourceTree = ""; }; 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionIcon.swift; sourceTree = ""; }; 130196C52D73B3DC0093148B /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 423632142D678F4400878D92 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; @@ -185,6 +189,7 @@ isa = PBXGroup; children = ( 42B21FBB2D67566100F4A2C7 /* Alerts.swift */, + A1B2C3D42E8200000000AA01 /* AppFilter.swift */, 427C181F2BD31C3D00955B98 /* AppDelegate.swift */, 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */, 42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */, @@ -224,6 +229,7 @@ 427C17FB2BD311B500955B98 /* Leader KeyTests */ = { isa = PBXGroup; children = ( + A1B2C3D42E8200000000BB01 /* AppFilterTests.swift */, 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */, 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */, EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */, @@ -409,6 +415,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D42E8200000000AA02 /* AppFilter.swift in Sources */, 427C182B2BD31E2E00955B98 /* Controller.swift in Sources */, 423632222D68CA6500878D92 /* MysteryBox.swift in Sources */, 605385A32D523CAD00BEDB4B /* Pulsate.swift in Sources */, @@ -450,6 +457,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D42E8200000000BB02 /* AppFilterTests.swift in Sources */, 42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */, EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */, 427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */, diff --git a/Leader Key/AppFilter.swift b/Leader Key/AppFilter.swift new file mode 100644 index 00000000..5db46dbb --- /dev/null +++ b/Leader Key/AppFilter.swift @@ -0,0 +1,87 @@ +import Foundation + +enum AppFilter { + enum Tier: Int, Comparable { + case c = 0 // global (no `when` or both arrays empty) + case b = 1 // excludeApps non-empty, includeApps empty + case a = 2 // includeApps non-empty and contains active app + + static func < (lhs: Tier, rhs: Tier) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// Whether an item's `when` matches the given bundle ID. + static func matches(when: When?, bundleID: String?) -> Bool { + guard let when = when else { return true } + let include = when.includeApps ?? [] + let exclude = when.excludeApps ?? [] + let includeMatch = include.isEmpty || include.contains(bundleID ?? "") + let excludeMatch = exclude.isEmpty || !exclude.contains(bundleID ?? "") + return includeMatch && excludeMatch + } + + /// Compute the tier of an item for a given bundle ID. + /// Returns nil if the item doesn't match (should be filtered out). + static func tier(for when: When?, bundleID: String?) -> Tier? { + guard matches(when: when, bundleID: bundleID) else { return nil } + guard let when = when else { return .c } + let include = when.includeApps ?? [] + let exclude = when.excludeApps ?? [] + if include.isEmpty && exclude.isEmpty { return .c } + if !include.isEmpty && include.contains(bundleID ?? "") { return .a } + if include.isEmpty && !exclude.isEmpty { return .b } + return .c + } + + /// Filter and resolve actions for the frontmost app. + /// Returns items that match scope, with highest-tier winner per key. + static func resolve(actions: [ActionOrGroup], for bundleID: String?) -> [ActionOrGroup] { + // Compute tier for each item. nil means filtered out. + var tiered: [(item: ActionOrGroup, tier: Tier, index: Int)] = [] + for (index, item) in actions.enumerated() { + if let t = tier(for: item.when, bundleID: bundleID) { + tiered.append((item, t, index)) + } + } + + // Group by normalized key + var byKey: [String: [(item: ActionOrGroup, tier: Tier, index: Int)]] = [:] + var noKey: [(item: ActionOrGroup, tier: Tier, index: Int)] = [] + + for entry in tiered { + let key = entry.item.item.key ?? "" + if key.isEmpty { + noKey.append(entry) + } else { + let normalized = KeyMaps.glyph(for: key) ?? key + byKey[normalized, default: []].append(entry) + } + } + + // For each key, keep only the highest-tier winner(s) + var winnerIndices: Set = [] + + for (_, candidates) in byKey { + let maxTier = candidates.map(\.tier).max()! + let winners = candidates.filter { $0.tier == maxTier } + // Take the first winner at the highest tier (validator catches same-tier ties) + if let winner = winners.first { + winnerIndices.insert(winner.index) + } + } + + // Items without keys always pass if they matched + for entry in noKey { + winnerIndices.insert(entry.index) + } + + // Preserve original order + var result: [ActionOrGroup] = [] + for (index, item) in actions.enumerated() { + if winnerIndices.contains(index) { + result.append(item) + } + } + + return result + } +} diff --git a/Leader Key/Cheatsheet.swift b/Leader Key/Cheatsheet.swift index 1dc54bda..1af251ff 100644 --- a/Leader Key/Cheatsheet.swift +++ b/Leader Key/Cheatsheet.swift @@ -123,8 +123,10 @@ enum Cheatsheet { } var actions: [ActionOrGroup] { - (userState.currentGroup != nil) + let source = + (userState.currentGroup != nil) ? userState.currentGroup!.actions : userState.userConfig.root.actions + return AppFilter.resolve(actions: source, for: userState.frontmostBundleID) } var body: some SwiftUI.View { diff --git a/Leader Key/ConfigValidator.swift b/Leader Key/ConfigValidator.swift index 1ef89943..a03f47c2 100644 --- a/Leader Key/ConfigValidator.swift +++ b/Leader Key/ConfigValidator.swift @@ -15,6 +15,7 @@ enum ValidationErrorType { case emptyKey case nonSingleCharacterKey case duplicateKey + case invalidWhen } class ConfigValidator { @@ -33,50 +34,143 @@ class ConfigValidator { validateKey(group.key, at: path, errors: &errors) } - // Check for duplicate keys within this group - var keysInGroup = [String: Int]() // key: index + // Validate `when` self-consistency for the group itself + if !path.isEmpty { + validateWhen(group.when, at: path, errors: &errors) + } + + // Collect items by normalized key for tier-aware duplicate detection + struct KeyEntry { + let index: Int + let when: When? + } + var keyEntries: [String: [KeyEntry]] = [:] for (index, item) in group.actions.enumerated() { let currentPath = path + [index] - // Get the key from the item + // Get the key and when from the item let key: String? + let itemWhen: When? switch item { case .action(let action): key = action.key + itemWhen = action.when // Validate the key for actions validateKey(key, at: currentPath, errors: &errors) + // Validate when self-consistency + validateWhen(itemWhen, at: currentPath, errors: &errors) case .group(let subgroup): key = subgroup.key + itemWhen = subgroup.when // Recursively validate subgroups validateGroup(subgroup, path: currentPath, errors: &errors) - // Note: We don't validate the key here because it will be validated in the recursive call + // Validate when self-consistency (done in recursive call for the group itself) } - // Check for duplicates + // Collect entries by normalized key if let key = key, !key.isEmpty { - // Normalize key to glyph representation for consistent comparison let normalizedKey = KeyMaps.glyph(for: key) ?? key + keyEntries[normalizedKey, default: []].append(KeyEntry(index: index, when: itemWhen)) + } + } - if let existingIndex = keysInGroup[normalizedKey] { - // Found a duplicate key - let duplicatePath = path + [existingIndex] + // Tier-aware duplicate detection + for (normalizedKey, entries) in keyEntries { + guard entries.count > 1 else { continue } + + // Classify each entry by tier (using nil bundleID since we check structurally) + struct TieredEntry { + let index: Int + let when: When? + let structuralTier: AppFilter.Tier // tier based on structure, not a specific app + } + + let tieredEntries: [TieredEntry] = entries.map { entry in + let tier = structuralTier(for: entry.when) + return TieredEntry(index: entry.index, when: entry.when, structuralTier: tier) + } + + // Check for conflicts within each tier + let tierCEntries = tieredEntries.filter { $0.structuralTier == .c } + let tierBEntries = tieredEntries.filter { $0.structuralTier == .b } + let tierAEntries = tieredEntries.filter { $0.structuralTier == .a } + + // Multiple Tier C entries for same key = error + if tierCEntries.count > 1 { + for entry in tierCEntries { errors.append( ValidationError( - path: duplicatePath, + path: path + [entry.index], message: "Multiple actions for the same key '\(normalizedKey)'", type: .duplicateKey )) + } + } + + // Multiple Tier B entries for same key = error (they overlap broadly) + if tierBEntries.count > 1 { + for entry in tierBEntries { errors.append( ValidationError( - path: currentPath, - message: "Multiple actions for the same key '\(normalizedKey)'", + path: path + [entry.index], + message: "Multiple 'everywhere-except' actions for the same key '\(normalizedKey)'", type: .duplicateKey )) - } else { - keysInGroup[normalizedKey] = index } } + + // Tier A overlaps: two items with includeApps containing the same bundle ID + if tierAEntries.count > 1 { + var bundleToEntries: [String: [TieredEntry]] = [:] + for entry in tierAEntries { + for bundle in entry.when?.includeApps ?? [] { + bundleToEntries[bundle, default: []].append(entry) + } + } + var reportedIndices = Set() + for (bundle, conflicting) in bundleToEntries { + if conflicting.count > 1 { + for entry in conflicting where !reportedIndices.contains(entry.index) { + reportedIndices.insert(entry.index) + errors.append( + ValidationError( + path: path + [entry.index], + message: + "Multiple app-specific actions for '\(bundle)' on key '\(normalizedKey)'", + type: .duplicateKey + )) + } + } + } + } + } + } + + /// Determine the structural tier of a `when` clause (without a specific bundleID). + private static func structuralTier(for when: When?) -> AppFilter.Tier { + guard let when = when else { return .c } + let include = when.includeApps ?? [] + let exclude = when.excludeApps ?? [] + if include.isEmpty && exclude.isEmpty { return .c } + if !include.isEmpty { return .a } + if include.isEmpty && !exclude.isEmpty { return .b } + return .c + } + + /// Validate that a `when` clause is self-consistent. + private static func validateWhen(_ when: When?, at path: [Int], errors: inout [ValidationError]) { + guard let when = when else { return } + let include = Set(when.includeApps ?? []) + let exclude = Set(when.excludeApps ?? []) + let overlap = include.intersection(exclude) + if !overlap.isEmpty { + errors.append( + ValidationError( + path: path, + message: "Bundle ID '\(overlap.first!)' appears in both includeApps and excludeApps", + type: .invalidWhen + )) } } diff --git a/Leader Key/Controller.swift b/Leader Key/Controller.swift index 37c0eecd..004a5579 100644 --- a/Leader Key/Controller.swift +++ b/Leader Key/Controller.swift @@ -55,6 +55,7 @@ class Controller { } func show() { + userState.frontmostBundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier Events.send(.willActivate) let screen = Defaults[.screen].getNSScreen() ?? NSScreen() @@ -127,7 +128,9 @@ class Controller { } } - func handleKey(_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true) { + func handleKey( + _ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true + ) { if key == "?" { showCheatsheet() return @@ -137,7 +140,9 @@ class Controller { (userState.currentGroup != nil) ? userState.currentGroup : userConfig.root - let hit = list?.actions.first { item in + let actions = AppFilter.resolve(actions: list?.actions ?? [], for: userState.frontmostBundleID) + + let hit = actions.first { item in switch item { case .group(let group): // Normalize both keys for comparison @@ -168,7 +173,7 @@ class Controller { } } } - // If execute is false, just stay visible showing the matched action + // If execute is false, just stay visible showing the matched action case .group(let group): if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) { hide { diff --git a/Leader Key/URLSchemeHandler.swift b/Leader Key/URLSchemeHandler.swift index fc1d8522..954698b9 100644 --- a/Leader Key/URLSchemeHandler.swift +++ b/Leader Key/URLSchemeHandler.swift @@ -36,8 +36,8 @@ class URLSchemeHandler { return .reset case "navigate": guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems, - let keysParam = queryItems.first(where: { $0.name == "keys" })?.value + let queryItems = components.queryItems, + let keysParam = queryItems.first(where: { $0.name == "keys" })?.value else { return .show } diff --git a/Leader Key/UserConfig.swift b/Leader Key/UserConfig.swift index c339d028..fbb6d821 100644 --- a/Leader Key/UserConfig.swift +++ b/Leader Key/UserConfig.swift @@ -408,12 +408,18 @@ enum Type: String, Codable { case folder } +struct When: Codable, Equatable { + var includeApps: [String]? + var excludeApps: [String]? +} + protocol Item { var key: String? { get } var type: Type { get } var label: String? { get } var displayName: String { get } var iconPath: String? { get set } + var when: When? { get set } } struct Action: Item, Codable, Equatable { @@ -425,6 +431,7 @@ struct Action: Item, Codable, Equatable { var label: String? var value: String var iconPath: String? + var when: When? var displayName: String { guard let labelValue = label else { return bestGuessDisplayName } @@ -447,11 +454,11 @@ struct Action: Item, Codable, Equatable { return value } } - private enum CodingKeys: String, CodingKey { case key, type, label, value, iconPath } + private enum CodingKeys: String, CodingKey { case key, type, label, value, iconPath, when } init( uiid: UUID = UUID(), key: String?, type: Type, label: String? = nil, value: String, - iconPath: String? = nil + iconPath: String? = nil, when: When? = nil ) { self.uiid = uiid self.key = key @@ -459,6 +466,7 @@ struct Action: Item, Codable, Equatable { self.label = label self.value = value self.iconPath = iconPath + self.when = when } init(from decoder: Decoder) throws { @@ -469,6 +477,7 @@ struct Action: Item, Codable, Equatable { self.label = try c.decodeIfPresent(String.self, forKey: .label) self.value = try c.decode(String.self, forKey: .value) self.iconPath = try c.decodeIfPresent(String.self, forKey: .iconPath) + self.when = try c.decodeIfPresent(When.self, forKey: .when) } func encode(to encoder: Encoder) throws { @@ -482,6 +491,7 @@ struct Action: Item, Codable, Equatable { try c.encode(value, forKey: .value) if let l = label, !l.isEmpty { try c.encode(l, forKey: .label) } try c.encodeIfPresent(iconPath, forKey: .iconPath) + try c.encodeIfPresent(when, forKey: .when) } } @@ -494,6 +504,7 @@ struct Group: Item, Codable, Equatable { var label: String? var iconPath: String? var actions: [ActionOrGroup] + var when: When? var displayName: String { guard let labelValue = label else { return "Group" } @@ -504,12 +515,13 @@ struct Group: Item, Codable, Equatable { static func == (lhs: Group, rhs: Group) -> Bool { return lhs.key == rhs.key && lhs.type == rhs.type && lhs.label == rhs.label && lhs.iconPath == rhs.iconPath && lhs.actions == rhs.actions + && lhs.when == rhs.when } - private enum CodingKeys: String, CodingKey { case key, type, label, iconPath, actions } + private enum CodingKeys: String, CodingKey { case key, type, label, iconPath, actions, when } init( uiid: UUID = UUID(), key: String?, type: Type = .group, label: String? = nil, - iconPath: String? = nil, actions: [ActionOrGroup] + iconPath: String? = nil, actions: [ActionOrGroup], when: When? = nil ) { self.uiid = uiid self.key = key @@ -517,6 +529,7 @@ struct Group: Item, Codable, Equatable { self.label = label self.iconPath = iconPath self.actions = actions + self.when = when } init(from decoder: Decoder) throws { @@ -527,6 +540,7 @@ struct Group: Item, Codable, Equatable { self.label = try c.decodeIfPresent(String.self, forKey: .label) self.iconPath = try c.decodeIfPresent(String.self, forKey: .iconPath) self.actions = try c.decode([ActionOrGroup].self, forKey: .actions) + self.when = try c.decodeIfPresent(When.self, forKey: .when) } func encode(to encoder: Encoder) throws { @@ -540,6 +554,7 @@ struct Group: Item, Codable, Equatable { try c.encode(actions, forKey: .actions) if let l = label, !l.isEmpty { try c.encode(l, forKey: .label) } try c.encodeIfPresent(iconPath, forKey: .iconPath) + try c.encodeIfPresent(when, forKey: .when) } } @@ -555,7 +570,7 @@ enum ActionOrGroup: Codable, Equatable { } private enum CodingKeys: String, CodingKey { - case key, type, value, actions, label, iconPath + case key, type, value, actions, label, iconPath, when } var uiid: UUID { @@ -565,20 +580,29 @@ enum ActionOrGroup: Codable, Equatable { } } + var when: When? { + switch self { + case .action(let a): return a.when + case .group(let g): return g.when + } + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let key = try container.decode(String?.self, forKey: .key) let type = try container.decode(Type.self, forKey: .type) let label = try container.decodeIfPresent(String.self, forKey: .label) let iconPath = try container.decodeIfPresent(String.self, forKey: .iconPath) + let when = try container.decodeIfPresent(When.self, forKey: .when) switch type { case .group: let actions = try container.decode([ActionOrGroup].self, forKey: .actions) - self = .group(Group(key: key, label: label, iconPath: iconPath, actions: actions)) + self = .group(Group(key: key, label: label, iconPath: iconPath, actions: actions, when: when)) default: let value = try container.decode(String.self, forKey: .value) - self = .action(Action(key: key, type: type, label: label, value: value, iconPath: iconPath)) + self = .action( + Action(key: key, type: type, label: label, value: value, iconPath: iconPath, when: when)) } } @@ -599,6 +623,7 @@ enum ActionOrGroup: Codable, Equatable { try container.encodeIfPresent(action.label, forKey: .label) } try container.encodeIfPresent(action.iconPath, forKey: .iconPath) + try container.encodeIfPresent(action.when, forKey: .when) case .group(let group): // Always encode key in textual form for JSON if let keyValue = group.key { @@ -613,6 +638,7 @@ enum ActionOrGroup: Codable, Equatable { try container.encodeIfPresent(group.label, forKey: .label) } try container.encodeIfPresent(group.iconPath, forKey: .iconPath) + try container.encodeIfPresent(group.when, forKey: .when) } } } diff --git a/Leader Key/UserState.swift b/Leader Key/UserState.swift index b8ac1a2c..4bd30964 100644 --- a/Leader Key/UserState.swift +++ b/Leader Key/UserState.swift @@ -8,6 +8,7 @@ final class UserState: ObservableObject { @Published var display: String? @Published var isShowingRefreshState: Bool @Published var navigationPath: [Group] = [] + @Published var frontmostBundleID: String? var currentGroup: Group? { return navigationPath.last diff --git a/Leader KeyTests/AppFilterTests.swift b/Leader KeyTests/AppFilterTests.swift new file mode 100644 index 00000000..e7cfd33e --- /dev/null +++ b/Leader KeyTests/AppFilterTests.swift @@ -0,0 +1,239 @@ +import XCTest + +@testable import Leader_Key + +final class AppFilterTests: XCTestCase { + + // MARK: - matches tests + + func testMatches_noWhen() { + XCTAssertTrue(AppFilter.matches(when: nil, bundleID: "com.example.app")) + } + + func testMatches_includeApps_match() { + let when = When(includeApps: ["com.google.Chrome"], excludeApps: nil) + XCTAssertTrue(AppFilter.matches(when: when, bundleID: "com.google.Chrome")) + } + + func testMatches_includeApps_noMatch() { + let when = When(includeApps: ["com.google.Chrome"], excludeApps: nil) + XCTAssertFalse(AppFilter.matches(when: when, bundleID: "com.apple.Safari")) + } + + func testMatches_excludeApps_match() { + let when = When(includeApps: nil, excludeApps: ["com.google.Chrome"]) + XCTAssertFalse(AppFilter.matches(when: when, bundleID: "com.google.Chrome")) + } + + func testMatches_excludeApps_noMatch() { + let when = When(includeApps: nil, excludeApps: ["com.google.Chrome"]) + XCTAssertTrue(AppFilter.matches(when: when, bundleID: "com.apple.Safari")) + } + + func testMatches_includeAndExclude() { + // bundleID in both include and exclude → exclude wins (AND logic) + let when = When(includeApps: ["com.google.Chrome"], excludeApps: ["com.google.Chrome"]) + XCTAssertFalse(AppFilter.matches(when: when, bundleID: "com.google.Chrome")) + } + + func testMatches_nilBundleID() { + let when = When(includeApps: ["com.google.Chrome"], excludeApps: nil) + XCTAssertFalse(AppFilter.matches(when: when, bundleID: nil)) + } + + func testMatches_nilBundleID_noWhen() { + XCTAssertTrue(AppFilter.matches(when: nil, bundleID: nil)) + } + + func testMatches_emptyArrays() { + let when = When(includeApps: [], excludeApps: []) + XCTAssertTrue(AppFilter.matches(when: when, bundleID: "com.example.app")) + } + + // MARK: - tier tests + + func testTier_noWhen() { + XCTAssertEqual(AppFilter.tier(for: nil, bundleID: "com.example.app"), .c) + } + + func testTier_includeAppsContainsBundleID() { + let when = When(includeApps: ["com.google.Chrome"], excludeApps: nil) + XCTAssertEqual(AppFilter.tier(for: when, bundleID: "com.google.Chrome"), .a) + } + + func testTier_excludeAppsOnly() { + let when = When(includeApps: nil, excludeApps: ["com.google.Chrome"]) + XCTAssertEqual(AppFilter.tier(for: when, bundleID: "com.apple.Safari"), .b) + } + + func testTier_noMatch() { + let when = When(includeApps: ["com.google.Chrome"], excludeApps: nil) + XCTAssertNil(AppFilter.tier(for: when, bundleID: "com.apple.Safari")) + } + + func testTier_emptyArrays() { + let when = When(includeApps: [], excludeApps: []) + XCTAssertEqual(AppFilter.tier(for: when, bundleID: "com.example.app"), .c) + } + + func testTier_excludeAppsMatch() { + let when = When(includeApps: nil, excludeApps: ["com.google.Chrome"]) + XCTAssertNil(AppFilter.tier(for: when, bundleID: "com.google.Chrome")) + } + + // MARK: - resolve tests + + func testResolve_globalOnly() { + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/App1.app")), + .action(Action(key: "b", type: .application, value: "/Applications/App2.app")), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.example.app") + XCTAssertEqual(result.count, 2) + } + + func testResolve_tierAOverridesTierC() { + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.google.Chrome") + XCTAssertEqual(result.count, 1) + if case .action(let action) = result.first { + XCTAssertEqual(action.value, "/Applications/Chrome.app") + } else { + XCTFail("Expected an action") + } + } + + func testResolve_tierBOverridesTierC() { + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/EverywhereExcept.app", + when: When(excludeApps: ["com.google.Chrome"]))), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.apple.Safari") + XCTAssertEqual(result.count, 1) + if case .action(let action) = result.first { + XCTAssertEqual(action.value, "/Applications/EverywhereExcept.app") + } else { + XCTFail("Expected an action") + } + } + + func testResolve_tierAOverridesTierB() { + let actions: [ActionOrGroup] = [ + .action( + Action( + key: "a", type: .application, value: "/Applications/EverywhereExcept.app", + when: When(excludeApps: ["com.apple.Finder"]))), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.google.Chrome") + XCTAssertEqual(result.count, 1) + if case .action(let action) = result.first { + XCTAssertEqual(action.value, "/Applications/Chrome.app") + } else { + XCTFail("Expected an action") + } + } + + func testResolve_appSpecificHiddenForOtherApp() { + // Tier A item hidden for non-matching app, Tier C shows + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.apple.Safari") + XCTAssertEqual(result.count, 1) + if case .action(let action) = result.first { + XCTAssertEqual(action.value, "/Applications/Global.app") + } else { + XCTFail("Expected an action") + } + } + + func testResolve_excludePatternHidesForMatchingApp() { + let actions: [ActionOrGroup] = [ + .action( + Action( + key: "a", type: .application, value: "/Applications/NotInChrome.app", + when: When(excludeApps: ["com.google.Chrome"]))) + ] + + let result = AppFilter.resolve(actions: actions, for: "com.google.Chrome") + XCTAssertEqual(result.count, 0) + } + + func testResolve_groupFilteredOut() { + let actions: [ActionOrGroup] = [ + .group( + Group( + key: "g", label: "Chrome Only", + actions: [ + .action(Action(key: "a", type: .application, value: "/Applications/App.app")) + ], when: When(includeApps: ["com.google.Chrome"]))) + ] + + let result = AppFilter.resolve(actions: actions, for: "com.apple.Safari") + XCTAssertEqual(result.count, 0) + } + + func testResolve_preservesOrder() { + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/App1.app")), + .action(Action(key: "b", type: .application, value: "/Applications/App2.app")), + .action(Action(key: "c", type: .application, value: "/Applications/App3.app")), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.example.app") + XCTAssertEqual(result.count, 3) + if case .action(let a0) = result[0], case .action(let a1) = result[1], + case .action(let a2) = result[2] + { + XCTAssertEqual(a0.key, "a") + XCTAssertEqual(a1.key, "b") + XCTAssertEqual(a2.key, "c") + } else { + XCTFail("Expected actions in order") + } + } + + func testResolve_multipleKeysWithMixedTiers() { + // "a" has Tier A + Tier C, "b" is global only + let actions: [ActionOrGroup] = [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action(Action(key: "b", type: .application, value: "/Applications/App2.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + + let result = AppFilter.resolve(actions: actions, for: "com.google.Chrome") + XCTAssertEqual(result.count, 2) + // "b" should be present, and the Chrome-specific "a" should win + let keys = result.map { $0.item.key } + XCTAssertTrue(keys.contains("b")) + XCTAssertTrue(keys.contains("a")) + if case .action(let aAction) = result.first(where: { $0.item.key == "a" }) { + XCTAssertEqual(aAction.value, "/Applications/Chrome.app") + } + } +} diff --git a/Leader KeyTests/ConfigValidatorTests.swift b/Leader KeyTests/ConfigValidatorTests.swift index 18d59513..d72abef6 100644 --- a/Leader KeyTests/ConfigValidatorTests.swift +++ b/Leader KeyTests/ConfigValidatorTests.swift @@ -210,6 +210,170 @@ final class ConfigValidatorTests: XCTestCase { XCTAssertEqual(KeyMaps.glyph(for: "R"), "R") } + // MARK: - Tier-Aware Duplicate Detection + + func testDuplicateKeysAllowedWithDifferentTiers() { + // Tier A + Tier C for same key = OK (precedence resolves it) + let group = Group( + key: nil, + label: "Root", + actions: [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual(duplicateErrors.count, 0, "Tier A + Tier C should not be flagged as duplicates") + } + + func testDuplicateKeysErrorWhenBothGlobal() { + // Two Tier C entries for same key = error (existing behavior preserved) + let group = Group( + key: nil, + label: "Root", + actions: [ + .action(Action(key: "a", type: .application, value: "/Applications/App1.app")), + .action(Action(key: "a", type: .application, value: "/Applications/App2.app")), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual( + duplicateErrors.count, 2, "Two global entries for same key should produce errors") + } + + func testDuplicateKeysErrorWhenMultipleTierB() { + // Two Tier B for same key = error + let group = Group( + key: nil, + label: "Root", + actions: [ + .action( + Action( + key: "a", type: .application, value: "/Applications/App1.app", + when: When(excludeApps: ["com.google.Chrome"]))), + .action( + Action( + key: "a", type: .application, value: "/Applications/App2.app", + when: When(excludeApps: ["com.apple.Safari"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual( + duplicateErrors.count, 2, "Multiple Tier B entries for same key should produce errors") + } + + func testDuplicateKeysErrorWhenTierAOverlap() { + // Two Tier A items with same includeApps bundle for same key = error + let group = Group( + key: nil, + label: "Root", + actions: [ + .action( + Action( + key: "a", type: .application, value: "/Applications/App1.app", + when: When(includeApps: ["com.google.Chrome"]))), + .action( + Action( + key: "a", type: .application, value: "/Applications/App2.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual(duplicateErrors.count, 2, "Overlapping Tier A entries should produce errors") + } + + func testInvalidWhen_sameBundleInBothArrays() { + let group = Group( + key: nil, + label: "Root", + actions: [ + .action( + Action( + key: "a", type: .application, value: "/Applications/App1.app", + when: When(includeApps: ["com.google.Chrome"], excludeApps: ["com.google.Chrome"]))) + ] + ) + + let errors = ConfigValidator.validate(group: group) + let whenErrors = errors.filter { $0.type == .invalidWhen } + XCTAssertEqual(whenErrors.count, 1, "Same bundle in both arrays should produce an error") + } + + func testDuplicateKeysAllowedTierAAndTierB() { + // Tier A + Tier B for same key = OK + let group = Group( + key: nil, + label: "Root", + actions: [ + .action( + Action( + key: "a", type: .application, value: "/Applications/EverywhereExcept.app", + when: When(excludeApps: ["com.apple.Finder"]))), + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual(duplicateErrors.count, 0, "Tier A + Tier B should not be flagged as duplicates") + } + + func testDuplicateKeysAllowedTierBAndTierC() { + // Tier B + Tier C for same key = OK + let group = Group( + key: nil, + label: "Root", + actions: [ + .action(Action(key: "a", type: .application, value: "/Applications/Global.app")), + .action( + Action( + key: "a", type: .application, value: "/Applications/EverywhereExcept.app", + when: When(excludeApps: ["com.google.Chrome"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual(duplicateErrors.count, 0, "Tier B + Tier C should not be flagged as duplicates") + } + + func testTierADifferentBundlesAllowed() { + // Two Tier A items for different bundles on same key = OK + let group = Group( + key: nil, + label: "Root", + actions: [ + .action( + Action( + key: "a", type: .application, value: "/Applications/Chrome.app", + when: When(includeApps: ["com.google.Chrome"]))), + .action( + Action( + key: "a", type: .application, value: "/Applications/Safari.app", + when: When(includeApps: ["com.apple.Safari"]))), + ] + ) + + let errors = ConfigValidator.validate(group: group) + let duplicateErrors = errors.filter { $0.type == .duplicateKey } + XCTAssertEqual( + duplicateErrors.count, 0, "Tier A entries for different bundles should be allowed") + } + func testKeyMatchingLogic() { // Test the core key matching logic used in Controller.handleKey let testCases: [(input: String, config: String, shouldMatch: Bool)] = [ diff --git a/Leader KeyTests/URLSchemeTests.swift b/Leader KeyTests/URLSchemeTests.swift index 93ae3ce9..e3ce0143 100644 --- a/Leader KeyTests/URLSchemeTests.swift +++ b/Leader KeyTests/URLSchemeTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Leader_Key final class URLSchemeTests: XCTestCase { diff --git a/Leader KeyTests/UserConfigTests.swift b/Leader KeyTests/UserConfigTests.swift index 6a8fb64f..85a92798 100644 --- a/Leader KeyTests/UserConfigTests.swift +++ b/Leader KeyTests/UserConfigTests.swift @@ -142,6 +142,113 @@ final class UserConfigTests: XCTestCase { XCTAssertEqual(testAlertManager.shownAlerts.count, 0) } + func testWhenFieldRoundTrip() throws { + let json = """ + { + "type": "group", + "actions": [ + { + "key": "a", + "type": "application", + "value": "/Applications/Safari.app", + "when": { + "includeApps": ["com.google.Chrome"], + "excludeApps": ["com.google.Chrome.canary"] + } + } + ] + } + """ + + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(Group.self, from: data) + + // Verify decoded when field + if case .action(let action) = decoded.actions.first { + XCTAssertNotNil(action.when) + XCTAssertEqual(action.when?.includeApps, ["com.google.Chrome"]) + XCTAssertEqual(action.when?.excludeApps, ["com.google.Chrome.canary"]) + } else { + XCTFail("Expected an action") + } + + // Re-encode and decode to verify round-trip + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let reEncoded = try encoder.encode(decoded) + let reDecoded = try JSONDecoder().decode(Group.self, from: reEncoded) + + if case .action(let action) = reDecoded.actions.first { + XCTAssertEqual(action.when?.includeApps, ["com.google.Chrome"]) + XCTAssertEqual(action.when?.excludeApps, ["com.google.Chrome.canary"]) + } else { + XCTFail("Expected an action after round-trip") + } + } + + func testWhenFieldOmittedWhenNil() throws { + let json = """ + { + "type": "group", + "actions": [ + { + "key": "a", + "type": "application", + "value": "/Applications/Safari.app" + } + ] + } + """ + + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(Group.self, from: data) + + // Verify when is nil + if case .action(let action) = decoded.actions.first { + XCTAssertNil(action.when) + } else { + XCTFail("Expected an action") + } + + // Re-encode and verify no "when" key in JSON + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let reEncoded = try encoder.encode(decoded) + let jsonString = String(data: reEncoded, encoding: .utf8)! + XCTAssertFalse(jsonString.contains("when"), "JSON should not contain 'when' key when nil") + } + + func testWhenFieldOnGroup() throws { + let json = """ + { + "type": "group", + "actions": [ + { + "key": "g", + "type": "group", + "actions": [ + { "key": "a", "type": "application", "value": "/Applications/App.app" } + ], + "when": { + "includeApps": ["com.google.Chrome"] + } + } + ] + } + """ + + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(Group.self, from: data) + + if case .group(let group) = decoded.actions.first { + XCTAssertNotNil(group.when) + XCTAssertEqual(group.when?.includeApps, ["com.google.Chrome"]) + XCTAssertNil(group.when?.excludeApps) + } else { + XCTFail("Expected a group") + } + } + private func waitForConfigLoad() { let expectation = expectation(description: "config load flush") DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {