import AppKit import Foundation import Testing @testable import Kanban /// The drag session's **value** halves — the pasteboard payload, the locality model, and the /// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop). /// /// The session object itself, the drop delegates and the gestures are not unit-testable — they are /// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls /// where it does. // MARK: - The payload @Suite("DragPayload") struct DragPayloadTests { private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload { DragPayload( boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true), kind: kind, container: container, items: [ DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"), DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil) ] ) } @Test("A payload round-trips through its JSON representation unchanged") func roundTrip() throws { for kind in [DragKind.cards, .lanes] { for container in [ItemContainer.board, .trash] { let original = Self.payload(kind: kind, container: container) let data = try #require(original.encoded()) #expect(DragPayload(data: data) == original) } } } @Test("Garbage decodes to nothing rather than to an empty drag") func garbageDecodesToNil() { #expect(DragPayload(data: Data("not json".utf8)) == nil) #expect(DragPayload(data: Data()) == nil) } @Test("The ids, folders and root are read back off the strings, in flatten order") func derivedValues() { let payload = Self.payload() #expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")]) #expect(payload.folders.map(\.path) == [ "/Boards/Work.kanban/lane/aaa", "/Boards/Work.kanban/lane/bbb" ]) #expect(payload.rootURL.path == "/Boards/Work.kanban") } @Test("The plain-text representation is the dragged titles, one per line") func plainText() { // The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders // it — "Untitled" is a rendering, never a value (03-board-ui.md § Card face). #expect(Self.payload().plainText == "First\nUntitled") } @Test("The container survives the round trip, because it is what makes a trash drag a trash drag") func containerSurvives() throws { let data = try #require(Self.payload(container: .trash).encoded()) #expect(DragPayload(data: data)?.container == .trash) } } // MARK: - Board identity /// **The one canonical spelling every locality comparison is made against** (`BoardRootKey`). /// /// Minting is where the filesystem is touched — once per board, at `BoardStore.rootKey` — and these /// are the claims that one touch has to buy: every spelling of a place is one key, and two places /// are two keys. @Suite("BoardRootKey") struct BoardRootKeyTests { // Instance members, not `static`: every case below names them bare, and a static member is not // reachable unqualified from an instance method. Swift Testing builds a fresh instance per test, // so these are as constant either way. private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true) private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true) @Test("Roots key on their standardized path, so the same board is the same board") func standardization() { #expect(BoardRootKey(here) == BoardRootKey(here)) #expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/"))) #expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/Other/../Work.kanban"))) #expect(BoardRootKey(here) != BoardRootKey(there)) } /// Why the key resolves at all: "a board reached through a pinned symlink is the same board as /// the one reached directly" (01-storage-format.md's symlink pins). A real link over a real /// folder, because the claim is a filesystem fact and nothing else can stand in for one. @Test("A board reached through a pin is the board the pin points at") func pinsNameTheirTarget() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let real = fixture.url("Work.kanban") try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) let pin = fixture.url("Pinned.kanban") try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real) #expect(BoardRootKey(pin) == BoardRootKey(real)) // The key names the target rather than the link — the pin is a spelling, not a second board. #expect(BoardRootKey(pin).path.hasSuffix("Work.kanban")) // A sibling the link does not point at stays a board of its own. #expect(BoardRootKey(pin) != BoardRootKey(fixture.url("Other.kanban"))) } /// **The store mints once and keeps both**: the key is the board's identity, and `rootURL` keeps /// the user's spelling because the folder name is the board's display-name fallback /// (01-storage-format.md § Frontmatter) — canonicalizing there would visibly rename a board /// opened through a pin. @MainActor @Test("A board opened through a pin keeps the pin's spelling and the target's identity") func theStoreKeepsTheSpellingAndCachesTheIdentity() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let real = try fixture.item("Work.kanban", Item.board) let pin = fixture.url("Pinned.kanban") try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real) let store = try BoardStore(rootURL: pin) #expect(store.rootKey == BoardRootKey(real)) #expect(store.rootURL.lastPathComponent == "Pinned.kanban") } /// The key follows the folder. A board renamed away and a new board opened at the vacated path /// are two boards, and nothing about the drag they share may say otherwise — which is only true /// if the relocation re-mints. @MainActor @Test("An absorbed relocation re-mints the key, so the vacated path is somebody else's board") func relocationRemintsTheKey() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let original = try fixture.item("Work.kanban", Item.board) let store = try BoardStore(rootURL: original) let before = store.rootKey let moved = fixture.url("Renamed.kanban") try FileManager.default.moveItem(at: original, to: moved) store.relocate(to: moved) #expect(store.rootKey == BoardRootKey(moved)) #expect(store.rootKey != before) let successor = try BoardStore(rootURL: try fixture.item("Work.kanban", Item.board)) #expect(successor.rootKey != store.rootKey) } } // MARK: - Locality @Suite("DragLocality") struct DragLocalityTests { private let none: NSEvent.ModifierFlags = [] private let option: NSEvent.ModifierFlags = [.option] private let command: NSEvent.ModifierFlags = [.command] /// The Finder volume model: within a board a drag rearranges, between boards it transfers. @Test("Locality picks the default — within is a move, across is a copy") func theDefault() { #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move) #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy) #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy) } @Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default") func modifiersOverride() { #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy) #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move) // The no-ops. #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move) #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy) } @Test("⌘ wins over ⌥ when both are held") func commandWinsOverOption() { // Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks. #expect(DragLocality.operation( kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move) } /// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there: /// the drag stays a clean reorder and the badge never shows copy." @Test("A within-board lane drag ignores ⌥ entirely") func laneDragsNeverCopyWithinTheirBoard() { for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] { #expect( DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move, "a within-board lane drag is a reorder whatever is held" ) } // Across boards the lane obeys the ordinary grammar again. #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy) #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move) } /// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its /// own board the default is the restore — a move, no badge; across boards the default is the live /// copy that leaves the tombstone standing. ⌘ forces the true restore-move either way, and ⌥ the /// live copy either way. @Test("A trash row drags as a restore at home and as a copy-out abroad") func trashDragDefaults() { #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move) #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy) #expect(DragLocality.operation( kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move) #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy) } } // MARK: - Dropping on the trash /// **The delete gesture's gate** (04-interactions.md ▸ The trash, settled 2026-07-28: "dropping a /// live card on the shown trash deletes it"). /// /// One pure function decides it, and it is asked twice — once at hover for the shadow and once at /// release for the write — so every clause below is a claim about both. @Suite("TrashDrop") struct TrashDropTests { /// The accepted session, with one clause at a time knocked out by the cases. private func accepts( kind: DragKind? = .cards, container: ItemContainer = .board, isWithinBoard: Bool = true, operation: TransferOperation = .move, isTrashShown: Bool = true, acceptsMutations: Bool = true ) -> Bool { TrashDrop.accepts( kind: kind, container: container, isWithinBoard: isWithinBoard, operation: operation, isTrashShown: isTrashShown, acceptsMutations: acceptsMutations ) } /// "The shadow always takes the topmost position — which the sort makes honest, not arbitrary: /// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top." @Test("The landing is the topmost row, always") func theTopmostRow() { #expect(TrashDrop.landingIndex == 0) } @Test("A live, same-board, unmodified card drag is the one session the trash takes") func theOneItTakes() { #expect(accepts()) // ⌘ forces move, which is already the default here, so it changes nothing. #expect(accepts(operation: .move)) } /// "Dropping a live card — **or lane** — on the shown trash deletes it … a lane drag over the /// shown trash proposes the delete alongside its strip slots" (04-interactions.md ▸ The trash, /// lanes extended 2026-07-29, retiring "a lane drag proposes only lane slots"). @Test("A live lane drag proposes the delete too — and no session proposes nothing") func lanesAreDeliverable() { #expect(accepts(kind: .lanes)) // Every other clause binds the lane exactly as it binds the card: a trashed row dragged out // is not deletable back into the place it already is, a foreign board's lane is refused, and // a hidden column takes nothing. #expect(!accepts(kind: .lanes, container: .trash)) #expect(!accepts(kind: .lanes, isWithinBoard: false)) #expect(!accepts(kind: .lanes, isTrashShown: false)) #expect(!accepts(kind: .lanes, acceptsMutations: false)) // And no session at all is no proposal either — the column is inert between drags. #expect(!accepts(kind: nil)) } /// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it /// never proposes. @Test("A trash card dropped back on the trash is refused") func theTrashContainerIsRefused() { #expect(!accepts(container: .trash)) #expect(!accepts(container: .trash, isWithinBoard: false)) } /// "No move or paste ever targets the trash": a foreign card delivered into this board's trash /// would be a transfer-and-delete compound, which the design names nowhere. @Test("A foreign board's card is refused") func crossBoardIsRefused() { #expect(!accepts(isWithinBoard: false)) // Not even with ⌘, which forces the move a cross-board drag would otherwise only copy. #expect(!accepts(isWithinBoard: false, operation: .move)) } /// Copying into the trash is not a thing — and the alternative would be tombstoning an original /// the copy grammar had just promised to leave exactly where it was. @Test("⌥ is refused rather than reinterpreted") func optionCopyIsRefused() { #expect(!accepts(operation: .copy)) } /// "The trash stays undroppable-into while hidden, like every gesture." @Test("Hidden, the trash is invisible to the gesture") func hiddenIsInert() { #expect(!accepts(isTrashShown: false)) } @Test("The mutating-gesture rule applies, like every other write the pointer can start") func theLockAndTheEditorRefuse() { #expect(!accepts(acceptsMutations: false)) } } // MARK: - The mixed-kind drag out of the trash /// **"A mixed-kind drag never leaves the trash"** (04-interactions.md ▸ The trash, ruled 2026-07-31 /// with kind-blind trash selection): "pickup is allowed — the selection is legal — but every /// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining /// the rule … the refused drag ends like any refusal, rows staying put". /// /// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not /// unit-testable — the same split every other drop suite makes. What is testable is the whole of /// what the refusal is *made* of: the flag a pickup records, and the notice the release posts. @MainActor @Suite("The mixed-kind drag out of the trash") struct MixedTrashDragTests { private static let card1 = ItemID(rawValue: Ident.card1) private static let lane1 = ItemID(rawValue: Ident.lane1) /// One lane and one trashed card — enough for a store to exist and a session to name folders. 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(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed")) return fixture } /// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a /// per-kind payload — so nothing falls silently out of the drag. @Test("A pickup records whether its selection spanned both kinds") func theFlagTravels() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = DragSession() let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true) session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store) #expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag") session.beginCards( [Self.card1], folders: [folder], heights: [44], container: .trash, source: store, mixesKinds: true ) #expect(session.mixesKinds) #expect(session.container == .trash) // The lane level records it the same way, and a trashed lane row's session is in `.trash` — // which is what routes its release to the restore rather than to a strip permutation. session.beginLanes( [Self.lane1], folders: [folder], units: [1], container: .trash, source: store, mixesKinds: true ) #expect(session.mixesKinds) #expect(session.container == .trash) // And an ordinary strip drag is unaffected: board container, no flag. session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store) #expect(!session.mixesKinds) #expect(session.container == .board) } /// The notice is 04's own sentence, and it is a **loss row** — nothing failed and no write was /// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register). @Test("The release's notice is the rule, in the design's own words") func theNoticeExplainsTheRule() { let banners = BannerCenter() banners.postMixedTrashDrag() #expect(banners.losses.map(\.message) == ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"]) #expect(banners.oneShots.isEmpty, "no write failed — this is not an error row") } } // MARK: - The committed-overlay hold @Suite("CommittedHold") struct CommittedHoldTests { private static let here = BoardRootKey(URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)) private static let there = BoardRootKey(URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)) private static let hold = CommittedHold(boardRoot: here, generation: 7) @Test("The hold stands until the destination board applies a *newer* snapshot") func retiredByTheNextSnapshot() { // The generation at the commit is the one already on screen — it is the pre-drop arrangement, // and retiring on it would drop the overlay before the write has round-tripped. #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7)) #expect(Self.hold.isRetired(byRoot: Self.here, generation: 8)) // *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first // re-grounds everything anyway. #expect(Self.hold.isRetired(byRoot: Self.here, generation: 99)) } @Test("A reload on another board says nothing about this one") func otherBoardsDoNotRetireIt() { #expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99)) } @Test("The board is matched by identity, not by string") func rootMatchingUsesTheLocalityComparison() { let sameBoard = BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/")) #expect(Self.hold.isRetired(byRoot: sameBoard, generation: 8)) } @Test("A stale generation never retires it") func staleGenerations() { #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0)) #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6)) } /// The figure 03-board-ui.md fixes for a hold with no echo coming: long enough for a write plus a /// watcher round trip, short enough that a refused write does not leave the board drawing an /// arrangement it never got. @Test("The deadline is the design's own figure") func theTimeoutFigure() { #expect(CommittedHold.timeout == .milliseconds(1500)) } } // MARK: - The committed-overlay hold, in the session /// **What the session renders between the release and the echo** (DRAG-REORDER.md § The /// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session /// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals /// lifted out — until the destination store applies its next snapshot, or the deadline says none is /// coming. The claims here are the pure state the board's surfaces read (`laneProposal`, /// `trashProposal`, `hiddenMembers`), so the whole ruling is checkable without a view. /// /// A **real store over a real temp board**, like the write suites: `commit` names the destination /// store, and the session's own re-grounding reads that store's transient state, so a stub would be /// standing in for exactly the thing under test. Nothing here writes. @MainActor @Suite("The committed hold") struct DropSettleTests { private static let lane1 = ItemID(rawValue: Ident.lane1) private static let card1 = ItemID(rawValue: Ident.card1) private static let card2 = ItemID(rawValue: Ident.card2) /// Two cards in the first lane — enough for a run of two, and for one of them to vanish /// mid-flight while the other still lands. 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.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) return fixture } /// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`. /// /// The titles ride along only so the fixtures read as the cards they name — nothing in the /// session reads them. private func proposing( _ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")], at index: Int = 2 ) -> DragSession { let session = DragSession() pickUp(session, from: store, members: members) session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index)) return session } /// The pickup half, on a session that may already have had a life — the second drag in /// `aRetiredHoldsTimeoutIsCancelled` is the whole reason it is separable. private func pickUp( _ session: DragSession, from store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")] ) { session.beginCards( members.map(\.id), folders: members.map { store.rootURL .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent($0.id.rawValue, isDirectory: true) }, heights: members.map { _ in 44 }, container: .board, source: store ) } /// Polls for `condition`, because the timeout's discard is a `Task` on this very actor: the test /// has to yield for it to run at all. Bounded, so a discard 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: In flight @Test("While the drag is in flight the shadow run opens at the proposal and the originals are lifted out") func inFlightDrawsShadows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) #expect(!session.isSettled) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } // MARK: The hold /// "The session flips from proposing to committed and keeps rendering the arrangement it was /// showing": the write is on its way but the snapshot has not moved, so nothing about the /// release may change what is on screen — the shadows stay at their landing slot and the /// originals stay lifted out until the echo reload brings the real faces. @Test("A committed hold keeps the shadows at their landing slot and the originals lifted out") func theHoldKeepsTheArrangement() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.commit(into: store) #expect(session.isSettled) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } @Test("A settled release is past retargeting: a late callback cannot move or withdraw it") func settledProposalsAreFinal() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.commit(into: store) session.propose(nil) session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2) } // MARK: The trash's landing /// A session proposing into the trash column rather than into a lane — the delete gesture /// (04-interactions.md ▸ The trash). private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession { let session = DragSession() pickUp(session, from: store, members: members) session.propose(DropTarget( boardRoot: store.rootKey, container: .trash, index: TrashDrop.landingIndex )) return session } @Test("The trash's shadow opens at the topmost row, and no lane draws one") func trashInFlightDrawsTheTopRow() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposingIntoTheTrash(store) #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0) // The proposal names one container and one only: the lane the cards came out of draws // nothing, and neither does the strip. #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) #expect(session.stripProposal(onBoardRooted: store.rootKey) == nil) // And they are lifted out of the lane, as ever. #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } /// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep /// the space they landed in until the echo brings the real tombstones. @Test("A committed trash drop keeps its shadow rows on top and the originals lifted") func trashHoldKeepsTheRows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")]) session.commit(into: store) #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0) #expect(session.shadowCount == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2]) } /// The column draws the delete gesture's shadow for a **lane** session too (lanes extended /// 2026-07-29): the accessor asks "is this proposal mine", and the kind question belongs to /// `TrashDrop.accepts`, which is asked at hover and again at release. @Test("A lane session's trash proposal reaches the column, and only on its own board") func laneSessionsProposeIntoTheTrash() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = DragSession() session.beginLanes( [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)], units: [1], source: store ) session.propose(DropTarget(boardRoot: store.rootKey, container: .trash, index: 0)) #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0) #expect(session.shadowCount == 1) // Another board's column draws nothing, like every other proposal accessor. #expect(session.trashProposal(onBoardRooted: BoardRootKey(URL(filePath: "/tmp/other-board"))) == nil) } // MARK: The hand-off @Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again") func handOffClearsEverything() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.commit(into: store) session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1) #expect(!session.isSettled) #expect(!session.isActive) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } @Test("Ending the session outright ends the hold with it") func endClearsTheHold() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.commit(into: store) session.end() #expect(!session.isSettled) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) } // MARK: The timeout — the failed write's path /// "A failed write discards the proposal and the board animates back to snapshot order" /// (03-board-ui.md § Motion). A write refused outright produces no reload at all, so the deadline /// is the only thing standing between the board and an arrangement it never got. @Test("A hold with no echo coming times out, and the board is left with its snapshot order") func theTimeoutDiscardsTheHold() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) // The seam: the real figure is `CommittedHold.timeout`, and waiting it out would be 1.5 s of // wall clock in the suite for a claim about the discard rather than about the clock. session.holdTimeout = .milliseconds(20) session.commit(into: store) #expect(session.isSettled) #expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming") #expect(!session.isActive) #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) } /// The discard's own body, called directly — the half of the timeout that is a decision rather /// than a wait, and the guard that makes the wait harmless. @Test("The discard ends the hold it was armed for, and no other") func theDiscardEndsOnlyItsOwnHold() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.commit(into: store) let hold = try #require(session.hold) session.expire(CommittedHold(boardRoot: store.rootKey, generation: 999)) #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 retired hold's deadline never reaches the next drag") func aRetiredHoldsTimeoutIsCancelled() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.holdTimeout = .milliseconds(20) session.commit(into: store) // The echo lands well inside the deadline, and the user starts another drag immediately — // the lifecycle trap the watchdog was written for, at the hold's end of the session. session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1) pickUp(session, from: store, members: [(Self.card2, "Second")]) try? await Task.sleep(for: .milliseconds(80)) #expect(session.isActive, "the retired hold's deadline must not end the drag that followed it") #expect(session.hold == nil) } } // MARK: - What the source board's resting layout holds /// **The resting layout follows the effective operation** (DRAG-REORDER.md § Resting-layout zones, /// ruled 2026-08-01; 04-interactions.md ▸ Drag and drop: "originals stay" for both copies): a move /// lifts the dragged run out of the source board, a copy leaves it standing there, because that is /// what the release will actually leave behind. The whole rule is `hiddenMembers(onBoardRooted:)`, /// and every surface that builds a resting layout — the lane masonries, the strip, the drop zones — /// reads it, so pinning it here pins all of them. /// /// The modifiers are passed to `resolveOperation` rather than held down: it is the same seam /// `DragLocalityTests` uses one level down, and it is what makes the flip checkable without a /// keyboard. @MainActor @Suite("The source board's resting layout") struct DragRestingLayoutTests { private static let lane1 = ItemID(rawValue: Ident.lane1) private static let card1 = ItemID(rawValue: Ident.card1) private static let card2 = ItemID(rawValue: Ident.card2) private let none: NSEvent.ModifierFlags = [] private let option: NSEvent.ModifierFlags = [.option] private let command: NSEvent.ModifierFlags = [.command] /// Another board entirely — the right-hand side of every cross-board resolution below. It needs /// no fixture: locality compares paths, and nothing here reads the foreign board's contents. private let elsewhere = BoardRootKey(URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true)) /// One lane holding two cards, plus a trashed card so a `.trash`-container session has something /// real to name. 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.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed")) return fixture } /// A card session over `members`, picked up out of `lane1`. private func cardSession( _ store: BoardStore, members: [ItemID] = [card1], container: ItemContainer = .board ) -> DragSession { let session = DragSession() session.beginCards( members, folders: members.map { store.rootURL .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent($0.rawValue, isDirectory: true) }, heights: members.map { _ in 44 }, container: container, source: store ) return session } // MARK: The two layouts @Test("A move lifts the dragged run out — the source board shows what it is giving away") func aMoveLiftsTheRunOut() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store, members: [Self.card1, Self.card2]) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2]) // ⌘ over a foreign board is the cross-board move, and it lifts them out just the same. #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2]) } /// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here. @Test("A copy hides nothing — the originals re-admit into the source board's layout") func aCopyReAdmitsTheOriginals() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store, members: [Self.card1, Self.card2]) // The within-board ⌥-copy. #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) // And the cross-board default, which is a copy with no modifier at all. #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } /// The flip is a deliberate user action and the one-shot reflow is its feedback — which means /// the layout has to answer *both* ways, as many times as the user asks. @Test("Flipping the modifier mid-drag re-admits and re-lifts, every time") func theFlipGoesBothWays() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) for modifiers in [none, option, none, option] { session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers) let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) #expect(hidden == (modifiers == option ? [] : [Self.card1])) } } // MARK: The two things the rule does not reach /// The trash column's rows never lift, whatever the operation: they render in the column rather /// than in a lane's masonry, so there is no resting layout of a lane's for them to leave. They /// dim in place instead (`CardFaceView`, `TrashLaneRowView`). @Test("A trash-container session hides nothing, under either operation") func trashSessionsAreUnchanged() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } @Test("Only the source board hides anything, whatever the operation") func onlyTheSourceBoardHides() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) session.resolveOperation(destinationRoot: elsewhere, modifiers: command) #expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } /// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to /// `.copy`, so the strip's zones under the cursor never see a re-admitted lane and `moveLanes` /// keeps the index space it always had. A **cross-board** lane copy does re-admit — into the /// source strip, while the cursor is over the foreign board. @Test("A within-board lane drag never re-admits; a cross-board lane copy does") func laneSessionsFollowTheCarveOut() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = DragSession() session.beginLanes( [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)], units: [1], source: store ) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1]) #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } // MARK: The hold freezes it /// A release settles the arrangement, and the operation is part of the arrangement now: the /// write that went out named one, and a stray `dropUpdated` sampling a modifier the user has /// already let go of must not redraw the board against the other one. @Test("A settled copy keeps its originals on screen, whatever the modifiers do next") func aSettledCopyStaysReAdmitted() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) session.commit(into: store) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .copy) #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy) #expect(session.operation == .copy) #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } @Test("A settled move keeps its originals lifted, whatever the modifiers do next") func aSettledMoveStaysLifted() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) session.commit(into: store) #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move) #expect(session.operation == .move) #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } }