diff --git a/Fixtures/README.md b/Fixtures/README.md index 5e6f019..2a7fdc9 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -4,7 +4,7 @@ Golden fixture boards for the storage-contract test suite — real on-disk folde Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`. -Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. +Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. That is a fixture-authoring convention, not the loader's gate: the identity predicate is shape-only (`8-4-4-4-12` hex, **any case, any version** — 01-storage-format.md § Fractal layout ▸ Rules), and the case/version coverage lives in `KanbanTests/BoardLoaderTests.swift` rather than here. ## Valid/ — one board per tolerated/valid case diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index fb8741a..8014402 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -9,8 +9,9 @@ import os /// /// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders → /// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸ -/// Rules): only a folder whose name has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — is a -/// lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly what's checked. +/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any +/// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly +/// what's checked. /// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped /// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended /// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a @@ -175,26 +176,40 @@ public enum BoardLoader: Sendable { FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path) } - /// The lowercase hex characters `isUUIDShaped` accepts in each `-`-delimited group. - private static let lowercaseHexDigits = Set("0123456789abcdef") + /// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**, + /// per the shape-only identity predicate below. + private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF") - /// Whether `name` has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — gating lane/card level - /// detection (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level - /// detection"). Deliberately permissive about *which* nibbles matter: the version (13th hex - /// digit) and variant (17th hex digit) are **not** validated, so any lowercase-hex string in - /// this shape reads as a candidate — whether or not it was actually minted by - /// `UUID().uuidString.lowercased()`. That reading is intentional, not an oversight: the - /// loader's job is recognizing the folder-naming *convention*, not re-deriving RFC 4122 - /// conformance every load. Case-sensitive — an uppercase or mixed-case UUID string is a - /// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name - /// (`BoardModel.swift`). + /// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** — + /// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name + /// shape gates level detection"). This is *the* identity predicate, and it is deliberately + /// **shape-only**: lowercase v4 is the app's emission rule, not the gate. + /// + /// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*, + /// so a strict lowercase gate would turn an agent's standard-tool card into a silently + /// skipped stray — the worst failure mode for a files-first app. Accept liberally, emit + /// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an + /// existing folder to canonicalize it. + /// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are + /// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique + /// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not + /// re-deriving RFC 4122 conformance every load. + /// + /// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md + /// states the rule; kept as a manual scan because that is the cheaper answer on the hot path + /// (every folder of every load) and needs no bridging. + /// + /// Recognizing a name is not the same as *comparing* two of them: identity comparison is + /// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere — + /// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares + /// canonically. /// /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same /// candidates the loader walked, and level detection has to be one rule, not two. static func isUUIDShaped(_ name: String) -> Bool { let groups = name.split(separator: "-", omittingEmptySubsequences: false) guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false } - return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) } + return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) } } /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding @@ -310,7 +325,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { /// this case. case missingIndex(path: String) - /// A lane/card-depth folder whose name doesn't have UUIDv4's shape (`isUUIDShaped`) — + /// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex, + /// `8-4-4-4-12`, any case, any version) — /// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one, /// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level /// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 8c9016b..a055e3f 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -7,16 +7,31 @@ import Foundation /// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field — /// the three distinct Swift types encode it structurally. -/// The immutable identity of a lane or card folder: its exact name, byte-for-byte. +/// The immutable identity of a lane or card folder: its exact name, byte-for-byte — compared as +/// a UUID *value*. /// -/// Folder names are lowercase UUIDv4, gated at load time (01-storage-format.md § Fractal -/// layout ▸ Rules, "Name shape gates level detection") — `BoardLoader` only ever promotes a -/// UUID-*shaped* folder (lowercase hex, `8-4-4-4-12`; version/variant nibbles unchecked) to a -/// `Lane`/`Card` in the first place, so every `ItemID` reaching this type already has that -/// shape. The model still stores whatever the folder is actually named rather than -/// re-validating or normalizing it: it must round-trip byte-perfect (it is the primary key) and -/// is the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's -/// `UUID`, which normalizes to uppercase and would silently corrupt that round-trip. +/// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip +/// byte-perfect (it is the primary key on disk), and it is the display-order tie-break +/// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not** +/// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that +/// round-trip. +/// +/// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal +/// layout ▸ Rules, settled): *two case-spellings of one UUID are one identity everywhere* — +/// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by +/// this type. That matches default-APFS case-insensitivity, where the two spellings name one +/// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints +/// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` — hex, `8-4-4-4-12`, +/// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so +/// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value +/// canonicalization. Nothing asserts that — the type stays total on any string a caller hands it; +/// off-shape input simply compares by its own lowercasing, which is the harmless reading. +/// +/// Consequences, all intended: `RawRepresentable` is unaffected — only `==` and `hash(into:)` +/// are hand-written, and `rawValue` still reads back exactly as it was stored; +/// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder +/// is the *same* row rather than a delete plus an insert; and a `Set` holding both +/// spellings collapses them to one member, keeping whichever arrived first. /// /// Board roots don't get one of these: a board's folder name is a human/Finder-assigned /// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) — its identity is @@ -28,6 +43,20 @@ public struct ItemID: Hashable, Sendable, RawRepresentable { public init(rawValue: String) { self.rawValue = rawValue } + + /// The comparison key: `rawValue` case-folded. Computed rather than stored so `ItemID` stays + /// one string wide and `rawValue` remains the single source of truth for what is on disk. + /// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is + /// UUID-value canonicalization and nothing more. + var canonicalValue: String { rawValue.lowercased() } + + public static func == (lhs: ItemID, rhs: ItemID) -> Bool { + lhs.canonicalValue == rhs.canonicalValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(canonicalValue) + } } extension ItemID: CustomStringConvertible { diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 959a859..c37995b 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -259,15 +259,19 @@ public enum BoardWriter: Sendable { /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase - /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match - /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. + /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission** + /// rule — accept liberally, emit conservatively. The loader's gate + /// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the + /// app holds itself to, not something a reader depends on. /// /// Two exclusions, both re-minted rather than assumed away: a name already on disk in /// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a /// race with an existing entry), and any name in `taken` — the identities a collision repair - /// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID - /// hitting either is astronomically unlikely — 122 bits of randomness per mint — but the - /// loop body is trivial precisely because the case it handles essentially never fires. + /// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical** + /// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value* + /// probe: the minted name is lowercase, so it can only match a canonical set. A freshly + /// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint — + /// but the loop body is trivial precisely because the case it handles essentially never fires. private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { var name: String repeat { @@ -439,7 +443,10 @@ public enum BoardWriter: Sendable { /// siblings — while the moved item is still elsewhere and so cannot count itself. /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` + /// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a - /// same-board move cannot collide with anything but itself. + /// same-board move cannot collide with anything but itself. The scan and every probe + /// against it are **by UUID value, not spelling** (`identities(inBoard:)` / + /// `canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…` + /// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules). /// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across /// volumes). A colliding *root* is renamed by moving it straight to its minted name /// rather than moving and then renaming: one filesystem operation instead of two, and it @@ -492,7 +499,15 @@ public enum BoardWriter: Sendable { rank = order } else { let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false) - rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order)) + // "Which sibling is the item itself" is an identity question, so it is asked by + // UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the + // directory listing can disagree in case for one and the same folder. + let selfIdentity = canonicalIdentity(sourceName) + rank = Ranks.append( + toVisible: siblings + .filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity } + .map(\.order) + ) } try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) @@ -508,7 +523,7 @@ public enum BoardWriter: Sendable { var reminted: [MoveResult.Remint] = [] var arrivedName = sourceName - if existing.contains(sourceName) { + if existing.contains(canonicalIdentity(sourceName)) { arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved) reserved.insert(arrivedName) reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName))) @@ -527,8 +542,8 @@ public enum BoardWriter: Sendable { if isImport { let children = childCandidates(of: arrivedRoot) - reserved.formUnion(children.map(\.lastPathComponent)) - for child in children where existing.contains(child.lastPathComponent) { + reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) }) + for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) { let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved) reserved.insert(fresh) try renameFolder(child, toSiblingNamed: fresh, operation: operation) @@ -561,17 +576,30 @@ public enum BoardWriter: Sendable { /// and the conservative direction here — a missed identity remints nothing, and a duplicate /// UUID in one board is the unspecified-behavior case the design already names, not a /// corruption. + /// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`), + /// and every probe against the returned set must be too. Identity comparison is UUID-*value* + /// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving + /// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a + /// verbatim set would miss exactly that collision and let a duplicate UUID into the board. private static func identities(inBoard boardRoot: URL) -> Set { var identities: Set = [] for lane in childCandidates(of: boardRoot) { - identities.insert(lane.lastPathComponent) + identities.insert(canonicalIdentity(lane.lastPathComponent)) for card in childCandidates(of: lane) { - identities.insert(card.lastPathComponent) + identities.insert(canonicalIdentity(card.lastPathComponent)) } } return identities } + /// A folder name reduced to its identity *value* — the same canonicalization `ItemID`'s + /// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names + /// as strings because it is working with paths rather than model values. Every identity-shaped + /// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly. + private static func canonicalIdentity(_ folderName: String) -> String { + folderName.lowercased() + } + /// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's /// level-detection rule and therefore the only definition of "an identity-bearing child" @@ -1017,8 +1045,8 @@ public enum BoardWriter: Sendable { } /// Refuses a folder that is not a lane or a card. Level detection is by name shape - /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase - /// UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or + /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, a truncated or + /// non-hex UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or /// purging one as if it were would invent (or destroy) an identity the loader would /// otherwise just ignore. Shared by every operation that must never reach a board root: a /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index b8f75d5..773d81d 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -47,11 +47,12 @@ private struct BoardFixture { } } -/// A fresh folder name with UUIDv4's shape (lowercase hex, `8-4-4-4-12`) — the only shape -/// `BoardLoader` accepts as a lane/card candidate (01-storage-format.md § Fractal layout ▸ -/// Rules, "Name shape gates level detection"). Used wherever a test just needs *a* valid -/// lane/card identity and doesn't care about the exact value; tests that need a specific -/// lexicographic ordering use literal UUID-shaped strings instead. +/// A fresh folder name in the app's own emission spelling — lowercase v4. The loader's gate is +/// shape-only (hex, `8-4-4-4-12`, any case, any version — 01-storage-format.md § Fractal layout +/// ▸ Rules, "Name shape gates level detection"), so this is *a* valid identity rather than the +/// only kind; the case tests below cover the rest. Used wherever a test just needs some lane/card +/// identity and doesn't care about the exact value; tests that need a specific lexicographic +/// ordering use literal UUID-shaped strings instead. private func uuidFolderName() -> String { UUID().uuidString.lowercased() } @@ -303,22 +304,90 @@ struct BoardLoaderNonUUIDStrayTests { #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch"))) } - /// Case sensitivity: an uppercase (or mixed-case) UUID string doesn't have UUIDv4's - /// *lowercase* shape, so it's a stray — folder names are never normalized. - @Test func uppercaseUUIDFolderIsTreatedAsNonUUIDStray() throws { + /// The identity predicate is **shape-only, any case** (01-storage-format.md § Fractal layout + /// ▸ Rules, settled): `uuidgen` and `UUID().uuidString` both print uppercase, so an + /// uppercase folder is an ordinary lane — never a silently skipped stray. Its `rawValue` + /// keeps the exact spelling: the app accepts liberally and never renames to canonicalize. + @Test func uppercaseUUIDFolderIsALaneWithItsSpellingPreserved() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lowercaseLane = uuidFolderName() + let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. + + try fixture.index("", "schema: 1\n") + try fixture.index(lowercaseLane, "schema: 1\norder: 1024\n") + try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [lowercaseLane, uppercaseLane]) + #expect(result.warnings.isEmpty) + } + + /// One level down, and mixed case rather than uniform: an agent's `uuidgen`-named card + /// folder loads as a card, spelling intact. + @Test func mixedCaseUUIDCardFolderLoadsAsACardWithItsSpellingPreserved() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = uuidFolderName() + let mixedCaseCard = "AbCdEf01-2345-6789-aBcD-EF0123456789" + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(mixedCaseCard)", "schema: 1\norder: 1024\ntitle: From an agent\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + let loadedLane = try #require(result.model.lanes.first) + #expect(loadedLane.cards.map(\.id.rawValue) == [mixedCaseCard]) + #expect(loadedLane.cards.first?.title.value == "From an agent") + #expect(result.warnings.isEmpty) + } + + /// **Any version**, not just v4: the version nibble protects no invariant here — a v7 (or a + /// nibble no RFC ever assigned) is exactly as unique as a v4, so it is an identity, not a + /// stray. Only the shape is checked. + @Test func oddVersionAndVariantNibblesAreStillIdentities() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let v7Lane = "01912d5e-7c00-7000-8000-abcdefabcdef" // version nibble 7 + let oddLane = "01912d5e-7c00-c000-f000-abcdefabcdef" // version c, variant f — no RFC's + + try fixture.index("", "schema: 1\n") + try fixture.index(v7Lane, "schema: 1\norder: 1024\n") + try fixture.index(oddLane, "schema: 1\norder: 2048\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [v7Lane, oddLane]) + #expect(result.warnings.isEmpty) + } + + /// The shape itself is still strict: near-misses stay strays. Hex only, exact group lengths, + /// hyphens exactly where they belong. + @Test func nearMissUUIDShapesAreStillStrays() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() - let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. + let strays = [ + "GGGGGGGG-0000-4000-8000-000000000000", // not hex + "00000000-0000-4000-8000-00000000000", // one digit short + "000000000-000-4000-8000-000000000000", // hyphens misplaced + "00000000-0000-4000-8000-000000000000-", // trailing hyphen + ] try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") - try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") + for (offset, stray) in strays.enumerated() { + try fixture.index(stray, "schema: 1\norder: \(2048 + offset)\n") + } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) - #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: uppercaseLane))) + for stray in strays { + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: stray))) + } } /// Reserved card children are covered "by construction" now: `attachments/` and @@ -343,6 +412,54 @@ struct BoardLoaderNonUUIDStrayTests { } } +// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity +// comparison is UUID-value equality, never string equality") + +/// `ItemID` stores the folder's exact spelling — it builds URLs — but *compares* as a UUID +/// value: two case-spellings of one UUID are one identity everywhere. +struct ItemIDValueSemanticsTests { + private static let lower = "abcdef01-2345-6789-abcd-ef0123456789" + private static let upper = "ABCDEF01-2345-6789-ABCD-EF0123456789" + private static let mixed = "AbCdEf01-2345-6789-aBcD-eF0123456789" + + @Test func caseSpellingsOfOneUUIDAreEqualAndHashAlike() { + let lower = ItemID(rawValue: Self.lower) + let upper = ItemID(rawValue: Self.upper) + let mixed = ItemID(rawValue: Self.mixed) + + #expect(lower == upper) + #expect(lower == mixed) + #expect(upper == mixed) + #expect(lower.hashValue == upper.hashValue) + #expect(upper.hashValue == mixed.hashValue) + } + + /// Equality is by value, but the spelling is never rewritten — the app accepts liberally and + /// emits conservatively, and `rawValue` is what builds the folder's URL. + @Test func rawValueKeepsTheExactSpelling() { + #expect(ItemID(rawValue: Self.upper).rawValue == Self.upper) + #expect(ItemID(rawValue: Self.mixed).description == Self.mixed) + } + + @Test func distinctUUIDsAreUnequalHoweverTheyAreSpelled() { + let one = ItemID(rawValue: "abcdef01-2345-6789-abcd-ef0123456789") + let other = ItemID(rawValue: "ABCDEF01-2345-6789-ABCD-EF012345678A") + #expect(one != other) + } + + /// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection + /// membership included (`BoardStore.Selection`). + @Test func aSetCollapsesTheTwoSpellingsToOneMember() { + let set: Set = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)] + #expect(set.count == 1) + #expect(set.contains(ItemID(rawValue: Self.mixed))) + + var byID: [ItemID: String] = [:] + byID[ItemID(rawValue: Self.upper)] = "written uppercase" + #expect(byID[ItemID(rawValue: Self.lower)] == "written uppercase") + } +} + // MARK: - Board-level deleted struct BoardLoaderBoardLevelDeletedTests { diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 108e13a..bedc122 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -1014,6 +1014,66 @@ struct BoardWriterMoveTests { #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") == twinBefore) } + /// **Identity comparison is UUID-value equality, never string equality** (01-storage-format.md + /// § Fractal layout ▸ Rules, settled): the destination board already holds the arriving UUID + /// spelled in *uppercase* — an agent's `uuidgen` card — so the two are one identity and the + /// import boundary must remint. A verbatim string set would sail straight past this and leave + /// a duplicate UUID in the board. + @Test func aCollisionSpelledInADifferentCaseIsStillOneIdentityAndRemints() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + + let arriving = "abcdef01-2345-6789-abcd-ef0123456789" + let twin = arriving.uppercased() // The same UUID, as `uuidgen` would have printed it. + try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Card One")) + try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "Stale Twin")) + let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(arriving)", + to: "B.kanban/\(Ident.lane4)", into: "B.kanban" + ) + + let minted = result.id.rawValue + #expect(BoardLoader.isUUIDShaped(minted)) + #expect(ItemID(rawValue: minted) != ItemID(rawValue: arriving)) + #expect(ItemID(rawValue: minted) != ItemID(rawValue: twin)) + #expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: arriving), to: ItemID(rawValue: minted))]) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)")).title + == .valid("Card One")) + + // The resident twin is untouched, and the source left as for any move. + #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") == twinBefore) + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(arriving)")) + } + + /// The same rule one level down: a lane arrives carrying a card whose UUID the destination + /// board already holds under a different case-spelling — that card, and only that card, is + /// reminted. + @Test func aLaneMoveRemintsAChildCollidingOnlyByCaseSpelling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + + let arriving = "abcdef01-2345-6789-abcd-ef0123456789" + let twin = arriving.uppercased() + try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Colliding")) + try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "B's Own")) + + let result = try move(fixture, "A.kanban/\(Ident.lane1)", to: "B.kanban", into: "B.kanban") + + #expect(result.id.rawValue == Ident.lane1) + #expect(result.reminted.map(\.from.rawValue) == [arriving]) + let minted = try #require(result.reminted.first?.to.rawValue) + #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(minted)")) + #expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(arriving)")) + // The card that collided with nothing kept its identity. + #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card1)")) + } + /// The collision sitting in the very lane being dropped into — the case a move-then-rename /// could not repair, because the plain move would fail on the existing name. @Test func aCollisionInTheDestinationParentItselfIsStillReminted() throws {