import CoreGraphics import Foundation import Testing @testable import Kanban /// **The lane-resize release hold** (`LaneWidthHold`, `LaneResizeSession.end`): the bridge across /// the one-way flow's round trip between a release and the reload that carries its write. /// /// The bug these pin is a visible two-step. The drag grows the *window* one standard-plus-gap per /// tick and freezes the strip's standard so the siblings never move; the release then writes the new /// unit count, which takes a Writer → disk → watcher → reload round trip to reach the snapshot. A /// session that cleared at the release handed the strip back to /// `LaneLayoutMath.standardWidth(stripWidth:totalUnits:gap:)` for that whole stretch — the *grown* /// window divided by the *stale* unit total — so every lane took a proportionally wrong width and /// the board jumped a second time when the echo landed. /// /// Fixture geometry is `LaneLayoutMathTests`': a frozen standard of 100 and a gap of 12, so the slot /// widths are 1× = 100 · 2× = 212 · 3× = 324, and the tick-down thresholds are 214 (3→2) and 102 /// (2→1). Every drag here **shrinks**, because with no `NSWindow` to measure the on-screen fit is /// the count the drag started from (`LaneResizeSession.fittingMaxUnits`) and growth has no room — /// direction is nothing to the hold's state machine, which is what is under test. private let standard: CGFloat = 100 private let gap: CGFloat = 12 /// Enough leftward translation from a 3× start to land on 2×, and on 1×: 324 − 120 = 204, under the /// 214 threshold and over the 102 one; 324 − 240 rubber-bands to 96, under both. private let toTwoUnits: CGFloat = -120 private let toOneUnit: CGFloat = -240 private let lane1 = ItemID(rawValue: Ident.lane1) private let lane2 = ItemID(rawValue: Ident.lane2) private func laneText(order: String, title: String, width: String) -> String { """ --- schema: 1 title: \(title) order: \(order) width: \(width) created: 2026-01-01T09:00:00Z --- \(title) body. """ } /// One plain lane (no `width`, so 1×) and one at 3× — the one every drag below shrinks. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) return fixture } @MainActor @Suite("The lane-resize release hold") struct LaneResizeHoldTests { // MARK: - Harness /// One walk through the store's inbound door — the echo, as the watcher delivers it. private func reload(_ store: BoardStore, _ origin: WatchOrigin = .appMediated) async { store.handleWatcherEvent(.treeChanged(origin)) await store.awaitQuiescence() } /// A session mid-drag on lane two, `translation` points to the left of its 3× start. private func dragging(_ translation: CGFloat) -> LaneResizeSession { let session = LaneResizeSession() session.begin(laneID: lane2, units: 3, standard: standard, gap: gap, window: nil) session.update(translation: translation) return session } /// A session that has released onto `store` and is holding the width it wrote. private func settled(on store: BoardStore, translation: CGFloat = toTwoUnits) -> LaneResizeSession { let session = dragging(translation) session.end { id, units in store.setLaneWidth(id, units: units) } return session } private func lane(_ id: String, in store: BoardStore) throws -> Lane { try #require(store.snapshot.lanes.first { $0.id.rawValue == id }) } /// Polls for `condition`, because the deadline's dissolve is a `Task` on this very actor: the /// test has to yield for it to run at all. Bounded, so a dissolve that never comes fails rather /// than hangs. private func settles(_ condition: () -> Bool) async -> Bool { for _ in 0..<200 { if condition() { return true } try? await Task.sleep(for: .milliseconds(5)) } return condition() } // MARK: - Entering the hold @Test("The release holds instead of clearing — the frozen standard and the written units keep governing") func releaseEntersTheHold() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) #expect(session.isSettled) #expect(session.hold == LaneWidthHold(laneID: lane2, units: 2)) // Still governing — which is the whole fix. `isActive` is what `BoardView.standardWidth` // reads to keep the frozen standard in force, and `governs` is what the lane slot reads. #expect(session.isActive) #expect(session.governs(lane2)) #expect(session.standard == standard) // …but no longer *dragging*: no mouse is holding this. #expect(!session.isDragging) #expect(!session.isDragging(lane2)) // The live width comes to rest on the written count's slot, measured against the frozen // standard: the last sub-tick of overflow animates away and nothing else moves. #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) // The snapshot is a round trip behind, and this is precisely the window in which reading it // would draw the pre-drag layout. let stale = try lane(Ident.lane2, in: store) #expect(LaneLayoutMath.displayUnits(of: stale) == 3) #expect(session.displayUnits(of: stale) == 2, "the written count answers, not the stale one") #expect(session.displayUnits(of: try lane(Ident.lane1, in: store)) == 1, "a lane the session does not govern still reads from the snapshot") } @Test("A drag that ends where it started writes nothing and holds nothing") func anUnchangedReleaseNeverArms() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // 3× released at 3×: `setLaneWidth`'s unchanged-units guard refuses it, so there is no write, // no reload and therefore no echo — an overlay armed here would only ever time out. let session = dragging(0) session.end { id, units in store.setLaneWidth(id, units: units) } #expect(!session.isSettled) #expect(!session.isActive) #expect(session.liveWidth == 0) } @Test("A refused write dissolves the session outright — the hold must not outlive it") func aRefusedWriteNeverArms() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() let session = settled(on: store) // Nothing reached disk, so nothing is coming back: the snapshot takes the layout straight // back and the lane returns to its pre-drag width, which is the truth (and the banner row is // already saying why). #expect(!session.isSettled) #expect(!session.isActive) #expect(try lane(Ident.lane2, in: store).width == .valid(3)) } // MARK: - The hand-off @Test("Only a snapshot carrying the written width releases the hold") func theEchoReleasesIt() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) session.handOff(against: store.snapshot.lanes) #expect(session.isSettled, "a snapshot that still says the old width is not the echo") await reload(store) #expect(try lane(Ident.lane2, in: store).width == .valid(2)) #expect(store.snapshotGeneration == 1) session.handOff(against: store.snapshot.lanes) #expect(!session.isSettled) #expect(!session.isActive) #expect(session.liveWidth == 0) } @Test("A reload that does not carry the written width leaves the hold standing, whatever else moved") func anUnrelatedReloadDoesNotRelease() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) // Somebody else got there first: another lane renamed, and this lane's width put back where // it was. The walk lands and moves the generation — which is why this hold is retired by a // *value* and not by a counter, unlike the drag's `CommittedHold`. Handing the strip back on // the bump alone would divide the already-grown window by the unit total the board still // has, which is the two-step the hold exists to remove. try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Renamed")) try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3")) await reload(store, .foreign) #expect(store.snapshotGeneration == 1) #expect(try lane(Ident.lane2, in: store).width == .valid(3)) session.handOff(against: store.snapshot.lanes) #expect(session.isSettled) #expect(session.governs(lane2)) // And the deadline is what ends it, since the width it named is never coming. session.expire(try #require(session.hold)) #expect(!session.isActive) } @Test("A landed walk that assigns nothing still releases a hold the current snapshot satisfies") func aValueEqualWalkReleasesIt() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) // The echo lands and is applied, but nobody asks the hold about it yet; then a second walk // lands over the same tree. That one comes back value-equal, so it skips the assignment and // moves no `snapshotGeneration` — a hold watching for a *bump* would sit out its whole // deadline over a snapshot that already agrees with it. await reload(store) await reload(store) #expect(store.landedReloads == 2) #expect(store.snapshotGeneration == 1, "the second walk had nothing to assign") session.handOff(against: store.snapshot.lanes) #expect(!session.isSettled) #expect(!session.isActive) } @Test("A width landing on one is echoed by an ABSENT key, and that releases the hold") func theOneUnitEchoIsAnAbsentKey() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store, translation: toOneUnit) #expect(session.hold == LaneWidthHold(laneID: lane2, units: 1)) await reload(store) // The remove-at-default rule: there is no `width: 1` on disk to compare against, so the // condition can only ever be the *display* reading of an absent key. #expect(try lane(Ident.lane2, in: store).width.isMissing) session.handOff(against: store.snapshot.lanes) #expect(!session.isSettled) #expect(!session.isActive) } @Test("A lane that vanished under the gesture retires the hold — the snapshot is the authority") func aVanishedLaneRetiresIt() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) session.handOff(against: store.snapshot.lanes.filter { $0.id != lane2 }) #expect(!session.isSettled) #expect(!session.isActive) } /// The condition as a value, away from the session — both traps in one place. @Test("The hold's condition reads the width exactly as the board does") func theConditionIsTheDisplayReading() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let atThree = try lane(Ident.lane2, in: store) let keyless = try lane(Ident.lane1, in: store) #expect(!LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [atThree])) #expect(LaneWidthHold(laneID: lane2, units: 3).isRetired(by: [atThree])) // A lane with no `width` key at all displays one unit, which is what a 1× write leaves // behind — and is therefore the echo of one. #expect(LaneWidthHold(laneID: lane1, units: 1).isRetired(by: [keyless])) #expect(!LaneWidthHold(laneID: lane1, units: 2).isRetired(by: [keyless])) #expect(LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [])) } // MARK: - The deadline @Test("A hold with no echo coming times out, and the snapshot takes the layout back") func theDeadlineDissolvesIt() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = dragging(toTwoUnits) // The seam: the real figure is `LaneWidthHold.timeout`, and waiting it out would be 1.5 s of // wall clock in the suite for a claim about the dissolve rather than about the clock. session.holdTimeout = .milliseconds(20) session.end { id, units in store.setLaneWidth(id, units: units) } #expect(session.isSettled) #expect(await settles { !session.isSettled }, "a hold with no hand-off coming must dissolve") #expect(!session.isActive) #expect(session.liveWidth == 0) } @Test("The dissolve ends the hold it was armed for, and no other") func theDissolveEndsOnlyItsOwnHold() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) let hold = try #require(session.hold) session.expire(LaneWidthHold(laneID: lane1, units: 9)) #expect(session.isSettled, "a hold this session is not holding is not this session's to end") session.expire(hold) #expect(!session.isSettled) #expect(!session.isActive) } @Test("A second drag supersedes a standing hold, and the retired deadline never reaches it") func aSecondDragSupersedesTheHold() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = dragging(toTwoUnits) session.holdTimeout = .milliseconds(20) session.end { id, units in store.setLaneWidth(id, units: units) } // The user grabs the same edge again before the echo. The anchor is what is on screen — the // width the release wrote — not the snapshot's, which is still a round trip behind. let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store)) #expect(onScreen == 2) session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap, window: nil) #expect(!session.isSettled) #expect(session.isDragging(lane2)) #expect(session.units == 2) try? await Task.sleep(for: .milliseconds(80)) #expect(session.isDragging(lane2), "the retired hold's deadline must not end the drag that followed it") } @Test("A hold ignores drag updates — no mouse is driving it") func theHoldTakesNoTranslations() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = settled(on: store) session.update(translation: toOneUnit) #expect(session.units == 2) #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) } }