From f9ce7eb546d52c49d0e93c06d9d0377c6454def5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:20:08 +0100 Subject: [PATCH 1/3] test: shared Windows/Mac fixtures for view math, zoom controller and view owner (#546) tests/fixtures/shared/ holds language-neutral cases (one per line, tagged all/win/mac) that tests/test_shared_fixtures.cpp and mac/Tests/WindCoreChecks/SharedFixtures.swift both run. The all and win values come from the C++ code; WIND_UPDATE_FIXTURES=1 rewrites them. Deliberate differences (the wheel glide path, keyboard pan speed, setLevel during the ease-out, the Mac-only still view) are per-platform rows. mac.yml now also runs on changes to the shared folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kg7JN6HgdB9FaHJsgLxovP --- .github/workflows/mac.yml | 3 +- docs/architecture/11-build-test-release.md | 7 + mac/Tests/WindCoreChecks/SharedFixtures.swift | 216 +++++++++++++ mac/Tests/WindCoreChecks/main.swift | 4 + mac/docs/architecture.md | 5 + tests/fixtures/shared/view_math_cases.txt | 105 +++++++ tests/fixtures/shared/view_owner_cases.txt | 47 +++ .../fixtures/shared/zoom_controller_cases.txt | 106 +++++++ tests/test_shared_fixtures.cpp | 287 ++++++++++++++++++ 9 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 mac/Tests/WindCoreChecks/SharedFixtures.swift create mode 100644 tests/fixtures/shared/view_math_cases.txt create mode 100644 tests/fixtures/shared/view_owner_cases.txt create mode 100644 tests/fixtures/shared/zoom_controller_cases.txt create mode 100644 tests/test_shared_fixtures.cpp diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index f4952cf9..706273f1 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -16,10 +16,11 @@ on: - '!mac/docs/**' - 'LICENSE' - 'THIRD-PARTY-NOTICES.md' + - 'tests/fixtures/shared/**' - '.github/workflows/mac.yml' pull_request: # The licence and notices ship inside the Mac zip too, so a PR that changes them runs these checks. - paths: [ 'mac/**', 'ui-shared/**', '.github/workflows/mac.yml', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'tools/check_copyright.ps1' ] + paths: [ 'mac/**', 'ui-shared/**', 'tests/fixtures/shared/**', '.github/workflows/mac.yml', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'tools/check_copyright.ps1' ] workflow_dispatch: permissions: diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index af09fc46..cefacda2 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -81,6 +81,13 @@ headlessly, so the unit tests are the only verification loop that runs everywher a slider may be narrower than the core's clamp only when listed there with a reason. After an intended change to a default or a clamp, regenerate the fixture with `set WIND_UPDATE_FIXTURES=1 && build.bat test` and review its diff. +- `tests/fixtures/shared/` holds the cases Wind shares with Wind for Mac, whose view geometry, zoom + controller and view owner are Swift ports of `src/track` and `src/zoom`: `tests/test_shared_fixtures.cpp` + runs them here, `mac/Tests/WindCoreChecks/SharedFixtures.swift` on the Mac (`mac.yml` runs on + changes to the folder). Rows are tagged `all`, `win` or `mac`; deliberate differences (the wheel + glide, keyboard pan speed at low zoom, `setLevel` during the ease-out) are per-platform rows. The + `all` and `win` values come from the C++ code: the same `WIND_UPDATE_FIXTURES=1` run rewrites them, + then the Mac has to match. **UI suite.** `ui/tests/*.spec.js` are Playwright specs, run with `npx playwright test` in `ui/` (the config starts the Vite server). Each spec installs a fake `window.chrome.webview` that answers diff --git a/mac/Tests/WindCoreChecks/SharedFixtures.swift b/mac/Tests/WindCoreChecks/SharedFixtures.swift new file mode 100644 index 00000000..ff7771fb --- /dev/null +++ b/mac/Tests/WindCoreChecks/SharedFixtures.swift @@ -0,0 +1,216 @@ +import Foundation + +@testable import WindCore + +/// The cases Wind for Mac shares with Windows Wind (the repo's tests/fixtures/shared/, also read by +/// tests/test_shared_fixtures.cpp): view geometry, the zoom controller and the view owner, which are +/// ports of the Windows code. Rows tagged all or mac run here; win rows are Windows-only. The file +/// headers give the formats. Windows is the origin, so a change there regenerates the all rows and +/// this side has to follow. +struct SharedFixtureTests { + /// mac/Tests/WindCoreChecks/ -> the repo root. + static let dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("tests/fixtures/shared") + static let tolerance = 1e-6 // documented in the fixture headers + + struct BadRow: Error, CustomStringConvertible { + let description: String + init(_ d: String) { description = d } + } + + func viewMathCases() throws { try runFile("view_math_cases.txt", atLeast: 50) } + func zoomControllerCases() throws { try runFile("zoom_controller_cases.txt", atLeast: 50) } + func viewOwnerCases() throws { try runFile("view_owner_cases.txt", atLeast: 14) } + + func runFile(_ name: String, atLeast: Int) throws { + let text = try String(contentsOf: Self.dir.appendingPathComponent(name), encoding: .utf8) + var n = 0 + for raw in text.split(whereSeparator: \.isNewline) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard toks.count >= 2, ["all", "win", "mac"].contains(toks[1]) else { + print(" \(name): bad row \"\(line)\""); expect(false); continue + } + if toks[1] == "win" { continue } + let body = Array(toks.dropFirst(2)) + let want = body.filter { $0.hasPrefix("=") }.map { String($0.dropFirst()) } + let got: [String] + do { + switch toks[0] { + case "zoom": got = try zoomSequence(body) + case "owner": got = try ownerSequence(body) + default: got = try plain(toks[0], Array(body.prefix(while: { !$0.hasPrefix("=") }))) + } + } catch { + print(" \(name): \"\(line)\": \(error)"); expect(false); continue + } + let ok = got.count == want.count && zip(want, got).allSatisfy { Self.same($0.0, $0.1) } + if !ok { print(" \(name): \"\(line)\"\n got =\(got.joined(separator: " ="))") } + expect(ok) + n += 1 + } + expect(n >= atLeast) + } + + static func same(_ want: String, _ got: String) -> Bool { + if let w = Double(want), let g = Double(got) { return abs(w - g) <= tolerance } + return want == got + } + + static func num(_ v: Double) -> String { String(format: "%.10g", v) } + + func number(_ s: String) throws -> Double { + guard let v = Double(s) else { throw BadRow("not a number: \(s)") } + return v + } + + func whole(_ s: String) throws -> Int { + let v = try number(s) + guard v == v.rounded() else { throw BadRow("not a whole number: \(s)") } + return Int(v) + } + + func keyValue(_ t: String) -> (String, String) { + guard let eq = t.firstIndex(of: "=") else { return (t, "") } + return (String(t[.. [String] { + let a = try args.map { try number($0) } + func need(_ n: Int) throws { if a.count != n { throw BadRow("\(fn): wrong input count") } } + let num = Self.num + switch fn { + case "clamp": + try need(5) + let c = clampCenter(Pt(a[0], a[1]), level: a[2], screen: Sz(a[3], a[4])) + return [num(c.x), num(c.y)] + case "edge": + try need(12) + let c = edgePanCenter(cur: Pt(a[0], a[1]), ptr: Pt(a[2], a[3]), level: a[4], screen: Sz(a[5], a[6]), + marginPct: a[7], body: CursorBody(l: a[8], t: a[9], r: a[10], b: a[11])) + return [num(c.x), num(c.y)] + case "track": + try need(11) + guard let c = trackTargetCenter(Rc(l: a[0], t: a[1], r: a[2], b: a[3]), cur: Pt(a[4], a[5]), level: a[6], + screen: Sz(a[7], a[8]), align: Int(a[9]), marginPct: a[10]) else { return ["none"] } + return [num(c.x), num(c.y)] + case "still": + try need(11) + let c = stillViewTarget(Pt(a[0], a[1]), cur: Pt(a[2], a[3]), ptr: Pt(a[4], a[5]), level: a[6], + screen: Sz(a[7], a[8]), marginPct: a[9], leadPct: a[10]) + return [num(c.x), num(c.y)] + case "keypan": + try need(3) + return [num(keyPanSpeed(level: a[0], screenWidth: a[1], panSpeed: a[2]))] + case "dir": + try need(2) + switch resolveDirection(inHeld: a[0] != 0, outHeld: a[1] != 0) { + case .zoomIn: return ["in"] + case .zoomOut: return ["out"] + case .none: return ["none"] + } + case "notch": + try need(1) + return [num(wheelNotchStep(speed: a[0]))] + case "quick": + try need(4) + let r = applyQuickZoom(cur: a[0], stored: a[1], def: a[2], maxLevel: a[3]) + return [num(r.newLevel), num(r.newStored)] + default: + throw BadRow("unknown function \(fn)") + } + } + + /// A zoom step sequence: one output per '=' token, in order. + func zoomSequence(_ toks: [String]) throws -> [String] { + var minL = 1.0, maxL = 30.0 + for t in toks { + let (k, v) = keyValue(t) + if k == "min" { minL = try number(v) } + if k == "max" { maxL = try number(v) } + } + var z = ZoomController(minLevel: minL, maxLevel: maxL) + var out: [String] = [] + for t in toks { + if t.hasPrefix("=") { out.append(Self.num(z.level)); continue } + let (k, v) = keyValue(t) + switch k { + case "min", "max": break + case "profile": + let p = try v.split(separator: ",").map { try number(String($0)) } + guard p.count == 5 else { throw BadRow("profile needs 5 values") } + z.setProfile(inSpeed: p[0], outSpeed: p[1], smooth: p[2] != 0, accel: p[3], rampSeconds: p[4]) + case "ease": try z.setEaseOut(seconds: number(v)) + case "in": z.direction = .zoomIn + case "out": z.direction = .zoomOut + case "none": z.direction = .none + case "t": + let p = v.split(separator: "*").map(String.init) + guard p.count == 2 else { throw BadRow("t needs HZ*N") } + let dt = try 1.0 / number(p[0]) + let n = try whole(p[1]) + for _ in 0.. = ... +# platform: all (both run it), win (Windows only), mac (Mac only). A function or row that differs on +# purpose is tagged per platform instead of forced to agree. +# Expected values are the tokens that start with '='. Numbers match within 1e-6 (absolute); words +# (none) match exactly. Coordinates are desktop px (Windows) or points (Mac), top-left origin; the +# view is its centre and level and shows screen/level around it. Margins are whole percent. +# all and win rows come from the C++ code (Windows is the origin): after an intended change there, +# set WIND_UPDATE_FIXTURES=1 && build.bat test +# rewrites them, then the Mac must match. mac rows are written by hand from the Swift code. +# +# clamp cx cy level W H =cx =cy (ComputeOffsetF + half a view / clampCenter) +# edge curX curY ptrX ptrY level W H margin bL bT bR bB =cx =cy (EdgePanCenter, no MPO wall / edgePanCenter) +# track l t r b curX curY level W H align margin =cx =cy, or =none for a rejected rect +# still tX tY curX curY ptrX ptrY level W H margin lead =tx =ty (Mac only: stillViewTarget has no Windows twin) +# keypan level W panSpeed =desktop px per second (differs on purpose: see below) + +# clamp: the centre stays half a view from every edge +clamp all 960 540 2 1920 1080 =960 =540 +clamp all 10 1070 4 1920 1080 =240 =945 +clamp all 500 500 1 1920 1080 =960 =540 +clamp all 100 100 0.5 1920 1080 =960 =540 +clamp all 0 0 2 1920 1080 =480 =270 +clamp all 1920 1080 2 1920 1080 =1440 =810 +clamp all 1919 5 8 1920 1080 =1800 =67.5 +clamp all 3000 -500 3 1920 1080 =1600 =180 +clamp all 2500 1200 30 2560 1440 =2500 =1200 +clamp all 1 1 64 3840 2160 =30 =16.875 +clamp all 3839 2159 64 3840 2160 =3810 =2143.125 +clamp all 1234.5 678.25 7.3 3840 2160 =1234.5 =678.25 +clamp all 1000 500 1.5 1366 768 =910.6666667 =500 + +# edge: still inside the band, then out past each side, corners, cursor bodies, high zoom +edge all 960 540 1000 600 2 1920 1080 0 0 0 0 0 =960 =540 +edge all 960 540 1500 540 2 1920 1080 0 0 0 0 0 =1020 =540 +edge all 960 540 1440 810 2 1920 1080 0 0 0 0 0 =960 =540 +edge all 960 540 1500 540 2 1920 1080 10 0 0 0 0 =1116 =540 +edge all 960 540 400 100 2 1920 1080 10 0 0 0 0 =784 =316 +edge all 960 540 1000 600 2 1920 1080 10 1 1 12 19 =960 =540 +edge all 960 540 1340 800 2 1920 1080 10 1 1 12 19 =968 =603 +edge all 480 270 0 0 2 1920 1080 10 0 0 0 0 =480 =270 +edge all 1440 810 1919 1079 2 1920 1080 15 1 1 12 19 =1440 =810 +edge all 480 270 1919 1079 2 1920 1080 15 1 1 12 19 =1440 =810 +edge all 960 540 1100 560 16 1920 1080 20 1 1 12 19 =1076 =558.75 +edge all 960 540 700 300 16 1920 1080 20 1 1 12 19 =735 =319.25 +edge all 960 540 990 550 30 1920 1080 40 1 1 12 19 =995.5 =559 +edge all 960 540 0 0 30 1920 1080 40 1 1 12 19 =32 =18 +edge all 960 540 0 0 1 1920 1080 10 0 0 0 0 =960 =540 +edge all 960 540 100 100 0.5 1920 1080 10 0 0 0 0 =960 =540 +edge all 1920 1080 3000 2000 5 3840 2160 25 1 1 12 19 =2820 =1911 +edge all 1920 1080 1000 1500 5 3840 2160 25 1 1 12 19 =1191 =1411 +edge all 600 400 1200 700 3.7 2560 1440 12 2 2 16 26 =953.0810811 =578.1081081 + +# track: rejected rects +track all 0 0 0 0 960 540 2 1920 1080 0 15 =none +track all 3000 10 3001 20 960 540 2 1920 1080 0 15 =none +track all 10 3000 20 3010 960 540 2 1920 1080 0 15 =none +track all 10 10 5 20 960 540 2 1920 1080 0 15 =none +track all 10 10 20 5 960 540 2 1920 1080 0 15 =none +track all -20 -20 -1 -1 960 540 2 1920 1080 0 15 =none +track all 100 100 100 100 960 540 2 1920 1080 0 15 =none +# track: centred (align 0), clamped at the edges +track all 700 300 702 318 960 540 2 1920 1080 0 15 =701 =309 +track all 100 100 100 120 960 540 2 1920 1080 0 15 =480 =270 +track all 1900 1060 1902 1078 960 540 4 1920 1080 0 15 =1680 =945 +track all 1915 500 1925 520 960 540 2 1920 1080 0 15 =1440 =510 +track all 300 300 302 318 960 540 0.5 1920 1080 0 15 =960 =540 +track all 300 300 302 318 960 540 20 1920 1080 0 15 =301 =309 +# track: just far enough (align 1) +track all 900 500 902 518 960 540 2 1920 1080 1 15 =960 =540 +track all 1400 500 1402 518 960 540 2 1920 1080 1 15 =1066 =540 +track all 500 100 502 118 960 540 2 1920 1080 1 15 =836 =289 +track all 600 300 1500 320 960 540 2 1920 1080 1 15 =936 =489 +track all 1400 500 1402 518 960 540 2 1920 1080 1 0 =960 =540 +track all 1000 560 1002 578 960 540 20 1920 1080 1 15 =968.4 =559.1 +track all 1910 1070 1912 1088 960 540 3 1920 1080 1 10 =1600 =900 + +# keypan: Windows' ZoomRateScale power curve vs the Mac's linear ease (0.5 at 1x up to 1 at 3x) +keypan win 1 1920 1 =746.7025787 +keypan win 2 1920 1 =565.7281521 +keypan win 4 1920 1 =425.3532714 +keypan win 7.5 1920 1 =299.0675978 +keypan win 10 1920 1 =235.0731999 +keypan win 30 1920 1 =79.99075169 +keypan win 4 3840 2.5 =2126.766357 +keypan mac 1 1920 1 =1200 +keypan mac 2 1920 1 =900 +keypan mac 4 1920 1 =600 +keypan mac 7.5 1920 1 =320 +keypan mac 10 1920 1 =240 +keypan mac 30 1920 1 =80 +keypan mac 4 3840 2.5 =3000 + +# still: the Mac's screenshot/colour-sampler mode (macOS's own zoom and cursor) +still mac 960 540 960 540 1100 600 3 1920 1080 15 0 =960 =540 +still mac 960 540 960 540 1200 600 3 1920 1080 15 0 =1200 =540 +still mac 1200 540 1100 540 1210 560 3 1920 1080 15 0 =1200 =540 +still mac 1600 900 1600 900 1915 1075 3 1920 1080 15 0 =1600 =900 +still mac 960 540 960 540 1200 600 3 1920 1080 15 20 =1328 =540 +still mac 960 540 960 540 900 400 3 1920 1080 15 20 =960 =328 +still mac 960 540 960 540 10 10 2 1920 1080 10 0 =480 =270 +still mac 960 540 960 540 1000 600 1 1920 1080 10 0 =960 =540 diff --git a/tests/fixtures/shared/view_owner_cases.txt b/tests/fixtures/shared/view_owner_cases.txt new file mode 100644 index 00000000..efa73d98 --- /dev/null +++ b/tests/fixtures/shared/view_owner_cases.txt @@ -0,0 +1,47 @@ +# View owner cases shared by Wind (Windows) and Wind for Mac. Read by tests/test_shared_fixtures.cpp +# (src/track/view_target.h, StepViewOwner) and by mac/Tests/WindCoreChecks/SharedFixtures.swift +# (mac/Sources/WindCore/ViewOwner.swift, stepViewOwner). +# +# One case per line: owner , tokens run left to right on one ViewOwnerState. +# platform: all (both run it), win (Windows only), mac (Mac only). +# Inputs are key=value and stay set for the rest of the line, except dx, dy and btn, which last one +# step. Before the first token: en=1 tc=1 tf=1 dt=8, everything else the ViewOwnerInputs default. +# en enabled (zoomed, not locked) tc / tf track caret / focus +# dx dy pointer movement this step, px btn a mouse button is down +# sb ms since a button sk ms since a key (0 = unknown, no gate) +# kab a typing key went down after the button dt ms this step +# kind none|caret|focus seq the tracker's report number pan keyboard panning act tracking on +# reset ResetViewOwnerForSession (a new zoom session) +# =owner:warp:seq one StepViewOwner with the inputs so far, then the expected owner +# (mouse|caret|focus|keys), warpPointer (0/1) and the latched target's seq. +# Expected values come from the C++ code: after an intended change there, +# set WIND_UPDATE_FIXTURES=1 && build.bat test +# rewrites the all and win rows, then the Mac must match. + +# a typed caret takes the view; the mouse takes it back on real movement and the pointer comes to the view +owner all reset sk=100 kind=caret seq=1 =mouse:0:0 seq=2 =caret:0:2 dx=4 =mouse:1:2 +# a new zoom session re-baselines: a report from before the zoom-in never fires +owner all sk=100 kind=caret seq=5 en=0 =mouse:0:0 reset en=1 =mouse:0:0 seq=6 =caret:0:6 +# no key within a second: the caret is ignored (scrolling moves a caret without a key) +owner all reset kind=caret seq=1 sk=5000 =mouse:0:0 seq=2 =mouse:0:0 sk=1000 seq=3 =caret:0:3 +# the first step on a fresh state counts as already tracking, so seq 1 fires at once +owner all sk=100 kind=caret seq=1 =caret:0:1 =caret:0:1 +# kinds that are off leave the mouse in charge; focus follows its own switch +owner all tc=0 sk=100 kind=caret seq=1 =mouse:0:0 kind=focus seq=2 =focus:0:2 tf=0 seq=3 =focus:0:2 +# jitter: under 3 px within 100 ms keeps the caret; 3 px takes the view back +owner all sk=100 kind=caret seq=1 =caret:0:1 dx=1 =caret:0:1 dx=1 =caret:0:1 dx=1 =mouse:1:1 +owner all sk=100 kind=caret seq=1 =caret:0:1 dx=1 dy=1 =caret:0:1 dy=-1 =mouse:1:1 +# movement spread over more than 100 ms never adds up +owner all sk=100 kind=caret seq=1 =caret:0:1 dx=2 dt=60 =caret:0:1 =caret:0:1 dx=2 =caret:0:1 dx=1 dt=8 =mouse:1:1 +# a button gives the view back without moving the pointer, and its quiet second swallows the click's caret +owner all sk=100 kind=caret seq=1 =caret:0:1 btn=1 =mouse:0:1 sb=0 seq=2 =mouse:0:1 sb=400 seq=3 =mouse:0:1 sb=1000 seq=4 =caret:0:4 +# a typing key after the click ends the quiet second early (#328) +owner all sk=100 kind=caret seq=1 btn=1 =mouse:0:0 sb=200 seq=2 =mouse:0:0 kab=1 seq=3 =caret:0:3 +# keyboard panning owns the view ahead of a caret, the mouse takes it back from the keys +owner all sk=100 pan=1 kind=caret seq=1 =keys:0:0 seq=2 =keys:0:0 pan=0 =keys:0:0 dx=5 =mouse:1:0 +# disabled (zoomed out or locked): the mouse resumes and reports seen meanwhile never fire +owner all sk=100 kind=caret seq=1 =caret:0:1 en=0 seq=2 =mouse:0:1 en=1 =mouse:0:1 seq=3 =caret:0:3 +# tracking off: nothing takes the view, and its rising edge is a baseline, not an event +owner all sk=100 kind=caret seq=1 =caret:0:1 act=0 seq=2 =caret:0:1 act=1 seq=3 =caret:0:1 seq=4 =caret:0:4 +# the latched target keeps the report that passed the gates +owner all sk=100 kind=focus seq=1 =focus:0:1 kind=caret seq=2 =caret:0:2 sk=3000 seq=3 =caret:0:2 diff --git a/tests/fixtures/shared/zoom_controller_cases.txt b/tests/fixtures/shared/zoom_controller_cases.txt new file mode 100644 index 00000000..9cbd405f --- /dev/null +++ b/tests/fixtures/shared/zoom_controller_cases.txt @@ -0,0 +1,106 @@ +# Zoom controller cases shared by Wind (Windows) and Wind for Mac. Read by tests/test_shared_fixtures.cpp +# (src/zoom/zoom_controller.*) and by mac/Tests/WindCoreChecks/SharedFixtures.swift +# (mac/Sources/WindCore/ZoomController.swift). +# +# One case per line: +# platform: all (both run it), win (Windows only), mac (Mac only). Rows that differ on purpose are +# tagged per platform instead of forced to agree. +# Expected values are the tokens that start with '='. Numbers match within 1e-6 (absolute); words +# match exactly. +# all and win rows come from the C++ code (Windows is the origin): after an intended change there, +# set WIND_UPDATE_FIXTURES=1 && build.bat test +# rewrites them, then the Mac must match. mac rows are written by hand from the Swift code. +# +# dir inHeld outHeld =none|in|out (ResolveDirection) +# notch speed =step (WheelNotchStep: x(1+step) per notch) +# quick cur stored def maxLevel =newLevel =newStored (ApplyQuickZoom) +# zoom a step sequence on one controller, tokens run left to right: +# min=L max=L the controller's bounds (default 1 and 30; read before anything runs) +# profile=in,out,smooth,accel,ramp setProfile (smooth 0/1) +# ease=s setEaseOut (release glide time constant, seconds; 0 = dead stop) +# in | out | none the held direction +# t=HZ*N N ticks of 1/HZ seconds +# wheel=N wheelNotches(N), whole notches +# step=N,S stepTarget(N, S) +# set=L setLevel(L) +# reset reset() +# =L the level now + +# direction: both held freezes +dir all 0 0 =none +dir all 1 0 =in +dir all 0 1 =out +dir all 1 1 =none + +# notch step: one notch zooms as far as 0.1 s of holding at the same speed +notch all 0.25 =0.04427378243 +notch all 1 =0.189207115 +notch all 2.7 =0.5965967727 +notch all 4 =1 + +# quick zoom: out remembers a level above 2x; in snaps to the remembered level or the default +quick all 1 0 4 30 =4 =0 +quick all 6 4 4 30 =1 =6 +quick all 1.5 4 4 30 =1 =4 +quick all 2 4 4 30 =1 =4 +quick all 2.0001 4 4 30 =1 =2.0001 +quick all 1 40 4 30 =30 =40 +quick all 1 0 50 30 =30 =0 +quick all 1 0.5 4 30 =1 =0.5 +quick all 1.0000001 7 4 30 =7 =7 +quick all 30 0 4 30 =1 =30 + +# hold: the linear rate (2.5 doublings/s at speed 1), clamps, both directions +zoom all ease=0 in t=120*120 =5.656854249 +zoom all ease=0 in t=120*60 =2.37841423 t=120*60 =5.656854249 +zoom all ease=0 profile=2,1,0,3,0.6 in t=120*30 =2.37841423 +zoom all ease=0 set=8 out t=120*30 =5.187358219 +zoom all ease=0 profile=1,0.5,0,3,0.6 set=8 out t=120*60 =5.187358219 +zoom all max=4 in t=120*600 =4 none t=120*1 =4 +zoom all set=8 out t=120*600 =1 none t=120*1 =1 +zoom all min=2 max=10 =2 in t=120*30 =2.873862896 out t=120*600 =2 +zoom all ease=0 in t=60*30 =2.37841423 t=144*72 =5.656854249 t=240*120 =13.45434264 +# release ease-out (default 45 ms), reversal, a long tau and a dead stop +zoom all in t=120*60 =2.215445564 none t=120*1 =2.242189302 t=120*120 =2.3749613 +zoom all in t=120*60 =2.215445564 none t=120*12 =2.360189627 t=120*120 =2.3749613 +zoom all ease=0.2 in t=120*30 =1.21052513 none t=120*24 =1.410762443 t=120*240 =1.531431228 +zoom all ease=0 in t=120*60 =2.37841423 none t=120*1 =2.37841423 +zoom all in t=120*30 out t=120*10 =1.40151715 none t=120*60 =1.336770004 +zoom all max=8 in t=120*600 =8 none t=120*1 =8 t=120*60 =8 +# smooth zoom-in ease-in (accel ramp), and what turns it off +zoom all ease=0 profile=1,1,1,3,0.6 in t=120*12 =1.07057037 t=120*36 =1.474458468 t=120*72 =4.019300503 t=120*120 =22.73659713 +zoom all ease=0 profile=2,0.5,1,4,0.3 in t=120*60 =3.872123587 out t=120*60 =2.510761514 +zoom all ease=0 profile=1,1,1,1,0.6 in t=120*60 =2.37841423 +zoom all ease=0 profile=1,1,1,0.5,0.6 in t=120*60 =2.37841423 +zoom all ease=0 profile=1,1,1,3,0 in t=120*60 =2.37841423 +zoom all ease=0 profile=1,1,1,3,0.6 in t=120*36 none t=120*12 in t=120*12 =1.391703473 +zoom all profile=1,1,1,3,0.6 in t=120*36 =1.243926053 none t=120*24 =1.297922618 +# reset and set +zoom all in t=120*60 reset =1 t=120*5 =1 +zoom all set=40 =30 set=0.5 =1 set=6.5 =6.5 +# wheel: where a glide ends (the glide itself differs, see below), stacking, clamps, speeds +zoom all wheel=1 t=120*240 =1.189207115 +zoom all wheel=3 t=120*240 =1.681792831 +zoom all wheel=2 wheel=2 t=120*240 =2 +zoom all wheel=2 t=120*240 wheel=-1 t=120*240 =1.189207115 +zoom all max=4 wheel=20 t=120*480 =4 +zoom all set=2 wheel=-10 t=120*480 =1 +zoom all step=3,0.5 t=120*480 =3.375 +zoom all step=0,0.5 step=2,0 step=-1,-1 =1 t=120*10 =1 +zoom all profile=1,2,0,3,0.6 set=8 wheel=-1 t=120*480 =5.656854249 +zoom all profile=2.7,1,0,3,0.6 wheel=1 t=120*480 =1.596596773 +zoom all wheel=5 in t=120*60 =2.215445564 +zoom all ease=0 wheel=5 in t=120*60 =2.37841423 + +# Differs on purpose, so per platform: +# The wheel glide: Windows is a first-order glide in log space (tau 80 ms); the Mac moved to a +# critically damped spring (250 ms to 95%, an ease-in from rest; owner ask 2026-10-31). Same target, +# different path: 50 ms into a 1x -> 1.189x notch Windows is at 1.0839 and the Mac at 1.0432. +zoom win wheel=1 t=120*6 =1.083864676 t=120*6 =1.131607623 t=120*12 =1.172411277 +zoom win wheel=3 t=120*12 =1.449064088 t=120*24 =1.661356506 +zoom mac wheel=1 t=120*6 =1.0432116 t=120*6 =1.102663116 t=120*12 =1.167030389 +zoom mac wheel=3 t=120*12 =1.340690536 t=120*24 =1.66195293 +# setLevel during the release ease-out: Windows keeps the decaying rate (the level drifts on), the +# Mac's setLevel stops it. +zoom win in t=120*60 none set=2 t=120*5 =2.087584575 +zoom mac in t=120*60 none set=2 t=120*5 =2 diff --git a/tests/test_shared_fixtures.cpp b/tests/test_shared_fixtures.cpp new file mode 100644 index 00000000..2743d934 --- /dev/null +++ b/tests/test_shared_fixtures.cpp @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Max Aubert. All rights reserved. +// Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary +// The cases Wind shares with Wind for Mac (tests/fixtures/shared/, also read by +// mac/Tests/WindCoreChecks/SharedFixtures.swift): view geometry, the zoom controller and the view +// owner, whose Swift code is a port of this. Rows tagged all or win run here; mac rows are the Mac's. +// The file headers give the formats. +// +// Windows is the origin, so the all and win rows' expected values come from this code. After an +// intended change, regenerate them and let the Mac follow: +// set WIND_UPDATE_FIXTURES=1 && build.bat test +#include "doctest.h" +#include "edge_pan.h" +#include "keyboard_pan.h" +#include "transform.h" +#include "view_glide.h" +#include "view_target.h" +#include "zoom_controller.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace wind; + +namespace { +using Tokens = std::vector; +constexpr double kTolerance = 1e-6; // documented in the fixture headers + +bool UpdateRequested() { + char* v = nullptr; size_t n = 0; + const bool on = _dupenv_s(&v, &n, "WIND_UPDATE_FIXTURES") == 0 && v && v[0] == '1'; + std::free(v); + return on; +} + +// The cwd varies (repo root from build.bat, a build dir elsewhere): try this file's folder, then +// walk up from the cwd. Returns the path that opened, or "". +std::string FindFixture(const std::string& name) { + std::string here = __FILE__; + const size_t cut = here.find_last_of("/\\"); + std::vector tries; + if (cut != std::string::npos) tries.push_back(here.substr(0, cut) + "/fixtures/shared/" + name); + for (const char* up : { "", "../", "../../", "../../../" }) + tries.push_back(std::string(up) + "tests/fixtures/shared/" + name); + for (const std::string& p : tries) { std::ifstream f(p); if (f.good()) return p; } + return ""; +} + +std::string Num(double v) { char b[64]; std::snprintf(b, sizeof b, "%.10g", v); return b; } +double D(const std::string& s) { + size_t used = 0; + const double v = std::stod(s, &used); + if (used != s.size()) throw std::invalid_argument("not a number: " + s); + return v; +} +int I(const std::string& s) { + const double v = D(s); + if (v != std::floor(v)) throw std::invalid_argument("not a whole number: " + s); + return (int)v; +} +Tokens Split(const std::string& s, char sep) { + Tokens out; std::string cur; + std::istringstream in(s); + while (std::getline(in, cur, sep)) out.push_back(cur); + return out; +} +// "key=value" -> key, value ("in" -> "in", ""). +void KeyValue(const std::string& t, std::string& k, std::string& v) { + const size_t eq = t.find('='); + k = eq == std::string::npos ? t : t.substr(0, eq); + v = eq == std::string::npos ? "" : t.substr(eq + 1); +} + +// One call of a plain function: every input comes before the expected values. +Tokens EvalPlain(const std::string& fn, const Tokens& a) { + auto need = [&](size_t n) { if (a.size() != n) throw std::invalid_argument(fn + ": wrong input count"); }; + auto d = [&](size_t i) { return D(a[i]); }; + auto n = [&](size_t i) { return I(a[i]); }; + if (fn == "clamp") { // the clamp both TrackTargetCenter and EdgePanCenter end in, as a centre + need(5); + const double lv = d(2) < 1.0 ? 1.0 : d(2); + const OffsetF o = ComputeOffsetF(d(0), d(1), lv, n(3), n(4)); + return { Num(o.x + n(3) / lv / 2), Num(o.y + n(4) / lv / 2) }; + } + if (fn == "edge") { + need(12); + double cx = 0, cy = 0; + CursorBody body; body.l = d(8); body.t = d(9); body.r = d(10); body.b = d(11); + EdgePanCenter(d(0), d(1), d(2), d(3), d(4), n(5), n(6), n(7), -1, -1, cx, cy, body); + return { Num(cx), Num(cy) }; + } + if (fn == "track") { + need(11); + double cx = 0, cy = 0; + const TrackRect rc{ d(0), d(1), d(2), d(3) }; + if (!TrackTargetCenter(rc, d(4), d(5), d(6), n(7), n(8), n(9), n(10), cx, cy)) return { "none" }; + return { Num(cx), Num(cy) }; + } + if (fn == "keypan") { // KeyPan::step's continuous rate, in desktop px per second + need(3); + const double lv = d(0) < 1.0 ? 1.0 : d(0); + return { Num(d(2) * KeyPan::kScreensPerSec * KeyPan::ZoomRateScale(lv) * n(1) / lv) }; + } + if (fn == "dir") { + need(2); + const ZoomDir z = ResolveDirection(n(0) != 0, n(1) != 0); + return { z == ZoomDir::In ? "in" : (z == ZoomDir::Out ? "out" : "none") }; + } + if (fn == "notch") { need(1); return { Num(WheelNotchStep(d(0))) }; } + if (fn == "quick") { + need(4); + const QuickZoomResult r = ApplyQuickZoom(d(0), d(1), d(2), d(3)); + return { Num(r.newLevel), Num(r.newStored) }; + } + throw std::invalid_argument("unknown function " + fn); +} + +// A zoom step sequence: one output per '=' token, in order. +Tokens EvalZoom(const Tokens& toks) { + double minL = 1.0, maxL = 30.0; + std::string k, v; + for (const std::string& t : toks) { + KeyValue(t, k, v); + if (k == "min") minL = D(v); + if (k == "max") maxL = D(v); + } + ZoomController z(minL, maxL); + Tokens out; + for (const std::string& t : toks) { + if (t[0] == '=') { out.push_back(Num(z.level())); continue; } + KeyValue(t, k, v); + if (k == "min" || k == "max") continue; + if (k == "profile") { + const Tokens p = Split(v, ','); + if (p.size() != 5) throw std::invalid_argument("profile needs 5 values"); + z.setProfile(D(p[0]), D(p[1]), I(p[2]) != 0, D(p[3]), D(p[4])); + } else if (k == "ease") z.setEaseOut(D(v)); + else if (k == "in") z.setDirection(ZoomDir::In); + else if (k == "out") z.setDirection(ZoomDir::Out); + else if (k == "none") z.setDirection(ZoomDir::None); + else if (k == "t") { + const Tokens p = Split(v, '*'); + if (p.size() != 2) throw std::invalid_argument("t needs HZ*N"); + const double dt = 1.0 / D(p[0]); + for (int i = 0, n = I(p[1]); i < n; ++i) z.tick(dt); + } else if (k == "wheel") z.wheelNotches(I(v)); + else if (k == "step") { + const Tokens p = Split(v, ','); + if (p.size() != 2) throw std::invalid_argument("step needs N,S"); + z.stepTarget(I(p[0]), D(p[1])); + } else if (k == "set") z.setLevel(D(v)); + else if (k == "reset") z.reset(); + else throw std::invalid_argument("unknown zoom token " + t); + } + return out; +} + +const char* OwnerName(ViewOwner o) { + switch (o) { + case ViewOwner::Mouse: return "mouse"; + case ViewOwner::Caret: return "caret"; + case ViewOwner::Focus: return "focus"; + case ViewOwner::Keys: return "keys"; + } + return "?"; +} + +// A view owner step sequence: each '=' token runs one StepViewOwner. +Tokens EvalOwner(const Tokens& toks) { + ViewOwnerState s; + ViewOwnerInputs in; in.enabled = true; in.trackCaret = true; in.trackFocus = true; in.dtMs = 8; + Tokens out; + std::string k, v; + for (const std::string& t : toks) { + if (t[0] == '=') { + StepViewOwner(s, in); + out.push_back(std::string(OwnerName(s.owner)) + ":" + (s.warpPointer ? "1" : "0") + ":" + + std::to_string(s.target.seq)); + in.mouseDx = 0; in.mouseDy = 0; in.buttonDown = false; // these last one step + continue; + } + if (t == "reset") { ResetViewOwnerForSession(s); continue; } + KeyValue(t, k, v); + if (k == "en") in.enabled = I(v) != 0; + else if (k == "tc") in.trackCaret = I(v) != 0; + else if (k == "tf") in.trackFocus = I(v) != 0; + else if (k == "dx") in.mouseDx = D(v); + else if (k == "dy") in.mouseDy = D(v); + else if (k == "btn") in.buttonDown = I(v) != 0; + else if (k == "sb") in.msSinceButton = D(v); + else if (k == "sk") in.msSinceKey = D(v); + else if (k == "kab") in.keyAfterButton = I(v) != 0; + else if (k == "dt") in.dtMs = D(v); + else if (k == "seq") in.snap.seq = (unsigned)I(v); + else if (k == "pan") in.panning = I(v) != 0; + else if (k == "act") in.trackActive = I(v) != 0; + else if (k == "kind") { + if (v == "none") in.snap.kind = TrackKind::None; + else if (v == "caret") in.snap.kind = TrackKind::Caret; + else if (v == "focus") in.snap.kind = TrackKind::Focus; + else throw std::invalid_argument("unknown kind " + v); + } else throw std::invalid_argument("unknown owner token " + t); + } + return out; +} + +bool Same(const std::string& want, const std::string& got) { + try { return std::fabs(D(want) - D(got)) <= kTolerance; } + catch (const std::exception&) { return want == got; } +} + +// Runs (or, with WIND_UPDATE_FIXTURES=1, rewrites) every all/win row of one shared file. Returns +// the number of rows run here. +int RunSharedFile(const std::string& name) { + const std::string path = FindFixture(name); + INFO("fixture " << name); + REQUIRE(!path.empty()); + std::ifstream f(path); + const bool update = UpdateRequested(); + std::string line, rewritten; + int cases = 0; + while (std::getline(f, line)) { + if (!line.empty() && line.back() == 0x0D) line.pop_back(); // CRLF checkouts + std::istringstream in(line); + Tokens toks; std::string t; + while (in >> t) toks.push_back(t); + if (toks.empty() || toks[0][0] == 0x23) { rewritten += line + "\n"; continue; } + INFO("case: " << line); + REQUIRE(toks.size() >= 2); + const std::string fn = toks[0], tag = toks[1]; + REQUIRE((tag == "all" || tag == "win" || tag == "mac")); + if (tag == "mac") { rewritten += line + "\n"; continue; } + const Tokens body(toks.begin() + 2, toks.end()); + Tokens want, inputs; + for (const std::string& b : body) { + if (b[0] == '=') want.push_back(b.substr(1)); + else if (want.empty()) inputs.push_back(b); + } + const bool sequence = fn == "zoom" || fn == "owner"; + Tokens got; + try { + got = fn == "zoom" ? EvalZoom(body) : (fn == "owner" ? EvalOwner(body) : EvalPlain(fn, inputs)); + } catch (const std::exception& e) { + FAIL_CHECK("cannot run: " << e.what()); + rewritten += line + "\n"; + continue; + } + ++cases; + if (update) { + // Sequences keep each expectation in its place; plain rows put them after the inputs. + std::string row = fn + " " + tag; + size_t g = 0; + for (const std::string& b : sequence ? body : inputs) + row += " " + (b[0] == '=' ? "=" + got[g++] : b); + for (; !sequence && g < got.size(); ++g) row += " =" + got[g]; + rewritten += row + "\n"; + continue; + } + bool ok = got.size() == want.size(); + for (size_t i = 0; ok && i < got.size(); ++i) ok = Same(want[i], got[i]); + std::string gotText; + for (const std::string& g : got) gotText += " =" + g; + CHECK_MESSAGE(ok, "got" << gotText); + } + if (update) { + f.close(); + std::ofstream w(path, std::ios::binary | std::ios::trunc); + w << rewritten; + MESSAGE("rewrote " << path); + } + return cases; +} +} // namespace + +TEST_CASE("view geometry matches the cases shared with the Mac") { + CHECK(RunSharedFile("view_math_cases.txt") >= 50); +} +TEST_CASE("the zoom controller matches the cases shared with the Mac") { + CHECK(RunSharedFile("zoom_controller_cases.txt") >= 50); +} +TEST_CASE("the view owner matches the cases shared with the Mac") { + CHECK(RunSharedFile("view_owner_cases.txt") >= 14); +} From abfa925d070f82001fe65549969fc0bcf315c716 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:59:10 +0100 Subject: [PATCH 2/3] test: wheel glide rows are shared now that Windows uses the spring (#548) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kg7JN6HgdB9FaHJsgLxovP --- tests/fixtures/shared/zoom_controller_cases.txt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/fixtures/shared/zoom_controller_cases.txt b/tests/fixtures/shared/zoom_controller_cases.txt index 9cbd405f..d51f69a6 100644 --- a/tests/fixtures/shared/zoom_controller_cases.txt +++ b/tests/fixtures/shared/zoom_controller_cases.txt @@ -92,14 +92,11 @@ zoom all profile=2.7,1,0,3,0.6 wheel=1 t=120*480 =1.596596773 zoom all wheel=5 in t=120*60 =2.215445564 zoom all ease=0 wheel=5 in t=120*60 =2.37841423 +# The wheel glide: a critically damped spring in log space on both (250 ms to 95%, an ease-in from +# rest; Mac 2026-10-31, Windows #548). 50 ms into a 1x -> 1.189x notch both are at 1.0432. +zoom all wheel=1 t=120*6 =1.0432116 t=120*6 =1.102663116 t=120*12 =1.167030389 +zoom all wheel=3 t=120*12 =1.340690536 t=120*24 =1.66195293 # Differs on purpose, so per platform: -# The wheel glide: Windows is a first-order glide in log space (tau 80 ms); the Mac moved to a -# critically damped spring (250 ms to 95%, an ease-in from rest; owner ask 2026-10-31). Same target, -# different path: 50 ms into a 1x -> 1.189x notch Windows is at 1.0839 and the Mac at 1.0432. -zoom win wheel=1 t=120*6 =1.083864676 t=120*6 =1.131607623 t=120*12 =1.172411277 -zoom win wheel=3 t=120*12 =1.449064088 t=120*24 =1.661356506 -zoom mac wheel=1 t=120*6 =1.0432116 t=120*6 =1.102663116 t=120*12 =1.167030389 -zoom mac wheel=3 t=120*12 =1.340690536 t=120*24 =1.66195293 # setLevel during the release ease-out: Windows keeps the decaying rate (the level drifts on), the # Mac's setLevel stops it. zoom win in t=120*60 none set=2 t=120*5 =2.087584575 From 8b5ed7200caad0ae29651f51df22c64a67413131 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:52:14 +0100 Subject: [PATCH 3/3] test: copyright header on SharedFixtures.swift Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kg7JN6HgdB9FaHJsgLxovP --- mac/Tests/WindCoreChecks/SharedFixtures.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mac/Tests/WindCoreChecks/SharedFixtures.swift b/mac/Tests/WindCoreChecks/SharedFixtures.swift index ff7771fb..217676ef 100644 --- a/mac/Tests/WindCoreChecks/SharedFixtures.swift +++ b/mac/Tests/WindCoreChecks/SharedFixtures.swift @@ -1,3 +1,5 @@ +// Copyright (c) 2026 Max Aubert. All rights reserved. +// Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary import Foundation @testable import WindCore