Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
347 lines
15 KiB
Swift
347 lines
15 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The card window's **one write into a body**: a Preview task-list checkbox being ticked
|
|
/// (05-card-window.md ▸ Preview — "clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker
|
|
/// in the source — a single-character textual edit; every other byte of the body is untouched").
|
|
///
|
|
/// Every other write in the app edits *frontmatter* by line span, and the round-trip guarantee falls
|
|
/// out of never re-serializing the body at all. This one edits the body, which means the guarantee
|
|
/// has to be earned rather than inherited — so the assertions here are byte comparisons of the body
|
|
/// against a literal expectation, not "the checkbox reads as ticked afterwards".
|
|
///
|
|
/// Like the rest of the write suites this drives real files in a temp board and reads back **raw
|
|
/// bytes**, never a snapshot: the claim is about what is on disk. `WriterFixture`, `Ident` and
|
|
/// `Item` come from `WriterTestSupport.swift`.
|
|
|
|
// MARK: - Fixture
|
|
|
|
/// A body with one of each checkbox state, a nested one, and — deliberately — a literal `[x]` in
|
|
/// prose that no flip may ever touch.
|
|
private let checklistBody = """
|
|
# Tasks
|
|
|
|
- [ ] first
|
|
- [x] second
|
|
- [ ] nested
|
|
|
|
Trailing prose with a [x] literal.
|
|
|
|
"""
|
|
|
|
/// The card, with everything a write must leave alone around the body: an unknown key carrying an
|
|
/// inline comment, a `created` from before today, and a foreign `modified-by`.
|
|
private let checklistCard = """
|
|
---
|
|
schema: 1
|
|
title: Checklist
|
|
order: 1024
|
|
project: lanework # agent overlay
|
|
created: 2026-01-01T09:00:00Z
|
|
modified: 2026-02-02T09:00:00Z
|
|
modified-by: claude
|
|
---
|
|
\(checklistBody)
|
|
"""
|
|
|
|
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
|
private let siblingPath = "\(Ident.lane1)/\(Ident.card2)"
|
|
|
|
@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(cardPath, checklistCard)
|
|
try fixture.item(siblingPath, Item.rich(order: "2048", title: "Untouched"))
|
|
return fixture
|
|
}
|
|
|
|
/// The card's body as it is on disk right now — split off at the closing delimiter by the same
|
|
/// parser the writer used, so "the body" means the same thing in the test as in the app.
|
|
private func body(of fixture: WriterFixture, _ relativePath: String) throws -> String {
|
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).body
|
|
}
|
|
|
|
/// The file's frontmatter lines, minus the two the stamp owns — what has to be identical, comment
|
|
/// and key order included.
|
|
private func frontmatterLines(_ text: String) -> [String] {
|
|
let lines = text.components(separatedBy: "\n")
|
|
guard let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" })
|
|
else { return lines }
|
|
return lines[0 ..< closing].filter { !$0.hasPrefix("modified:") && !$0.hasPrefix("modified-by:") }
|
|
}
|
|
|
|
/// The marker offsets the *parse* produces for a body — the app's own path from a rendered checkbox
|
|
/// to the byte a click will flip, used here rather than hand-counted numbers so the two halves of
|
|
/// the feature are tested joined up.
|
|
private func markerOffsets(in body: String) -> [Int] {
|
|
func walk(_ blocks: [BodyBlock]) -> [Int] {
|
|
blocks.flatMap { block -> [Int] in
|
|
switch block {
|
|
case let .list(list, _):
|
|
list.items.flatMap { ($0.task?.markerOffset.map { [$0] } ?? []) + walk($0.blocks) }
|
|
case let .quote(children, _):
|
|
walk(children)
|
|
default:
|
|
[]
|
|
}
|
|
}
|
|
}
|
|
return walk(BodyMarkup.parse(body).blocks)
|
|
}
|
|
|
|
private func modifiedDate(_ fixture: WriterFixture, _ relativePath: String) throws -> Date? {
|
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).modified.value
|
|
}
|
|
|
|
// MARK: - The flip
|
|
|
|
@MainActor
|
|
@Suite("BoardWriter ▸ toggleTaskMarker")
|
|
struct ToggleTaskMarkerTests {
|
|
|
|
@Test("Ticking a box changes exactly that byte of the body, and nothing else in the file")
|
|
func aFlipIsOneByte() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.indexText(cardPath)
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
#expect(offsets.count == 3, "the fixture's three checkboxes should all be located")
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
|
|
|
|
// The body, byte for byte, against a literal: the heading, the blank lines, the nested item,
|
|
// the trailing prose's literal `[x]` and the trailing newline are all still exactly there.
|
|
#expect(try body(of: fixture, cardPath) == """
|
|
# Tasks
|
|
|
|
- [x] first
|
|
- [x] second
|
|
- [ ] nested
|
|
|
|
Trailing prose with a [x] literal.
|
|
|
|
""")
|
|
|
|
// And the frontmatter is untouched but for the two keys every app write owns: key order,
|
|
// the unknown `project` key, its inline comment and `created` all survive.
|
|
let after = try fixture.indexText(cardPath)
|
|
#expect(frontmatterLines(after) == frontmatterLines(before))
|
|
#expect(after.contains("project: lanework # agent overlay"))
|
|
#expect(after.contains("created: 2026-01-01T09:00:00Z"))
|
|
}
|
|
|
|
@Test("The write stamps modified and clears a foreign modified-by")
|
|
func theWriteStamps() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[1], checked: true)
|
|
|
|
// A toggle is "an ordinary user edit — the standard atomic write" (05), so it carries the
|
|
// same stamps every other app write does (01-storage-format.md § Frontmatter).
|
|
let stamped = try #require(try modifiedDate(fixture, cardPath))
|
|
#expect(stamped.timeIntervalSinceNow > -30)
|
|
#expect(!(try fixture.indexText(cardPath).contains("modified-by")))
|
|
}
|
|
|
|
@Test("Flipping twice restores the file's body byte for byte")
|
|
func aFlipRoundTrips() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let original = try body(of: fixture, cardPath)
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
|
|
#expect(try body(of: fixture, cardPath) != original)
|
|
|
|
// The second flip reads the file fresh and finds the marker where the first one left it —
|
|
// the offset is stable because the edit changed a byte's *value*, never the body's length.
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: true)
|
|
#expect(try body(of: fixture, cardPath) == original)
|
|
#expect(try Data(body(of: fixture, cardPath).utf8) == Data(original.utf8))
|
|
}
|
|
|
|
@Test("A nested checkbox flips, and its parent does not")
|
|
func nestedMarkersAreTheirOwn() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[2], checked: false)
|
|
|
|
#expect(try body(of: fixture, cardPath) == """
|
|
# Tasks
|
|
|
|
- [ ] first
|
|
- [x] second
|
|
- [x] nested
|
|
|
|
Trailing prose with a [x] literal.
|
|
|
|
""")
|
|
}
|
|
|
|
@Test("No other file is opened, let alone rewritten")
|
|
func siblingsAreUntouched() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let siblingURL = fixture.url(siblingPath).appendingPathComponent("index.md")
|
|
let siblingBefore = try Data(contentsOf: siblingURL)
|
|
let siblingModified = try FileManager.default.attributesOfItem(atPath: siblingURL.path)[.modificationDate] as? Date
|
|
let laneBefore = try fixture.indexData(Ident.lane1)
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
|
|
|
|
#expect(try Data(contentsOf: siblingURL) == siblingBefore)
|
|
// The filesystem's own "did anything happen here" signal, not just the bytes.
|
|
let siblingAfter = try FileManager.default.attributesOfItem(atPath: siblingURL.path)[.modificationDate] as? Date
|
|
#expect(siblingAfter == siblingModified)
|
|
#expect(try fixture.indexData(Ident.lane1) == laneBefore)
|
|
}
|
|
|
|
@Test("A successful flip leaves no temp file behind")
|
|
func noResidue() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[0], checked: false)
|
|
|
|
// Hidden entries included — the writer's temps are dot-prefixed, so only a listing that
|
|
// sees them can prove there is none.
|
|
#expect(try fixture.entryNames(cardPath) == ["index.md"])
|
|
}
|
|
}
|
|
|
|
// MARK: - Refusals
|
|
|
|
@MainActor
|
|
@Suite("BoardWriter ▸ toggleTaskMarker refusals")
|
|
struct ToggleTaskMarkerRefusalTests {
|
|
|
|
@Test("A state the file no longer agrees with refuses, and writes nothing")
|
|
func aStaleStateRefuses() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.indexData(cardPath)
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
// The user saw an unticked box; disk says it is ticked. Flipping would undo somebody else's
|
|
// edit instead of performing this one, so it refuses (`toggleTaskMarker`'s re-verification).
|
|
let error = writeFailure {
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offsets[1], checked: false)
|
|
}
|
|
#expect(error?.operation == .toggleTask(title: "Checklist"))
|
|
if case .staleTarget = error?.reason {} else {
|
|
Issue.record("expected a staleTarget refusal, got \(String(describing: error?.reason))")
|
|
}
|
|
#expect(try fixture.indexData(cardPath) == before, "a refused write is a write that did not happen")
|
|
}
|
|
|
|
@Test("An offset that is not a checkbox refuses")
|
|
func aStaleOffsetRefuses() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let before = try fixture.indexData(cardPath)
|
|
|
|
for offset in [0, 7, 999_999] {
|
|
let error = writeFailure {
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(cardPath), bodyOffset: offset, checked: false)
|
|
}
|
|
if case .staleTarget = error?.reason {} else {
|
|
Issue.record("expected a staleTarget refusal at \(offset), got \(String(describing: error?.reason))")
|
|
}
|
|
}
|
|
#expect(try fixture.indexData(cardPath) == before)
|
|
}
|
|
|
|
@Test("Frontmatter that cannot be edited in place refuses before the body is touched")
|
|
func uneditableFrontmatterRefuses() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
// A whole-frontmatter flow mapping: readable, renderable, and unwritable — the settled
|
|
// readable-but-uneditable rule, which a body edit is no exemption from, because the write
|
|
// still has to stamp `modified` through the span editor.
|
|
let path = "\(Ident.lane1)/\(Ident.card3)"
|
|
try fixture.item(path, "---\n{schema: 1, order: 3072}\n---\n- [ ] task\n")
|
|
let before = try fixture.indexData(path)
|
|
|
|
let error = writeFailure {
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.url(path), bodyOffset: 3, checked: false)
|
|
}
|
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
|
#expect(try fixture.indexData(path) == before)
|
|
}
|
|
|
|
@Test("A folder that is not a lane or a card refuses")
|
|
func strayFoldersRefuse() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
// The board root is the case that matters: a board's body is its description, no surface
|
|
// previews it, and the shape guard is what makes this call structurally unable to reach it.
|
|
let error = writeFailure {
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: fixture.root, bodyOffset: 3, checked: false)
|
|
}
|
|
if case .unreadable = error?.reason {} else {
|
|
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Through the store
|
|
|
|
@MainActor
|
|
@Suite("BoardStore ▸ toggleTaskMarker")
|
|
struct StoreToggleTaskMarkerTests {
|
|
|
|
@Test("A click through the store lands on disk")
|
|
func theStoreWritesThrough() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
|
|
|
|
// Read back through the loader, never through the store's snapshot: the one-way flow means
|
|
// the snapshot only catches up when the watcher's reload lands (02-architecture.md).
|
|
#expect(try body(of: fixture, cardPath).contains("- [x] first"))
|
|
}
|
|
|
|
@Test("A card that is not in the snapshot is not written to")
|
|
func aVanishedCardWritesNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try fixture.indexData(cardPath)
|
|
|
|
// An id the board does not hold — the vanished-target guard every gesture in the store
|
|
// makes, here standing in for a card window whose card left under the click.
|
|
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card4), bodyOffset: 3, checked: false)
|
|
|
|
#expect(try fixture.indexData(cardPath) == before)
|
|
}
|
|
|
|
@Test("A trashed card's checkbox does not write")
|
|
func aTrashedCardWritesNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.move(cardPath, toTrash: Ident.card1)
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let before = try body(of: fixture, ".trash/\(Ident.card1)")
|
|
let offsets = markerOffsets(in: checklistBody)
|
|
|
|
// `BoardStore.boardItem` is the board container and only it: a trashed card does not open,
|
|
// so nothing may write through a preview of one (03 ▸ Trash's no-editing rule).
|
|
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
|
|
|
|
#expect(try body(of: fixture, ".trash/\(Ident.card1)") == before)
|
|
}
|
|
}
|