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 infeae6d0, 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
1199 lines
58 KiB
Swift
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"))
|
|
}
|
|
}
|