Files
lanework/KanbanTests/DuplicateIdentityTests.swift
T
rzen 0463540aea Dedupe duplicate ids and heal them silently
The crash-class gap the integrity design pass found (DESIGN/01 -
Fractal layout rules; 02 - Live-reload resilience): the loader had no
board-wide dedupe at all, so two hand-copied folders sharing a UUID put
two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not
tolerate. Built to the day's re-rulings, both landing mid-flight: the
user-gated Repair banner retired (176c852 - the heal runs silently) and
the container boundary became the first tie-break (f153e79 - the
visible card never loses to its own trash ghost).

IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by
canonical identity, collapse case-spelled twins first - spellings with
a live occurrence outrank trash-only spellings, then canonical
lowercase, then lexicographically first; losers are silent strays
(LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) -
then earlier-occurrence-wins across the surviving spelling's folders on
a four-rung ladder: live-before-trashed, git path history rank, FS
birth date (nil is no comparison, never .distantPast), traversal
order. Occurrences are exactly the identity-bearing folders: lanes,
cards, .trash entries - a UUID-shaped folder under a card is content.

BoardLoader walks lanes as WalkedLane and builds Lane values only on
the far side of the verdict, so a withheld card can never reach a
snapshot; a name-only gate keeps the healthy-board cost at one
dictionary pass, no disk reads. Withheld subtrees are still walked - a
hand-copied lane's nested cards are their own withheld occurrences,
reminted at the finest grain like the import boundary would have. A
withheld trash entry's trashKinds reading leaves with it. The git rung
is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by
root-relative path) because base links no git machinery - base injects
nothing and falls through; pro-m1 owns the ranker (card annotated).

The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>)
rides HealScheduler as the fourth scheduled heal, ordered last among
the content heals because a remint renames folders and would stale the
paths the same load handed the relocation and migration.
BoardWriter.remintDuplicateIdentity re-verifies twice at write time -
the folder still carries the losing identity AND something else still
does (the vanished-duplicate race no-ops from either side) - then
renames to a fresh v4 minted against the whole board's identity bag.
A rename and nothing else: no index.md opened, no modified stamp, no
modified-by clear; the receipt is heal-marked (pro-m1's committer
splits it out, named by 06's kept Repair verb); no undo step - heals
are not gestures. The notice is the design's own sentence ("Repaired
duplicate id - 'Fix login'"; several fold to a count), a loss row on
the relocation's reasoning; WriteOperation.repairDuplicateID carries
the failure mirror.

Fixture repair rode along: duplicate-order-tie-break.kanban had a lane
and its own card sharing a UUID - a genuine duplicate the new pass
correctly withholds; the folder rename landed in feae6d0, the matching
test constant lands here.

66 tests added (DuplicateIdentityTests: the ladder rung by rung, the
straddles, withheld-lane subtrees, remint idempotence and races, the
one-heal-cycle window, all phrasing). 1804 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 18:19:52 -04:00

1199 lines
58 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// **Duplicate ids within a board are never tolerated** (01-storage-format.md § Fractal layout ▸
/// Rules, settled; the silent-remint re-ruling of 2026-07-29).
///
/// Two folders can end up carrying one UUID however carefully the app mints them — a user copies a
/// card folder in Finder, an agent duplicates a tree, an archive is unpacked over a board. The
/// snapshot invariant does not bend for any of it: **one occurrence per id, board-wide**, because
/// SwiftUI's `ForEach` does not tolerate two equal ids and a board that crashes on load is the worst
/// failure a files-first app has.
///
/// Two classes, two verdicts:
///
/// - **Case-spelled twins** of one identity are spelling artifacts of the *same* item. The canonical
/// all-lowercase spelling wins where present, else the lexicographically first; the loser is a
/// silent stray — logged, preserved, never rendered, **never reminted** (reminting one would create
/// duplicate content the user never made).
/// - **Content duplicates** — distinct folders carrying the identical name string, necessarily under
/// different parents — are copies. Earlier-occurrence-wins; every later occurrence is withheld from
/// the snapshot and then **reminted by a scheduled heal** (re-ruled 2026-07-29 — the user-gated
/// Repair banner retired: "Lanework owns the board and re-mints identity at will").
// MARK: - Identities
/// UUIDs with hex *letters* in them, because a case-twin fixture needs a name whose spelling can
/// actually change — `Ident.card1` is all digits and uppercases to itself.
private enum Dup {
/// The canonical, all-lowercase spelling.
static let lower = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
/// The same identity, shouted.
static let upper = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
/// The same identity again, spelled a third way — and lexicographically *after* `upper`, because
/// `"B" < "b"` in ASCII.
static let mixed = "AAAAAAAA-bbbb-4ccc-8ddd-eeeeeeeeeeee"
/// An unrelated identity, for boards that need a card that is nobody's twin.
static let other = "fedcba98-7654-4321-8fed-cba987654321"
}
private func occurrence(
_ path: String,
_ name: String,
container: IntegrityRules.IdentityOccurrence.Container = .live,
title: String? = nil,
birth: Date? = nil,
historyRank: Int? = nil
) -> IntegrityRules.IdentityOccurrence {
IntegrityRules.IdentityOccurrence(
path: path,
name: name,
container: container,
title: title,
birth: birth,
historyRank: historyRank
)
}
private func date(_ offset: TimeInterval) -> Date {
Date(timeIntervalSince1970: 1_700_000_000 + offset)
}
// MARK: - The rule
/// `IntegrityRules.dedupe(_:)` is pure — an occurrence list in, two lists of losers out — so the rule
/// is pinned here without a filesystem in the way.
@Suite("Duplicate ids ▸ the dedupe rule")
struct DedupeRuleTests {
@Test("A board with no repeated identity yields nothing")
func nothingToDo() {
let verdict = IntegrityRules.dedupe([
occurrence(Ident.lane1, Ident.lane1),
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower),
occurrence("\(Ident.lane1)/\(Dup.other)", Dup.other),
])
#expect(verdict.duplicates.isEmpty)
#expect(verdict.caseTwins.isEmpty)
}
/// The headline case: one hand copy, two lanes, identical name strings.
@Test("Two identical spellings under different parents: the later one is withheld")
func contentDuplicateWithholdsTheLater() {
let verdict = IntegrityRules.dedupe([
occurrence(Ident.lane1, Ident.lane1),
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, title: "Fix login"),
occurrence(Ident.lane2, Ident.lane2),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, title: "Fix login"),
])
#expect(verdict.caseTwins.isEmpty, "identical spellings are copies, never spelling artifacts")
#expect(verdict.duplicates == [
DuplicateIdentity(
path: "\(Ident.lane2)/\(Dup.lower)",
identity: Dup.lower,
title: "Fix login",
winner: "\(Ident.lane1)/\(Dup.lower)"
),
])
}
/// Rung two of the ladder outranks rung three: the older folder wins even when the traversal meets
/// it second.
@Test("Birth date beats traversal order")
func birthDateBeatsTraversal() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(100)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0)),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"])
#expect(verdict.duplicates.map(\.winner) == ["\(Ident.lane2)/\(Dup.lower)"])
}
@Test("Equal birth dates fall through to traversal order")
func equalDatesFallThrough() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0)),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
/// **An unreadable date is no comparison at all.** Substituting `.distantPast` for a missing birth
/// date would silently make the unreadable folder win every comparison it entered.
@Test("One unreadable birth date drops the comparison to traversal order")
func oneMissingDateFallsThrough() {
// The folder *with* the older date is second in traversal; if a `nil` date were read as
// `.distantPast` the first would win, which is exactly what must not happen.
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: nil),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(-10_000)),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
@Test("Neither date readable falls through to traversal order")
func noDatesFallThrough() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
/// Rung one — the git seam. Base never fills it; pro-m1 does.
@Test("Git path history beats birth date")
func historyBeatsBirthDate() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: 7),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 3),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"])
}
/// "The path history already tracks outranks the newcomer" — read literally, and *against* the
/// birth date, which is the whole reason the rungs are ordered.
@Test("A tracked path outranks an untracked one whatever the dates say")
func trackedOutranksUntracked() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: nil),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 9),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"])
}
@Test("Equal history ranks fall through to the next rung")
func equalRanksFallThrough() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 4),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: 4),
])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"])
}
// MARK: The container boundary — rung 0
/// **The visible card never loses to its own ghost** (01-storage-format.md § Fractal layout ▸
/// Rules, stated 2026-07-29): the straddle case is a restore done as a *copy* — an ⌥-drag out of
/// the trash in Finder — where the ghost left behind is genuinely the older folder. Every
/// age-based rung would withhold the card the user just restored; rung 0 is what stops them being
/// consulted at all.
@Test("A live occurrence beats an older trashed one")
func liveBeatsOlderTrashed() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(1000)),
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)),
])
#expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"])
#expect(verdict.duplicates.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"])
}
/// Rung 0 sits **above history too**, not merely above age: an ⌥-drag restore leaves the tracked
/// path in the trash, so the ghost is both older *and* the one git knows.
@Test("A live occurrence beats a trashed one git says entered first")
func liveBeatsTrackedTrashed() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(1000), historyRank: nil),
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0), historyRank: 1),
])
#expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"])
}
/// Traversal order does not decide it either — a trashed occurrence met *first* still loses.
@Test("A trashed occurrence met first still loses")
func trashedMetFirstStillLoses() {
let verdict = IntegrityRules.dedupe([
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed),
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower),
])
#expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"])
}
/// **A group wholly inside the trash falls through to the ordinary ladder**, unchanged: rung 0 only
/// speaks when the occurrences straddle the boundary.
///
/// The paths here are labels — the flat trash cannot really hold two entries with one name, so this
/// pins the *rule*'s fall-through rather than a reachable board. What makes it worth pinning is the
/// mistake it rules out: a container rung implemented as "trashed always loses" rather than as a
/// comparison would have no answer at all when both sides are trashed.
@Test("Two trashed occurrences fall through to the age ladder")
func bothTrashedFallThrough() {
let verdict = IntegrityRules.dedupe([
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(1000)),
occurrence(".trash/nested/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)),
])
#expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"], "the older trashed one wins")
}
/// Several live occurrences and a ghost: the ghost is out on rung 0, and the live ones settle it
/// among themselves on the age rungs.
@Test("The ghost is out first, then the live occurrences settle it by age")
func ghostOutThenAgeDecides() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(200)),
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)),
])
#expect(verdict.duplicates.map(\.path) == [
"\(Ident.lane1)/\(Dup.lower)",
".trash/\(Dup.lower)",
])
#expect(verdict.duplicates.map(\.winner) == Array(repeating: "\(Ident.lane2)/\(Dup.lower)", count: 2))
}
// MARK: Case twins
/// The canonical spelling wins **wherever it sits in the traversal** — this is not an
/// earlier-occurrence question, it is a spelling question.
@Test("The all-lowercase spelling wins, however late it is met")
func lowercaseWins() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(0)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)),
])
#expect(verdict.duplicates.isEmpty, "a spelling artifact is never healed")
#expect(verdict.caseTwins == [
CaseTwin(path: "\(Ident.lane1)/\(Dup.upper)", winner: "\(Ident.lane2)/\(Dup.lower)"),
])
}
@Test("With no lowercase spelling present, the lexicographically first wins")
func lexicographicallyFirstWins() {
// "AAAAAAAA-BBBB-…" < "AAAAAAAA-bbbb-…" — uppercase sorts before lowercase in ASCII.
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.mixed)", Dup.mixed),
occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper),
])
#expect(verdict.duplicates.isEmpty)
#expect(verdict.caseTwins == [
CaseTwin(path: "\(Ident.lane1)/\(Dup.mixed)", winner: "\(Ident.lane2)/\(Dup.upper)"),
])
}
/// **Mixed groups collapse spelling first, then apply earlier-occurrence-wins to what is left.**
/// The consequence is deliberate and documented: a copy whose case was *also* hand-changed
/// degrades to the silent case-twin posture and is never reminted.
@Test("Three occurrences spanning both classes: spelling collapses first")
func mixedGroupCollapsesSpellingFirst() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, title: "Fix login", birth: date(0)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, title: "Fix login", birth: date(100)),
occurrence("\(Ident.lane3)/\(Dup.upper)", Dup.upper, title: "Fix login", birth: date(200)),
])
// The uppercase copy is a spelling artifact — silent, never reminted…
#expect(verdict.caseTwins == [
CaseTwin(path: "\(Ident.lane3)/\(Dup.upper)", winner: "\(Ident.lane1)/\(Dup.lower)"),
])
// …and only the second *canonically spelled* occurrence is healed.
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
/// The winner a case twin is named against is the earlier-occurrence winner, not merely the first
/// canonically spelled folder the walk met.
@Test("A case twin's named winner is the group's actual winner")
func caseTwinNamesTheRealWinner() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)),
occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower, birth: date(0)),
])
#expect(verdict.caseTwins.map(\.winner) == ["\(Ident.lane3)/\(Dup.lower)"])
#expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
/// **The spelling contest obeys the container preference too.** Without it the canonical-wins rule
/// would hand the identity to the lowercase ghost in the trash and *skip* the live card as a
/// spelling artifact — the straddle read backwards, and the one outcome the ruling forbids: the
/// visible card would vanish while its ghost rendered from the trash.
///
/// The twin stays a **twin**: skipped silently, preserved, never reminted. Only the winner
/// selection gained the container preference; the spelling-artifacts-stay-silent rule is unchanged.
@Test("An uppercase live occurrence beats a lowercase trashed twin")
func liveSpellingBeatsTrashedCanonicalSpelling() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(1000)),
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)),
])
#expect(verdict.duplicates.isEmpty, "a spelling artifact is never reminted, trashed or not")
#expect(verdict.caseTwins == [
CaseTwin(path: ".trash/\(Dup.lower)", winner: "\(Ident.lane1)/\(Dup.upper)"),
])
}
/// Among *live* spellings the ordinary rule still decides — the container filter narrows the
/// candidates, it does not replace canonical-else-lexicographic.
@Test("Among live spellings, canonical still wins")
func canonicalStillWinsAmongLiveSpellings() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(0)),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(1000)),
occurrence(".trash/\(Dup.mixed)", Dup.mixed, container: .trashed, birth: date(-1000)),
])
#expect(verdict.duplicates.isEmpty)
#expect(verdict.caseTwins.map(\.path).sorted() == [
".trash/\(Dup.mixed)",
"\(Ident.lane1)/\(Dup.upper)",
].sorted())
#expect(verdict.caseTwins.allSatisfy { $0.winner == "\(Ident.lane2)/\(Dup.lower)" })
}
/// A wholly trashed group has no live candidates, so the spelling contest is exactly what it was.
@Test("A wholly trashed group picks its spelling the old way")
func whollyTrashedSpellingUnchanged() {
let verdict = IntegrityRules.dedupe([
occurrence(".trash/\(Dup.upper)", Dup.upper, container: .trashed, birth: date(0)),
occurrence(".trash/nested/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(1000)),
])
#expect(verdict.duplicates.isEmpty)
#expect(verdict.caseTwins == [
CaseTwin(path: ".trash/\(Dup.upper)", winner: ".trash/nested/\(Dup.lower)"),
])
}
// MARK: Determinism and shape
/// **Exactly one survivor per identity, whatever the group looks like** — the invariant the whole
/// pass exists for, over a deliberately nasty group of five.
@Test("Every group keeps exactly one occurrence")
func exactlyOneSurvivor() {
let occurrences = [
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(50)),
occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper, birth: date(10)),
occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower, birth: date(20)),
occurrence("\(Ident.lane4)/\(Dup.mixed)", Dup.mixed, birth: date(0)),
occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(90)),
]
let verdict = IntegrityRules.dedupe(occurrences)
let lost = Set(verdict.duplicates.map(\.path)).union(verdict.caseTwins.map(\.path))
#expect(lost.count == 4)
#expect(occurrences.filter { !lost.contains($0.path) }.map(\.path) == ["\(Ident.lane3)/\(Dup.lower)"])
}
/// The output order is the traversal's, not a dictionary's — the notice's subjects and the log's
/// lines must read in board order, and the same input must produce the same answer every time.
@Test("Losers come back in traversal order, repeatably")
func traversalOrderedAndStable() {
let occurrences = [
occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower),
occurrence("\(Ident.lane1)/\(Dup.other)", Dup.other),
occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower),
occurrence("\(Ident.lane2)/\(Dup.other)", Dup.other),
occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower),
]
let expected = [
"\(Ident.lane2)/\(Dup.lower)",
"\(Ident.lane2)/\(Dup.other)",
"\(Ident.lane3)/\(Dup.lower)",
]
for _ in 0..<20 {
#expect(IntegrityRules.dedupe(occurrences).duplicates.map(\.path) == expected)
}
}
/// Identity comparison is UUID-*value* equality, so an occurrence's `identity` is its name folded
/// — and that is what the defect carries, never the verbatim spelling.
@Test("A duplicate's identity is canonical, never the folder's spelling")
func identityIsCanonical() {
let verdict = IntegrityRules.dedupe([
occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper),
occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper),
])
#expect(verdict.duplicates.map(\.identity) == [Dup.lower])
}
}
// MARK: - Fixtures
@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, Item.rich(order: "2048", title: "Doing"))
return fixture
}
/// Every identity the snapshot renders, canonically — lanes, their cards, and the trash.
private func renderedIdentities(_ model: BoardModel) -> [String] {
var identities: [String] = []
for lane in model.lanes {
identities.append(IntegrityRules.canonicalIdentity(lane.id.rawValue))
identities.append(contentsOf: lane.cards.map { IntegrityRules.canonicalIdentity($0.id.rawValue) })
}
identities.append(contentsOf: model.trash.map { IntegrityRules.canonicalIdentity($0.id.rawValue) })
return identities
}
private func setBirth(_ url: URL, _ when: Date) throws {
try FileManager.default.setAttributes([.creationDate: when], ofItemAtPath: url.path)
}
@MainActor
private final class BracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
@MainActor
private final class StepLog: HistoryProviding {
var canUndo = false
var canRedo = false
var undoActionName: String?
var redoActionName: String?
private(set) var registered: [String] = []
func register(_ step: HistoryStep) { registered.append(step.name) }
func undo() {}
func redo() {}
func clear() {}
}
// MARK: - Detection, through the real loader
/// Detection is **read-only in the loader** — the walk reports and withholds, the store acts.
@MainActor
@Suite("Duplicate ids ▸ detection")
struct DuplicateIdentityDetectionTests {
@Test("A card hand-copied into another lane loads once, and the copy is reported")
func copiedCardLoadsOnce() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
let result = try BoardLoader.load(boardRoot: fixture.root)
// One occurrence per id, board-wide — the invariant.
#expect(renderedIdentities(result.model).sorted() == renderedIdentities(result.model).sorted())
#expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.isEmpty == true)
#expect(result.duplicateIdentities == [
DuplicateIdentity(
path: "\(Ident.lane2)/\(Dup.lower)",
identity: Dup.lower,
title: "Fix login",
winner: "\(Ident.lane1)/\(Dup.lower)"
),
])
// Nothing was moved, nothing was written: the loader never writes.
#expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md"))
#expect(try fixture.indexText("\(Ident.lane2)/\(Dup.lower)") == Item.rich(order: "1024", title: "Fix login"))
}
/// A healthy board pays nothing for this pass and reports nothing — the gate.
@Test("A board with no duplicates reports none")
func healthyBoardIsQuiet() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane2)/\(Dup.other)", Item.rich(order: "1024", title: "Other"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty)
#expect(result.warnings.isEmpty)
#expect(result.duplicateIdentities.isEmpty)
}
/// **Board-wide spans both containers**: a card in the trash and a card in a lane sharing one id
/// would put two items with equal ids into one snapshot the moment either was rendered.
@Test("The trash counts — a lane card and a trash card sharing an id dedupe")
func trashCounts() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let trashed = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(0))
try setBirth(trashed, date(1000))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.isEmpty)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
// The trash's `kind` reading goes with the withheld entry: leaving it would stand as an answer
// about the *live* card, which is the ambiguity the dedupe exists to remove.
#expect(result.trashKinds.isEmpty)
}
/// **The restore-as-a-copy straddle, end to end** (01-storage-format.md § Fractal layout ▸ Rules,
/// stated 2026-07-29): the user ⌥-dragged a card out of the trash in Finder, so the ghost left
/// behind is the *older* folder. Earlier-entrant-wins alone would withhold the very card they just
/// restored and render its ghost from the trash; the container boundary is the first tie-break
/// precisely so that cannot happen.
@Test("A live card outranks its own older ghost in the trash")
func liveCardOutranksItsGhost() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let result = try BoardLoader.load(boardRoot: fixture.root)
// The restored card renders; the ghost is the withheld one.
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(result.model.trash.isEmpty)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
#expect(result.duplicateIdentities.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"])
}
/// The same straddle with git in the picture: an ⌥-drag restore leaves the *tracked* path in the
/// trash, so the ghost is the one history knows — and still loses.
@Test("A tracked ghost still loses to the untracked live card")
func trackedGhostStillLoses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == ".trash/\(Dup.lower)" ? 1 : nil
}
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
}
/// **The same preference governs a trashed lane sharing a live lane's UUID** — the ruling says so
/// explicitly, and it needs no special case: a trashed lane is a `.trash/` entry like any other.
@Test("A trashed lane loses to the live lane sharing its UUID")
func trashedLaneLosesToLiveLane() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let live = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo"))
// A trashed lane: a `.trash/` entry with identity-shaped children of its own.
let trashed = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Todo"))
try fixture.item(".trash/\(Dup.lower)/\(Dup.other)", Item.rich(order: "1024", title: "Nested"))
try setBirth(live, date(1000))
try setBirth(trashed, date(0))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map { $0.id.rawValue } == [Dup.lower])
#expect(result.model.trash.isEmpty)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
}
/// The case-twin straddle through the real loader: the live card is spelled uppercase, its ghost
/// canonically. The live card renders, the ghost is a **silent twin** — skipped, preserved, and
/// never reminted, so no heal is scheduled at all.
@Test("A live uppercase card outranks its canonically spelled ghost, silently")
func liveUppercaseBeatsCanonicalGhost() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(result.model.trash.isEmpty)
#expect(result.defects.isEmpty, "a spelling artifact is never work")
#expect(result.warnings == [
.caseTwinIgnored(path: ".trash/\(Dup.lower)", winner: "\(Ident.lane1)/\(Dup.upper)"),
])
#expect(fixture.exists(".trash/\(Dup.lower)/index.md"), "preserved verbatim")
}
/// **Levels are not identity namespaces.** A lane folder copied *into* a lane becomes a
/// card-level folder carrying the lane's id — one identity, two folders, two levels.
@Test("A lane and a card sharing an id dedupe across levels")
func crossLevelDuplicate() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let lane = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let nested = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Copied lane"))
try setBirth(lane, date(0))
try setBirth(nested, date(1000))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map { $0.id.rawValue }.sorted() == [Dup.lower, Ident.lane2].sorted())
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.isEmpty == true)
#expect(result.duplicateIdentities.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"])
}
/// **A withheld lane takes its subtree out of the snapshot** — and its cards are still occurrences
/// in their own right, so a collision nested inside a losing lane is its own withheld occurrence
/// with its own remint (the import boundary's finest-grain rule, read for the heal).
@Test("A withheld lane's cards are still their own occurrences")
func withheldLaneSubtreeStillCollides() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// `<root>/<L>` (a lane) and `<root>/<other lane>/<L>` (a card) share an identity; the lane
// loses, so its whole subtree leaves the snapshot.
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let nested = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Copied lane"))
let lane = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo"))
try setBirth(nested, date(0))
try setBirth(lane, date(1000))
// The losing lane's own card collides with a card in the surviving lane.
let insideLoser = try fixture.item("\(Dup.lower)/\(Dup.other)", Item.rich(order: "1024", title: "Nested"))
let insideWinner = try fixture.item("\(Ident.lane2)/\(Dup.other)", Item.rich(order: "2048", title: "Nested"))
try setBirth(insideWinner, date(0))
try setBirth(insideLoser, date(1000))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map { $0.id.rawValue } == [Ident.lane2])
// Both the losing lane *and* the collision nested inside it are withheld occurrences, each
// with its own remint to come.
#expect(Set(result.duplicateIdentities.map(\.path)) == [
Dup.lower,
"\(Dup.lower)/\(Dup.other)",
])
}
// MARK: Case twins
/// Case twins are the **tolerate** tier: a warning, not a defect — nothing to heal.
///
/// Placed under different parents deliberately: default APFS is case-insensitive, so two
/// case-spelled *siblings* cannot exist there at all.
@Test("A case-spelled twin is a silent stray, not a defect")
func caseTwinIsSilent() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty, "a spelling artifact is never work")
#expect(result.warnings == [
.caseTwinIgnored(path: "\(Ident.lane1)/\(Dup.upper)", winner: "\(Ident.lane2)/\(Dup.lower)"),
])
// The canonical spelling renders; the twin is preserved verbatim and never rendered.
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.isEmpty == true)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1)
#expect(fixture.exists("\(Ident.lane1)/\(Dup.upper)/index.md"))
}
@Test("With no canonical spelling, the lexicographically first renders")
func lexicographicWinnerRenders() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Dup.mixed)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane2)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1)
#expect(result.warnings.map(\.description) == [
"\(Ident.lane1)/\(Dup.mixed): case-spelled twin of \(Ident.lane2)/\(Dup.upper), ignored as a spelling artifact",
])
}
// MARK: The history seam
/// The seam base can never fill: an injected ranker decides the winner ahead of the birth dates.
@Test("An injected history ranker outranks the filesystem")
func historyRankerDecides() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
// Without a ranker the older folder wins…
#expect(try BoardLoader.load(boardRoot: fixture.root).duplicateIdentities.map(\.path)
== ["\(Ident.lane2)/\(Dup.lower)"])
// …and with one that says the newer path entered history first, it does not.
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == "\(Ident.lane2)/\(Dup.lower)" ? 1 : nil
}
#expect(try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker).duplicateIdentities.map(\.path)
== ["\(Ident.lane1)/\(Dup.lower)"])
}
/// Three copies of one card: two are withheld, one renders — and the notice folds.
@Test("Three occurrences leave one standing")
func threeCopiesLeaveOne() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
let first = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let second = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let third = try fixture.item("\(Ident.lane3)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(first, date(0))
try setBirth(second, date(100))
try setBirth(third, date(200))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.duplicateIdentities.map(\.path) == [
"\(Ident.lane2)/\(Dup.lower)",
"\(Ident.lane3)/\(Dup.lower)",
])
#expect(renderedIdentities(result.model).filter { $0 == Dup.lower }.count == 1)
}
}
// MARK: - The remint
/// The Writer's half — a rename, and nothing else.
@MainActor
@Suite("Duplicate ids ▸ the remint")
struct DuplicateIdentityRemintTests {
private func board() throws -> (WriterFixture, DuplicateIdentity) {
let fixture = try makeBoard()
try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
return (fixture, DuplicateIdentity(
path: "\(Ident.lane2)/\(Dup.lower)",
identity: Dup.lower,
title: "Fix login",
winner: "\(Ident.lane1)/\(Dup.lower)"
))
}
/// **A fresh lowercase v4, content untouched, nothing stamped.** The remint is an identity repair,
/// not an edit — the folder's `index.md` is never opened, so `modified` and `modified-by` are
/// exactly what they were.
@Test("It renames to a fresh lowercase v4 and leaves everything else alone")
func remintsToAFreshName() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
let before = try fixture.indexText("\(Ident.lane2)/\(Dup.lower)")
let minted = try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)
let fresh = try #require(minted?.rawValue)
#expect(fresh != Dup.lower)
#expect(fresh == fresh.lowercased(), "the app emits lowercase")
#expect(UUID(uuidString: fresh) != nil)
#expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)"), "the losing name is gone")
#expect(try fixture.indexText("\(Ident.lane2)/\(fresh)") == before, "byte-verbatim, no stamp")
// The winner never moved.
#expect(fixture.exists("\(Ident.lane1)/\(Dup.lower)/index.md"))
}
/// Children travel with the folder — the rename never opens anything inside it.
@Test("Attachments, strays and nested folders travel with the rename")
func childrenTravel() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane2)/\(Dup.lower)/attachments/shot.png", Data("png".utf8))
try fixture.file("\(Ident.lane2)/\(Dup.lower)/notes.txt", Data("loose".utf8))
let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue)
#expect(try fixture.data("\(Ident.lane2)/\(fresh)/attachments/shot.png") == Data("png".utf8))
#expect(try fixture.data("\(Ident.lane2)/\(fresh)/notes.txt") == Data("loose".utf8))
}
/// **The re-verify, first half** — the folder is gone. Losing the race to a foreign fix is
/// success, never an error.
@Test("A duplicate hand-deleted under the write is a no-op")
func vanishedFolderIsANoOp() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane2)/\(Dup.lower)"))
#expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil)
}
/// **The re-verify, second half** — the *winner* is gone, so this folder is no longer a duplicate
/// of anything and reminting it would change an identity for no reason at all.
@Test("A vanished winner makes the remint a no-op")
func vanishedWinnerIsANoOp() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Dup.lower)"))
#expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil)
#expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md"), "left exactly as it was")
}
/// Idempotent by the same re-verify: a second attempt on already-repaired work does nothing.
@Test("Reminting twice remints once")
func remintingTwiceRemintsOnce() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue)
#expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil)
#expect(fixture.exists("\(Ident.lane2)/\(fresh)/index.md"))
}
/// A case twin is never handed to this call by the loader — but if one were, the winner probe is
/// what still makes it safe: the twin *is* a second occurrence, so the identity check is on the
/// canonical value and the rename would be honest. What must not happen is a remint of a folder
/// whose spelling no longer matches the defect that named it.
@Test("A folder already reminted under us is not reminted again")
func alreadyRemintedIsANoOp() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
try fixture.moveFolder("\(Ident.lane2)/\(Dup.lower)", to: "\(Ident.lane2)/\(Dup.other)")
#expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil)
#expect(fixture.exists("\(Ident.lane2)/\(Dup.other)/index.md"))
}
/// A rename is a move as far as provenance goes, and **heal-marked**, because the app started it
/// on its own — the receipt is what splits it into its own commit on git boards.
@Test("It drops a heal-marked move receipt")
func dropsAHealMarkedReceipt() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
let ledger = EchoLedger()
let from = fixture.url("\(Ident.lane2)/\(Dup.lower)")
let fresh = try EchoLedger.$current.withValue(ledger) {
try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue
}
let to = fixture.url("\(Ident.lane2)/\(try #require(fresh))")
#expect(ledger.receipt(at: from) == .move(from: EchoLedger.key(from), to: EchoLedger.key(to)))
#expect(ledger.isHeal(at: to))
}
/// The fresh name is minted away from **every** identity in the board, not merely from this
/// parent's children: trading one duplicate for another would be the one unacceptable outcome.
@Test("The minted name collides with nothing board-wide")
func mintedNameIsBoardWideUnique() throws {
let (fixture, duplicate) = try board()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Dup.other)", Item.rich(order: "2048", title: "Other"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty, "no duplicate left to find")
#expect(renderedIdentities(result.model).contains(fresh))
#expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count)
}
}
// MARK: - The scheduled heal
/// The store's half — the six-step engine, a notice, and no consent gate anywhere (re-ruled
/// 2026-07-29: the user-gated Repair banner retired).
@MainActor
@Suite("Duplicate ids ▸ the scheduled heal")
struct DuplicateIdentityHealTests {
private func store() throws -> (WriterFixture, BoardStore) {
let fixture = try makeBoard()
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
return (fixture, try BoardStore(rootURL: fixture.root))
}
@Test("It remints the withheld occurrence and announces the repair")
func remintsAndAnnounces() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
let brackets = BracketLog()
brackets.attach(to: store)
#expect(store.duplicateIdentities.count == 1)
store.remintDuplicateIdentities()
#expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)"))
#expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"])
#expect(store.banners.oneShots.isEmpty, "nothing failed")
#expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit")
}
/// The whole point of the re-ruling: the withheld window is **one heal cycle**, not a standing
/// condition. After the heal, the reload renders both cards under distinct ids.
@Test("The reload after the heal renders both cards and finds no defect")
func theWindowClosesAfterOneCycle() async throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
store.remintDuplicateIdentities()
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(store.duplicateIdentities.isEmpty)
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1)
#expect(Set(renderedIdentities(store.snapshot)).count == renderedIdentities(store.snapshot).count)
}
@Test("A reload fires it")
func aReloadFiresIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let store = try BoardStore(rootURL: fixture.root)
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)"))
#expect(store.banners.losses.count == 1)
}
/// **Defer, never abandon**: a read-only board writes nothing *and remembers nothing*, so the
/// reload that lifts the lock is the reload that heals.
@Test("A read-only board defers it")
func lockDefersIt() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
store.enterUnwritableLock(.permissionDenied)
store.remintDuplicateIdentities()
#expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md"))
#expect(store.banners.losses.isEmpty)
#expect(store.heals.memo(for: .duplicateIdentity) == nil, "deferred, not remembered")
}
/// **A repeat says nothing more**, and it is the disk re-verify rather than the memo that makes it
/// so: the memo *clears on success* (that is load-bearing — see below), so a second call against a
/// defect list the reload has not replaced yet does open a bracket. What it must not do is rename
/// anything again or claim a second repair, and the Writer's re-verify is what guarantees both.
@Test("A repeat attempt on already-repaired work says nothing more")
func repeatingIsHarmless() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
store.remintDuplicateIdentities()
let after = try fixture.entryNames(Ident.lane2)
store.remintDuplicateIdentities()
#expect(try fixture.entryNames(Ident.lane2) == after, "nothing renamed a second time")
#expect(store.banners.losses.count == 1, "and nothing claimed twice")
#expect(store.banners.oneShots.isEmpty)
}
/// Success clears the memo, which is what lets a *later* duplicate — the same picture restored by
/// a foreign undo, a second hand copy — be healed again immediately.
@Test("Success clears the memo")
func successClearsTheMemo() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
store.remintDuplicateIdentities()
#expect(store.heals.memo(for: .duplicateIdentity) == nil)
}
/// The no-op heal: the duplicate resolved itself between the load and the write. Nothing moves,
/// nothing is said, nothing fails.
@Test("A duplicate that vanished before the write is a silent no-op")
func vanishedDuplicateIsASilentNoOp() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
#expect(store.duplicateIdentities.count == 1)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane2)/\(Dup.lower)"))
store.remintDuplicateIdentities()
#expect(store.banners.losses.isEmpty, "nothing was claimed to have been repaired")
#expect(store.banners.oneShots.isEmpty)
}
/// **Not undoable** (13-native-undo.md, re-ruled 2026-07-29): heals are not gestures, so nothing
/// enters the stack — and undoing a remint would recreate the duplicate it exists to remove.
@Test("It registers no undo step")
func registersNoUndoStep() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
let steps = StepLog()
store.history = steps
store.remintDuplicateIdentities()
#expect(steps.registered.isEmpty)
}
/// A hand-copied lane's worth of collisions arrive together and heal together, in one bracket,
/// under one folded notice.
@Test("Several duplicates heal in one bracket under one folded notice")
func severalHealTogether() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
for (index, id) in [Dup.lower, Dup.other, Ident.card3].enumerated() {
let original = try fixture.item("\(Ident.lane1)/\(id)", Item.rich(order: "\(1024 * (index + 1))", title: "Card \(index)"))
let copy = try fixture.item("\(Ident.lane2)/\(id)", Item.rich(order: "\(1024 * (index + 1))", title: "Card \(index)"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
}
let store = try BoardStore(rootURL: fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
#expect(store.duplicateIdentities.count == 3)
store.remintDuplicateIdentities()
#expect(brackets.begins == 1)
#expect(store.banners.losses.map(\.message) == ["Repaired 3 duplicate ids"])
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 3)
}
/// Case twins reach no heal at all: the store's defect stream never carries one, so the engine
/// rests and the folders stay exactly where they are.
@Test("A case twin schedules nothing")
func caseTwinSchedulesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let store = try BoardStore(rootURL: fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
store.remintDuplicateIdentities()
#expect(brackets.begins == 0, "no work, no bracket")
#expect(store.banners.losses.isEmpty)
#expect(fixture.exists("\(Ident.lane1)/\(Dup.upper)/index.md"))
}
/// **The straddle healed end to end**: the ghost in the trash is the withheld occurrence, so it is
/// the ghost that gets reminted — the restored card keeps the identity it was restored with, and
/// the trash ends up holding an ordinary unrelated entry.
@Test("The ghost is the one reminted, never the restored card")
func theGhostIsReminted() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let store = try BoardStore(rootURL: fixture.root)
store.remintDuplicateIdentities()
#expect(fixture.exists("\(Ident.lane1)/\(Dup.lower)/index.md"), "the restored card never moved")
#expect(!fixture.exists(".trash/\(Dup.lower)"))
#expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"])
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.defects.isEmpty)
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
#expect(result.model.trash.count == 1)
#expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count)
}
/// It rides `runScheduledHeals()` like every other scheduled heal — the reload tail and board open.
@Test("runScheduledHeals includes it")
func runScheduledHealsIncludesIt() throws {
let (fixture, store) = try store()
defer { fixture.tearDown() }
store.runScheduledHeals()
#expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)"))
#expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"])
}
}
// MARK: - Phrasing
@Suite("Duplicate ids ▸ phrasing")
struct DuplicateIdentityPhrasingTests {
/// The design's own sentence, verbatim.
@Test("One remint names the item")
func oneRemintNamesTheItem() {
#expect(BannerCenter.remintedDuplicateIDsMessage(for: ["Fix login"])
== "Repaired duplicate id — 'Fix login'")
}
@Test("An untitled item takes the untitled rendering")
func untitled() {
#expect(BannerCenter.remintedDuplicateIDsMessage(for: [nil])
== "Repaired duplicate id — an untitled item")
}
@Test("Several fold to a count")
func severalFold() {
#expect(BannerCenter.remintedDuplicateIDsMessage(for: ["Fix login", nil, "Ship it"])
== "Repaired 3 duplicate ids")
}
@Test("Nothing reminted says nothing")
func nothingSaysNothing() {
#expect(BannerCenter.remintedDuplicateIDsMessage(for: []) == nil)
}
/// It rides the **loss-row** class: warning tone, user-dismissed, never expiring — the
/// relocation's class, because it is the same kind of event. Emphatically not a condition banner
/// with a button: that is the surface the 2026-07-29 re-ruling retired.
@Test("It rides the loss-row class, with no condition and no button")
@MainActor
func ridesTheLossClass() {
let banners = BannerCenter()
banners.postRemintedDuplicateIDs(["Fix login"])
#expect(banners.losses.count == 1)
#expect(banners.oneShots.isEmpty)
#expect(banners.signposts.isEmpty)
}
/// The failure's mirror, in the one-shot vocabulary the Writer's errors reach the strip through.
@Test("A failed remint says so")
func failureSaysSo() {
let error = BoardWriteError(
operation: .repairDuplicateID(title: "Fix login"),
path: "/b/lane/card",
reason: .io(message: "permission denied")
)
#expect(BannerCenter.headline(for: error)
== "Couldn't repair the duplicate id of 'Fix login' — permission denied")
}
@Test("An untitled failure stays graceful")
func untitledFailure() {
let error = BoardWriteError(
operation: .repairDuplicateID(title: nil),
path: "/b/lane/card",
reason: .io(message: "disk full")
)
#expect(BannerCenter.headline(for: error) == "Couldn't repair a duplicate id — disk full")
}
/// The Repair verb survives the re-ruling as the operation's own word — 06-history-undo.md keeps
/// it as the heal commit's name.
@Test("The operation describes itself with the Repair verb")
func operationDescribesItself() {
#expect(WriteOperation.repairDuplicateID(title: "Fix login").description
== "repair the duplicate id of 'Fix login'")
}
/// The remint never opens an `index.md`, so there is no document to enrich its title from — it
/// arrives already filled in from the load.
@Test("withTitle leaves it alone")
func withTitleIsIdentity() {
#expect(WriteOperation.repairDuplicateID(title: "Fix login").withTitle("Something else")
== .repairDuplicateID(title: "Fix login"))
}
}