From db15a7c8ab692192f16f2f9905feb8e7ba5f0a73 Mon Sep 17 00:00:00 2001 From: Xander Forge <65961104+xand3r40r93@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:48:08 +0100 Subject: [PATCH 1/2] feat(config): Add window count and workspace pattern support for dynamic gaps Enhance DynamicConfigValue to support window count and workspace pattern matching in per-monitor configurations. This allows for more granular control over gaps based on the number of windows and workspace names. Key changes: - Add windows and workspace parameters for PerMonitorValue - Implement window count aware gap configurations - Add workspace pattern matching support - Update documentation with new configuration examples - Add comprehensive test coverage --- .../AppBundle/config/DynamicConfigValue.swift | 136 ++++++++++----- Sources/AppBundle/config/parseGaps.swift | 15 +- .../AppBundleTests/config/ConfigTest.swift | 11 +- .../config/DynamicConfigValueTests.swift | 153 +++++++++++++++++ .../config/parseGapsTests.swift | 155 ++++++++++++++++++ docs/config-examples/default-config.toml | 31 +++- 6 files changed, 447 insertions(+), 54 deletions(-) create mode 100644 Sources/AppBundleTests/config/DynamicConfigValueTests.swift create mode 100644 Sources/AppBundleTests/config/parseGapsTests.swift diff --git a/Sources/AppBundle/config/DynamicConfigValue.swift b/Sources/AppBundle/config/DynamicConfigValue.swift index c8b263e59..da92e1b1a 100644 --- a/Sources/AppBundle/config/DynamicConfigValue.swift +++ b/Sources/AppBundle/config/DynamicConfigValue.swift @@ -1,9 +1,12 @@ import Common +import Foundation import TOMLKit struct PerMonitorValue: Equatable { let description: MonitorDescription let value: Value + let windows: Int? + let workspace: String? } extension PerMonitorValue: Sendable where Value: Sendable {} @@ -14,22 +17,74 @@ enum DynamicConfigValue: Equatable { extension DynamicConfigValue: Sendable where Value: Sendable {} extension DynamicConfigValue { - func getValue(for monitor: any Monitor) -> Value { + @MainActor + func getValue(for monitor: any Monitor, windowCount: Int = 1) -> Value { + let actualWindowCount = getActualWindowCount(monitor: monitor, windowCount: windowCount) + switch self { case .constant(let value): return value case .perMonitor(let array, let defaultValue): - let sortedMonitors = sortedMonitors - return array - .lazy - .compactMap { - $0.description.resolveMonitor(sortedMonitors: sortedMonitors)?.rect.topLeftCorner == monitor.rect.topLeftCorner - ? $0.value - : nil - } - .first ?? defaultValue + return getPerMonitorValue( + array: array, + monitor: monitor, + windowCount: actualWindowCount, + defaultValue: defaultValue + ) + } + } + + @MainActor + private func getActualWindowCount(monitor: any Monitor, windowCount: Int) -> Int { + guard !isUnitTest else { return windowCount } + + return monitor.activeWorkspace.allLeafWindowsRecursive + .filter { !$0.isFloating } + .count + } + + private func isMonitorMatching(_ description: MonitorDescription, _ monitor: any Monitor) -> Bool { + switch description { + case .main: return monitor.name == "main" + case .secondary: return monitor.name == "secondary" + case .pattern(_, let regex): return (try? regex.val.firstMatch(in: monitor.name)) != nil + case .sequenceNumber(let num): return num == monitor.monitorAppKitNsScreenScreensId } } + + private func matchesWorkspace(_ pattern: String?, _ workspaceName: String) -> Bool { + guard let pattern else { return true } + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + let range = NSRange(workspaceName.startIndex..., in: workspaceName) + return regex.firstMatch(in: workspaceName, range: range) != nil + } + + @MainActor + private func getPerMonitorValue( + array: [PerMonitorValue], + monitor: any Monitor, + windowCount: Int, + defaultValue: Value + ) -> Value { + let matchingValues = array.filter { isMonitorMatching($0.description, monitor) } + let workspaceName = monitor.activeWorkspace.name + + if let value = matchingValues.first(where: { + $0.windows == windowCount && matchesWorkspace($0.workspace, workspaceName) + }) { + return value.value + } + + if let value = matchingValues.first(where: { + $0.windows == nil && matchesWorkspace($0.workspace, workspaceName) + }) { + return value.value + } + + return matchingValues.first(where: { + $0.windows == nil && $0.workspace == nil + })?.value ?? defaultValue + } } func parseDynamicValue( @@ -41,51 +96,56 @@ func parseDynamicValue( ) -> DynamicConfigValue { if let simpleValue = parseSimpleType(raw) as T? { return .constant(simpleValue) - } else if let array = raw.array { - if array.isEmpty { - errors.append(.semantic(backtrace, "The array must not be empty")) - return .constant(fallback) - } - - guard let defaultValue = array.last.flatMap({ parseSimpleType($0) as T? }) else { - errors.append(.semantic(backtrace, "The last item in the array must be of type \(T.self)")) - return .constant(fallback) - } - - if array.dropLast().isEmpty { - errors.append(.semantic(backtrace, "The array must contain at least one monitor pattern")) - return .constant(fallback) - } + } - let rules: [PerMonitorValue] = parsePerMonitorValues(TOMLArray(array.dropLast()), backtrace, &errors) + guard let array = raw.array, !array.isEmpty else { + errors.append(.semantic(backtrace, "Expected non-empty array")) + return .constant(fallback) + } - return .perMonitor(rules, default: defaultValue) - } else { - errors.append(.semantic(backtrace, "Unsupported type: \(raw.type), expected: \(valueType) or array")) + guard let defaultValue = array.last.flatMap({ parseSimpleType($0) as T? }) else { + errors.append(.semantic(backtrace, "The last item in the array must be of type \(T.self)")) return .constant(fallback) } + + let rules = parsePerMonitorValues(TOMLArray(array.dropLast()), backtrace, &errors) as [PerMonitorValue] + return .perMonitor(rules, default: defaultValue) } func parsePerMonitorValues(_ array: TOMLArray, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [PerMonitorValue] { - array.enumerated().compactMap { (index: Int, raw: TOMLValueConvertible) -> PerMonitorValue? in + array.enumerated().compactMap { (index, raw) in var backtrace = backtrace + .index(index) - guard let (key, value) = raw.unwrapTableWithSingleKey(expectedKey: "monitor", &backtrace) + guard let (key, configValue) = raw.unwrapTableWithSingleKey(expectedKey: "monitor", &backtrace) .flatMap({ $0.value.unwrapTableWithSingleKey(expectedKey: nil, &backtrace) }) - .getOrNil(appendErrorTo: &errors) + .getOrNil(appendErrorTo: &errors), + let monitorDescription = parseMonitorDescription(key, backtrace).getOrNil(appendErrorTo: &errors) + else { return nil } + + if let simpleValue = parseSimpleType(configValue) as T? { + return PerMonitorValue(description: monitorDescription, value: simpleValue, windows: nil, workspace: nil) + } + + guard let table = configValue.table, + let value = table["value"].flatMap({ parseSimpleType($0) as T? }) else { + errors.append(.semantic(backtrace, "Expected '\(T.self)' or table with 'value' field")) return nil } - let monitorDescriptionResult = parseMonitorDescription(key, backtrace) - - guard let monitorDescription = monitorDescriptionResult.getOrNil(appendErrorTo: &errors) else { return nil } + let windows = table["windows"].flatMap { parseSimpleType($0) as Int? } + let workspace = table["workspace"].flatMap { parseSimpleType($0) as String? } - guard let value = parseSimpleType(value) as T? else { - errors.append(.semantic(backtrace, "Expected type is '\(T.self)'. But actual type is '\(value.type)'")) + if let workspace, (try? NSRegularExpression(pattern: workspace)) == nil { + errors.append(.semantic(backtrace, "Invalid workspace pattern")) return nil } - return PerMonitorValue(description: monitorDescription, value: value) + return PerMonitorValue( + description: monitorDescription, + value: value, + windows: windows, + workspace: workspace + ) } } diff --git a/Sources/AppBundle/config/parseGaps.swift b/Sources/AppBundle/config/parseGaps.swift index afbbc9ff1..9dd11b3d9 100644 --- a/Sources/AppBundle/config/parseGaps.swift +++ b/Sources/AppBundle/config/parseGaps.swift @@ -68,17 +68,18 @@ struct ResolvedGaps { let right: Int } - init(gaps: Gaps, monitor: any Monitor) { + @MainActor + init(gaps: Gaps, monitor: any Monitor, windowCount: Int = 1) { inner = .init( - vertical: gaps.inner.vertical.getValue(for: monitor), - horizontal: gaps.inner.horizontal.getValue(for: monitor) + vertical: gaps.inner.vertical.getValue(for: monitor, windowCount: windowCount), + horizontal: gaps.inner.horizontal.getValue(for: monitor, windowCount: windowCount) ) outer = .init( - left: gaps.outer.left.getValue(for: monitor), - bottom: gaps.outer.bottom.getValue(for: monitor), - top: gaps.outer.top.getValue(for: monitor), - right: gaps.outer.right.getValue(for: monitor) + left: gaps.outer.left.getValue(for: monitor, windowCount: windowCount), + bottom: gaps.outer.bottom.getValue(for: monitor, windowCount: windowCount), + top: gaps.outer.top.getValue(for: monitor, windowCount: windowCount), + right: gaps.outer.right.getValue(for: monitor, windowCount: windowCount) ) } } diff --git a/Sources/AppBundleTests/config/ConfigTest.swift b/Sources/AppBundleTests/config/ConfigTest.swift index 2f138d0ce..ee7ad95ea 100644 --- a/Sources/AppBundleTests/config/ConfigTest.swift +++ b/Sources/AppBundleTests/config/ConfigTest.swift @@ -302,7 +302,10 @@ final class ConfigTest: XCTestCase { Gaps( inner: .init( vertical: .perMonitor( - [PerMonitorValue(description: .main, value: 1), PerMonitorValue(description: .secondary, value: 2)], + [ + PerMonitorValue(description: .main, value: 1, windows: nil, workspace: nil), + PerMonitorValue(description: .secondary, value: 2, windows: nil, workspace: nil), + ], default: 5 ), horizontal: .constant(10) @@ -312,12 +315,12 @@ final class ConfigTest: XCTestCase { bottom: .constant(13), top: .perMonitor( [ - PerMonitorValue(description: .pattern("built-in")!, value: 3), - PerMonitorValue(description: .secondary, value: 4), + PerMonitorValue(description: .pattern("built-in")!, value: 3, windows: nil, workspace: nil), + PerMonitorValue(description: .secondary, value: 4, windows: nil, workspace: nil), ], default: 6 ), - right: .perMonitor([PerMonitorValue(description: .sequenceNumber(2), value: 7)], default: 8) + right: .perMonitor([PerMonitorValue(description: .sequenceNumber(2), value: 7, windows: nil, workspace: nil)], default: 8) ) ) ) diff --git a/Sources/AppBundleTests/config/DynamicConfigValueTests.swift b/Sources/AppBundleTests/config/DynamicConfigValueTests.swift new file mode 100644 index 000000000..bdb5c606a --- /dev/null +++ b/Sources/AppBundleTests/config/DynamicConfigValueTests.swift @@ -0,0 +1,153 @@ +@testable import AppBundle +import Common +import TOMLKit +import XCTest + +final class DynamicConfigValueTests: XCTestCase { + @MainActor + func testConstantValue() { + let value: DynamicConfigValue = .constant(42) + let monitor = TestMonitor() + + XCTAssertEqual(value.getValue(for: monitor), 42) + XCTAssertEqual(value.getValue(for: monitor, windowCount: 1), 42) + XCTAssertEqual(value.getValue(for: monitor, windowCount: 2), 42) + } + + @MainActor + func testPerMonitorWithoutWindows() { + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor([ + PerMonitorValue(description: mainDesc, value: 16, windows: nil, workspace: nil), + ], default: 8) + + // Should match monitor without considering windows + XCTAssertEqual(value.getValue(for: mainMonitor), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 16) + + // Should use default for non-matching monitor + XCTAssertEqual(value.getValue(for: secondaryMonitor), 8) + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) + } + + @MainActor + func testPerMonitorWithWindows() { + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor([ + PerMonitorValue(description: mainDesc, value: 16, windows: 1, workspace: nil), + PerMonitorValue(description: mainDesc, value: 32, windows: 2, workspace: nil), + PerMonitorValue(description: mainDesc, value: 24, windows: nil, workspace: nil), + ], default: 8) + + // Test exact matches - должны работать только точные совпадения + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 32) + + // Test fallback to monitor-only value при отсутствии точного совпадения + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 24) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 4), 24) + + // Test fallback to default + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 2), 8) + } + + @MainActor + func testSingleWindowRule() { + // Этот тест проверяет случай, когда указано правило только для 1 окна + let mainMonitor = TestMonitor(id: "main") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor([ + PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil), + ], default: 8) + + // Точное совпадение для 1 окна + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) + + // Для 2 и более окон должно использоваться значение по умолчанию + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 8) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 8) + } + + @MainActor + func testIgnoreFloatingWindows() { + // Этот тест проверяет, что плавающие окна игнорируются при подсчете + // Однако, в тестах мы просто используем значение windowCount + // Поэтому мы проверяем это косвенно, при помощи isUnitTest и соответствующего кода + + let mainMonitor = TestMonitor(id: "main") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor([ + PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil), + PerMonitorValue(description: mainDesc, value: 360, windows: 2, workspace: nil), + ], default: 8) + + // Тесты работают с переданным windowCount + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 360) + } + + @MainActor + func testConfigParsing() async throws { + let config = """ + [gaps] + inner.vertical = [ + { monitor.main = { value = 16, windows = 1 } }, + { monitor.main = { value = 32, windows = 2 } }, + { monitor.secondary = 24 }, + 8 + ] + """ + + let (parsed, parseErrors) = parseConfig(config) + XCTAssertTrue(parseErrors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let resolvedMain1 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 1) + XCTAssertEqual(resolvedMain1.inner.vertical, 16) + + let resolvedMain2 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 2) + XCTAssertEqual(resolvedMain2.inner.vertical, 32) + + let resolvedSecondary = ResolvedGaps(gaps: parsed.gaps, monitor: secondaryMonitor) + XCTAssertEqual(resolvedSecondary.inner.vertical, 24) + } +} + +@MainActor +private struct TestMonitor: Monitor { + var id: String + var name: String + var monitorAppKitNsScreenScreensId: Int + var rect: Rect + var visibleRect: Rect + var width: CGFloat + var height: CGFloat + + init(id: String = "test") { + self.id = id + self.name = id + self.monitorAppKitNsScreenScreensId = 1 + self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.width = 0 + self.height = 0 + } +} + +private struct MockBacktrace { + init() {} + func appending(_ component: String) -> Self { self } + static func + (lhs: Self, rhs: String) -> Self { lhs } +} diff --git a/Sources/AppBundleTests/config/parseGapsTests.swift b/Sources/AppBundleTests/config/parseGapsTests.swift new file mode 100644 index 000000000..abbad79a0 --- /dev/null +++ b/Sources/AppBundleTests/config/parseGapsTests.swift @@ -0,0 +1,155 @@ +@testable import AppBundle +import Common +import TOMLKit +import XCTest + +final class ParseGapsTests: XCTestCase { + @MainActor + func testParseSimpleGaps() { + let config = [ + "inner": [ + "vertical": 10, + "horizontal": 20, + ], + "outer": [ + "left": 5, + "bottom": 6, + "top": 7, + "right": 8, + ], + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let monitor = TestMonitor() + let resolved = ResolvedGaps(gaps: gaps, monitor: monitor) + + XCTAssertEqual(resolved.inner.vertical, 10) + XCTAssertEqual(resolved.inner.horizontal, 20) + XCTAssertEqual(resolved.outer.left, 5) + XCTAssertEqual(resolved.outer.bottom, 6) + XCTAssertEqual(resolved.outer.top, 7) + XCTAssertEqual(resolved.outer.right, 8) + } + + @MainActor + func testParsePerMonitorGaps() { + let config = [ + "outer": [ + "top": [ + ["monitor": ["main": ["value": 16]]], + 8, + ], + ], + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let otherMonitor = TestMonitor(id: "other") + + let mainResolved = ResolvedGaps(gaps: gaps, monitor: mainMonitor) + let otherResolved = ResolvedGaps(gaps: gaps, monitor: otherMonitor) + + XCTAssertEqual(mainResolved.outer.top, 16) + XCTAssertEqual(otherResolved.outer.top, 8) + } + + @MainActor + func testParseWindowDependentGaps() { + let config = [ + "outer": [ + "top": [ + ["monitor": ["main": ["value": 16, "windows": 1]]], + ["monitor": ["main": ["value": 32, "windows": 2]]], + ["monitor": ["main": ["value": 24]]], + 8, + ], + ], + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let otherMonitor = TestMonitor(id: "other") + + // Test exact window count matches + let oneWindow = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 1) + XCTAssertEqual(oneWindow.outer.top, 16) + + let twoWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 2) + XCTAssertEqual(twoWindows.outer.top, 32) + + // Test fallback to monitor-only value + let threeWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 3) + XCTAssertEqual(threeWindows.outer.top, 24) + + // Test fallback to default value + let otherMonitorGaps = ResolvedGaps(gaps: gaps, monitor: otherMonitor, windowCount: 1) + XCTAssertEqual(otherMonitorGaps.outer.top, 8) + } +} + +@MainActor private struct TestMonitor: Monitor { + var id: String + var name: String + var monitorAppKitNsScreenScreensId: Int + var rect: Rect + var visibleRect: Rect + var width: CGFloat + var height: CGFloat + + init(id: String = "test") { + self.id = id + self.name = id + self.monitorAppKitNsScreenScreensId = 1 + self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.width = 0 + self.height = 0 + } +} + +private extension Dictionary where Key == String { + var tomlValue: TOMLKit.TOMLValueConvertible { + let table = TOMLTable() + for (key, value) in self { + let tomlValue: TOMLValue + if let int = value as? Int { + tomlValue = TOMLValue(integerLiteral: int) + } else if let dict = value as? [String: Any] { + tomlValue = TOMLValue(dict.tomlValue as! TOMLTable) + } else if let array = value as? [Any] { + // Создаем TOMLArray и преобразуем его в TOMLValue + let tomlArray = TOMLArray() + for item in array { + if let dict = item as? [String: Any] { + let table = dict.tomlValue as! TOMLTable + tomlArray.append(table) + } else if let intValue = item as? Int { + tomlArray.append(intValue) + } else { + fatalError("Unsupported array item type: \(Swift.type(of: item))") + } + } + tomlValue = tomlArray.tomlValue + } else { + fatalError("Unsupported type") + } + table[key] = tomlValue + } + return table + } +} diff --git a/docs/config-examples/default-config.toml b/docs/config-examples/default-config.toml index cf74052e7..07fe9d32d 100644 --- a/docs/config-examples/default-config.toml +++ b/docs/config-examples/default-config.toml @@ -51,11 +51,32 @@ automatically-unhide-macos-hidden-apps = false # Gaps between windows (inner-*) and between monitor edges (outer-*). # Possible values: # - Constant: gaps.outer.top = 8 -# - Per monitor: gaps.outer.top = [{ monitor.main = 16 }, { monitor."some-pattern" = 32 }, 24] -# In this example, 24 is a default value when there is no match. -# Monitor pattern is the same as for 'workspace-to-monitor-force-assignment'. -# See: -# https://nikitabobko.github.io/AeroSpace/guide#assign-workspaces-to-monitors +# - Per monitor: gaps.outer.top = [ +# # Match specific monitor with window count and workspace +# { monitor.main = { value = 16, windows = 1, workspace = "[123]" } }, +# # Match specific monitor with window count only +# { monitor.main = { value = 32, windows = 2 } }, +# # Match specific monitor with workspace only +# { monitor.secondary = { value = 24, workspace = "[^B]" } }, +# # Match specific monitor without conditions +# { monitor."some-pattern" = 32 }, +# 24 # default value when there is no match +# ] +# +# Parameters: +# - value: The gap size in pixels +# - windows: (optional) Apply this value only when the monitor has exactly this many windows +# - workspace: (optional) Apply this value only when the workspace name matches this regex pattern +# +# When multiple rules match: +# 1. Rule with matching windows AND workspace has highest priority +# 2. Rule with matching windows only has second priority +# 3. Rule with matching workspace only has third priority +# 4. Rule without conditions has fourth priority +# 5. Default value is used if no rules match +# +# Monitor pattern is the same as for 'workspace-to-monitor-force-assignment'. +# See: https://nikitabobko.github.io/AeroSpace/guide#assign-workspaces-to-monitors [gaps] inner.horizontal = 0 inner.vertical = 0 From bf7c8a6f496de575497f6079064318cf28cb19f8 Mon Sep 17 00:00:00 2001 From: Xander Forge <65961104+xand3r40r93@users.noreply.github.com> Date: Fri, 11 Apr 2025 16:08:54 +0100 Subject: [PATCH 2/2] test: translate Russian comments to English --- .../config/DynamicConfigValueTests.swift | 285 +++++++++--------- .../config/parseGapsTests.swift | 285 +++++++++--------- 2 files changed, 288 insertions(+), 282 deletions(-) diff --git a/Sources/AppBundleTests/config/DynamicConfigValueTests.swift b/Sources/AppBundleTests/config/DynamicConfigValueTests.swift index bdb5c606a..611d869db 100644 --- a/Sources/AppBundleTests/config/DynamicConfigValueTests.swift +++ b/Sources/AppBundleTests/config/DynamicConfigValueTests.swift @@ -1,153 +1,158 @@ -@testable import AppBundle import Common import TOMLKit import XCTest +@testable import AppBundle + final class DynamicConfigValueTests: XCTestCase { - @MainActor - func testConstantValue() { - let value: DynamicConfigValue = .constant(42) - let monitor = TestMonitor() - - XCTAssertEqual(value.getValue(for: monitor), 42) - XCTAssertEqual(value.getValue(for: monitor, windowCount: 1), 42) - XCTAssertEqual(value.getValue(for: monitor, windowCount: 2), 42) - } - - @MainActor - func testPerMonitorWithoutWindows() { - let mainMonitor = TestMonitor(id: "main") - let secondaryMonitor = TestMonitor(id: "secondary") - - let mainDesc = MonitorDescription.pattern("main")! - let value: DynamicConfigValue = .perMonitor([ - PerMonitorValue(description: mainDesc, value: 16, windows: nil, workspace: nil), - ], default: 8) - - // Should match monitor without considering windows - XCTAssertEqual(value.getValue(for: mainMonitor), 16) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 16) - - // Should use default for non-matching monitor - XCTAssertEqual(value.getValue(for: secondaryMonitor), 8) - XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) - } - - @MainActor - func testPerMonitorWithWindows() { - let mainMonitor = TestMonitor(id: "main") - let secondaryMonitor = TestMonitor(id: "secondary") - - let mainDesc = MonitorDescription.pattern("main")! - let value: DynamicConfigValue = .perMonitor([ - PerMonitorValue(description: mainDesc, value: 16, windows: 1, workspace: nil), - PerMonitorValue(description: mainDesc, value: 32, windows: 2, workspace: nil), - PerMonitorValue(description: mainDesc, value: 24, windows: nil, workspace: nil), - ], default: 8) - - // Test exact matches - должны работать только точные совпадения - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 32) - - // Test fallback to monitor-only value при отсутствии точного совпадения - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 24) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 4), 24) - - // Test fallback to default - XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) - XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 2), 8) - } - - @MainActor - func testSingleWindowRule() { - // Этот тест проверяет случай, когда указано правило только для 1 окна - let mainMonitor = TestMonitor(id: "main") - - let mainDesc = MonitorDescription.pattern("main")! - let value: DynamicConfigValue = .perMonitor([ - PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil), - ], default: 8) - - // Точное совпадение для 1 окна - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) - - // Для 2 и более окон должно использоваться значение по умолчанию - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 8) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 8) - } - - @MainActor - func testIgnoreFloatingWindows() { - // Этот тест проверяет, что плавающие окна игнорируются при подсчете - // Однако, в тестах мы просто используем значение windowCount - // Поэтому мы проверяем это косвенно, при помощи isUnitTest и соответствующего кода - - let mainMonitor = TestMonitor(id: "main") - - let mainDesc = MonitorDescription.pattern("main")! - let value: DynamicConfigValue = .perMonitor([ - PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil), - PerMonitorValue(description: mainDesc, value: 360, windows: 2, workspace: nil), - ], default: 8) - - // Тесты работают с переданным windowCount - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) - XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 360) - } - - @MainActor - func testConfigParsing() async throws { - let config = """ - [gaps] - inner.vertical = [ - { monitor.main = { value = 16, windows = 1 } }, - { monitor.main = { value = 32, windows = 2 } }, - { monitor.secondary = 24 }, - 8 - ] - """ - - let (parsed, parseErrors) = parseConfig(config) - XCTAssertTrue(parseErrors.isEmpty) - - let mainMonitor = TestMonitor(id: "main") - let secondaryMonitor = TestMonitor(id: "secondary") - - let resolvedMain1 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 1) - XCTAssertEqual(resolvedMain1.inner.vertical, 16) - - let resolvedMain2 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 2) - XCTAssertEqual(resolvedMain2.inner.vertical, 32) - - let resolvedSecondary = ResolvedGaps(gaps: parsed.gaps, monitor: secondaryMonitor) - XCTAssertEqual(resolvedSecondary.inner.vertical, 24) - } + @MainActor + func testConstantValue() { + let value: DynamicConfigValue = .constant(42) + let monitor = TestMonitor() + + XCTAssertEqual(value.getValue(for: monitor), 42) + XCTAssertEqual(value.getValue(for: monitor, windowCount: 1), 42) + XCTAssertEqual(value.getValue(for: monitor, windowCount: 2), 42) + } + + @MainActor + func testPerMonitorWithoutWindows() { + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor( + [ + PerMonitorValue(description: mainDesc, value: 16, windows: nil, workspace: nil) + ], default: 8) + + // Should match monitor without considering windows + XCTAssertEqual(value.getValue(for: mainMonitor), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 16) + + // Should use default for non-matching monitor + XCTAssertEqual(value.getValue(for: secondaryMonitor), 8) + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) + } + + @MainActor + func testPerMonitorWithWindows() { + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor( + [ + PerMonitorValue(description: mainDesc, value: 16, windows: 1, workspace: nil), + PerMonitorValue(description: mainDesc, value: 32, windows: 2, workspace: nil), + PerMonitorValue(description: mainDesc, value: 24, windows: nil, workspace: nil), + ], default: 8) + + // Test exact matches - only exact matches should work + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 32) + + // Test fallback to monitor-only value when there's no exact match + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 24) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 4), 24) + + // Test fallback to default + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8) + XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 2), 8) + } + + @MainActor + func testSingleWindowRule() { + // This test verifies the case when a rule is specified only for 1 window + let mainMonitor = TestMonitor(id: "main") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor( + [ + PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil) + ], default: 8) + + // Exact match for 1 window + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) + + // Default value should be used for 2 or more windows + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 8) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 8) + } + + @MainActor + func testIgnoreFloatingWindows() { + // This test verifies that floating windows are ignored in the count + // However, in tests we simply use the windowCount value + // So we verify this indirectly using isUnitTest and corresponding code + + let mainMonitor = TestMonitor(id: "main") + + let mainDesc = MonitorDescription.pattern("main")! + let value: DynamicConfigValue = .perMonitor( + [ + PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil), + PerMonitorValue(description: mainDesc, value: 360, windows: 2, workspace: nil), + ], default: 8) + + // Tests work with the provided windowCount + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720) + XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 360) + } + + @MainActor + func testConfigParsing() async throws { + let config = """ + [gaps] + inner.vertical = [ + { monitor.main = { value = 16, windows = 1 } }, + { monitor.main = { value = 32, windows = 2 } }, + { monitor.secondary = 24 }, + 8 + ] + """ + + let (parsed, parseErrors) = parseConfig(config) + XCTAssertTrue(parseErrors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let secondaryMonitor = TestMonitor(id: "secondary") + + let resolvedMain1 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 1) + XCTAssertEqual(resolvedMain1.inner.vertical, 16) + + let resolvedMain2 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 2) + XCTAssertEqual(resolvedMain2.inner.vertical, 32) + + let resolvedSecondary = ResolvedGaps(gaps: parsed.gaps, monitor: secondaryMonitor) + XCTAssertEqual(resolvedSecondary.inner.vertical, 24) + } } @MainActor private struct TestMonitor: Monitor { - var id: String - var name: String - var monitorAppKitNsScreenScreensId: Int - var rect: Rect - var visibleRect: Rect - var width: CGFloat - var height: CGFloat - - init(id: String = "test") { - self.id = id - self.name = id - self.monitorAppKitNsScreenScreensId = 1 - self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) - self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) - self.width = 0 - self.height = 0 - } + var id: String + var name: String + var monitorAppKitNsScreenScreensId: Int + var rect: Rect + var visibleRect: Rect + var width: CGFloat + var height: CGFloat + + init(id: String = "test") { + self.id = id + self.name = id + self.monitorAppKitNsScreenScreensId = 1 + self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.width = 0 + self.height = 0 + } } private struct MockBacktrace { - init() {} - func appending(_ component: String) -> Self { self } - static func + (lhs: Self, rhs: String) -> Self { lhs } + init() {} + func appending(_ component: String) -> Self { self } + static func + (lhs: Self, rhs: String) -> Self { lhs } } diff --git a/Sources/AppBundleTests/config/parseGapsTests.swift b/Sources/AppBundleTests/config/parseGapsTests.swift index abbad79a0..8dd3b8e70 100644 --- a/Sources/AppBundleTests/config/parseGapsTests.swift +++ b/Sources/AppBundleTests/config/parseGapsTests.swift @@ -1,155 +1,156 @@ -@testable import AppBundle import Common import TOMLKit import XCTest -final class ParseGapsTests: XCTestCase { - @MainActor - func testParseSimpleGaps() { - let config = [ - "inner": [ - "vertical": 10, - "horizontal": 20, - ], - "outer": [ - "left": 5, - "bottom": 6, - "top": 7, - "right": 8, - ], - ].tomlValue - - var errors: [TomlParseError] = [] - let mockBacktrace: TomlBacktrace = .root - let gaps = parseGaps(config, mockBacktrace, &errors) - - XCTAssertTrue(errors.isEmpty) - - let monitor = TestMonitor() - let resolved = ResolvedGaps(gaps: gaps, monitor: monitor) - - XCTAssertEqual(resolved.inner.vertical, 10) - XCTAssertEqual(resolved.inner.horizontal, 20) - XCTAssertEqual(resolved.outer.left, 5) - XCTAssertEqual(resolved.outer.bottom, 6) - XCTAssertEqual(resolved.outer.top, 7) - XCTAssertEqual(resolved.outer.right, 8) - } - - @MainActor - func testParsePerMonitorGaps() { - let config = [ - "outer": [ - "top": [ - ["monitor": ["main": ["value": 16]]], - 8, - ], - ], - ].tomlValue - - var errors: [TomlParseError] = [] - let mockBacktrace: TomlBacktrace = .root - let gaps = parseGaps(config, mockBacktrace, &errors) - - XCTAssertTrue(errors.isEmpty) - - let mainMonitor = TestMonitor(id: "main") - let otherMonitor = TestMonitor(id: "other") - - let mainResolved = ResolvedGaps(gaps: gaps, monitor: mainMonitor) - let otherResolved = ResolvedGaps(gaps: gaps, monitor: otherMonitor) - - XCTAssertEqual(mainResolved.outer.top, 16) - XCTAssertEqual(otherResolved.outer.top, 8) - } +@testable import AppBundle - @MainActor - func testParseWindowDependentGaps() { - let config = [ - "outer": [ - "top": [ - ["monitor": ["main": ["value": 16, "windows": 1]]], - ["monitor": ["main": ["value": 32, "windows": 2]]], - ["monitor": ["main": ["value": 24]]], - 8, - ], - ], - ].tomlValue - - var errors: [TomlParseError] = [] - let mockBacktrace: TomlBacktrace = .root - let gaps = parseGaps(config, mockBacktrace, &errors) - - XCTAssertTrue(errors.isEmpty) - - let mainMonitor = TestMonitor(id: "main") - let otherMonitor = TestMonitor(id: "other") - - // Test exact window count matches - let oneWindow = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 1) - XCTAssertEqual(oneWindow.outer.top, 16) - - let twoWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 2) - XCTAssertEqual(twoWindows.outer.top, 32) - - // Test fallback to monitor-only value - let threeWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 3) - XCTAssertEqual(threeWindows.outer.top, 24) - - // Test fallback to default value - let otherMonitorGaps = ResolvedGaps(gaps: gaps, monitor: otherMonitor, windowCount: 1) - XCTAssertEqual(otherMonitorGaps.outer.top, 8) - } +final class ParseGapsTests: XCTestCase { + @MainActor + func testParseSimpleGaps() { + let config = [ + "inner": [ + "vertical": 10, + "horizontal": 20, + ], + "outer": [ + "left": 5, + "bottom": 6, + "top": 7, + "right": 8, + ], + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let monitor = TestMonitor() + let resolved = ResolvedGaps(gaps: gaps, monitor: monitor) + + XCTAssertEqual(resolved.inner.vertical, 10) + XCTAssertEqual(resolved.inner.horizontal, 20) + XCTAssertEqual(resolved.outer.left, 5) + XCTAssertEqual(resolved.outer.bottom, 6) + XCTAssertEqual(resolved.outer.top, 7) + XCTAssertEqual(resolved.outer.right, 8) + } + + @MainActor + func testParsePerMonitorGaps() { + let config = [ + "outer": [ + "top": [ + ["monitor": ["main": ["value": 16]]], + 8, + ] + ] + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let otherMonitor = TestMonitor(id: "other") + + let mainResolved = ResolvedGaps(gaps: gaps, monitor: mainMonitor) + let otherResolved = ResolvedGaps(gaps: gaps, monitor: otherMonitor) + + XCTAssertEqual(mainResolved.outer.top, 16) + XCTAssertEqual(otherResolved.outer.top, 8) + } + + @MainActor + func testParseWindowDependentGaps() { + let config = [ + "outer": [ + "top": [ + ["monitor": ["main": ["value": 16, "windows": 1]]], + ["monitor": ["main": ["value": 32, "windows": 2]]], + ["monitor": ["main": ["value": 24]]], + 8, + ] + ] + ].tomlValue + + var errors: [TomlParseError] = [] + let mockBacktrace: TomlBacktrace = .root + let gaps = parseGaps(config, mockBacktrace, &errors) + + XCTAssertTrue(errors.isEmpty) + + let mainMonitor = TestMonitor(id: "main") + let otherMonitor = TestMonitor(id: "other") + + // Test exact window count matches + let oneWindow = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 1) + XCTAssertEqual(oneWindow.outer.top, 16) + + let twoWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 2) + XCTAssertEqual(twoWindows.outer.top, 32) + + // Test fallback to monitor-only value + let threeWindows = ResolvedGaps(gaps: gaps, monitor: mainMonitor, windowCount: 3) + XCTAssertEqual(threeWindows.outer.top, 24) + + // Test fallback to default value + let otherMonitorGaps = ResolvedGaps(gaps: gaps, monitor: otherMonitor, windowCount: 1) + XCTAssertEqual(otherMonitorGaps.outer.top, 8) + } } @MainActor private struct TestMonitor: Monitor { - var id: String - var name: String - var monitorAppKitNsScreenScreensId: Int - var rect: Rect - var visibleRect: Rect - var width: CGFloat - var height: CGFloat - - init(id: String = "test") { - self.id = id - self.name = id - self.monitorAppKitNsScreenScreensId = 1 - self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) - self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) - self.width = 0 - self.height = 0 - } + var id: String + var name: String + var monitorAppKitNsScreenScreensId: Int + var rect: Rect + var visibleRect: Rect + var width: CGFloat + var height: CGFloat + + init(id: String = "test") { + self.id = id + self.name = id + self.monitorAppKitNsScreenScreensId = 1 + self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0) + self.width = 0 + self.height = 0 + } } -private extension Dictionary where Key == String { - var tomlValue: TOMLKit.TOMLValueConvertible { - let table = TOMLTable() - for (key, value) in self { - let tomlValue: TOMLValue - if let int = value as? Int { - tomlValue = TOMLValue(integerLiteral: int) - } else if let dict = value as? [String: Any] { - tomlValue = TOMLValue(dict.tomlValue as! TOMLTable) - } else if let array = value as? [Any] { - // Создаем TOMLArray и преобразуем его в TOMLValue - let tomlArray = TOMLArray() - for item in array { - if let dict = item as? [String: Any] { - let table = dict.tomlValue as! TOMLTable - tomlArray.append(table) - } else if let intValue = item as? Int { - tomlArray.append(intValue) - } else { - fatalError("Unsupported array item type: \(Swift.type(of: item))") - } - } - tomlValue = tomlArray.tomlValue - } else { - fatalError("Unsupported type") - } - table[key] = tomlValue +extension Dictionary where Key == String { + fileprivate var tomlValue: TOMLKit.TOMLValueConvertible { + let table = TOMLTable() + for (key, value) in self { + let tomlValue: TOMLValue + if let int = value as? Int { + tomlValue = TOMLValue(integerLiteral: int) + } else if let dict = value as? [String: Any] { + tomlValue = TOMLValue(dict.tomlValue as! TOMLTable) + } else if let array = value as? [Any] { + // Create TOMLArray and convert it to TOMLValue + let tomlArray = TOMLArray() + for item in array { + if let dict = item as? [String: Any] { + let table = dict.tomlValue as! TOMLTable + tomlArray.append(table) + } else if let intValue = item as? Int { + tomlArray.append(intValue) + } else { + fatalError("Unsupported array item type: \(Swift.type(of: item))") + } } - return table + tomlValue = tomlArray.tomlValue + } else { + fatalError("Unsupported type") + } + table[key] = tomlValue } + return table + } }