The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
513 lines
24 KiB
Swift
513 lines
24 KiB
Swift
import Foundation
|
|
import SwiftGitX
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// **The board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
|
/// ▸ HistoryStore) — composed under the tier, detected at open, and changed afterwards by exactly
|
|
/// one thing.
|
|
///
|
|
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
|
|
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
|
|
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
|
|
/// could not tell whether that happened. Nothing in this file shells out to `git` — there is no
|
|
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// A board with one lane and one card — small, and enough for a tree with three `index.md`s in it.
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
return fixture
|
|
}
|
|
|
|
/// A `.git` that is not a repository — a directory with a plausible `HEAD` in it. Enough for
|
|
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
|
|
/// which is how a test can tell the two apart.
|
|
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
|
|
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
|
|
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
|
|
}
|
|
|
|
/// A second (third, fourth) commit, made the way an external writer makes one — SwiftGitX directly,
|
|
/// not through the app, which has no commit surface until the auto-commit card.
|
|
private func commitEverything(at boardRoot: URL, message: String) throws {
|
|
let repository = try Repository.open(at: boardRoot)
|
|
try repository.add(paths: [])
|
|
_ = try repository.commit(message: message)
|
|
}
|
|
|
|
/// Bytes and mtimes of everything under a subtree — `InertGitTests`' instrument, in the shape this
|
|
/// file needs it: what proves that *reading* a board's mode touched nothing.
|
|
private struct SubtreeEntry: Equatable {
|
|
let path: String
|
|
let data: Data?
|
|
let modified: Date
|
|
}
|
|
|
|
private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
|
|
let base = root.appendingPathComponent(".git", isDirectory: true)
|
|
let manager = FileManager.default
|
|
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
|
|
|
|
var entries: [SubtreeEntry] = []
|
|
for case let relative as String in walker {
|
|
let url = base.appendingPathComponent(relative)
|
|
let attributes = try manager.attributesOfItem(atPath: url.path)
|
|
guard let modified = attributes[.modificationDate] as? Date else { continue }
|
|
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
|
|
entries.append(SubtreeEntry(
|
|
path: relative,
|
|
data: isDirectory ? nil : try Data(contentsOf: url),
|
|
modified: modified
|
|
))
|
|
}
|
|
return entries.sorted { $0.path < $1.path }
|
|
}
|
|
|
|
// MARK: - Composition
|
|
|
|
@MainActor
|
|
@Suite("HistoryStore ▸ composition and the tier gate")
|
|
struct HistoryStoreCompositionTests {
|
|
|
|
@Test("The free tier composes no git state at all, on any board")
|
|
func theFreeTierComposesNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try plantGitDirectory(in: fixture)
|
|
|
|
// Not "mode none on a git board" — *nothing*. With no object there is no path by which a
|
|
// free-tier session could read history, commit, or touch `.git` (12-editions.md ▸ The free
|
|
// tier and `.git`, whose byte-level half is `InertGitTests`).
|
|
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
|
|
}
|
|
|
|
@Test("Pro on a plain board is mode none — and opening one never creates a repository")
|
|
func proOnAPlainBoardIsModeNone() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(git.mode == .none)
|
|
|
|
// "No silent auto-init, ever" (06 ▸ Rules) — the deliberate pivot from the pathfinder, which
|
|
// initialized a repository under every board it opened. Composing twice is the whole test:
|
|
// two opens, no repository.
|
|
_ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro)
|
|
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
|
|
}
|
|
|
|
@Test("A board whose root already has a repository opens in git mode, silently")
|
|
func adoptionNeedsNoStep() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
|
|
// add-git in a previous session), and the repository is simply there.
|
|
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await first.addGit())
|
|
let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
|
|
let before = try snapshotGitDirectory(fixture.root)
|
|
|
|
// The next open. Adoption is not init: no dialog, no confirmation, no second step — the mode
|
|
// is simply what the filesystem says, and it says git.
|
|
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(second.mode == .git)
|
|
|
|
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
|
|
// Composition is a `stat`, not an operation.
|
|
#expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
|
|
#expect(try snapshotGitDirectory(fixture.root) == before)
|
|
}
|
|
|
|
@Test("A board nested inside a repository opens repo-nested")
|
|
func nestedBoardsAreDetectedAsNested() throws {
|
|
let outer = try WriterFixture()
|
|
defer { outer.tearDown() }
|
|
try plantGitDirectory(in: outer)
|
|
|
|
let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
|
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
|
|
#expect(git.mode == .repoNested)
|
|
}
|
|
|
|
@Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
|
|
func noMidSessionDiscoveredFlip() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(git.mode == .none)
|
|
|
|
// What a `git init` in a terminal under an open board does — which is nothing, until the
|
|
// next open (06 ▸ Rules: "the running session keeps its mode, and the watcher does not scan
|
|
// for `.git` appearing").
|
|
try plantGitDirectory(in: fixture)
|
|
#expect(git.mode == .none, "the open session keeps the mode it composed with")
|
|
|
|
let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(nextOpen.mode == .git, "and the next open reflects what it finds")
|
|
}
|
|
}
|
|
|
|
// MARK: - Add git
|
|
|
|
@MainActor
|
|
@Suite("HistoryStore ▸ add-git")
|
|
struct HistoryStoreAddGitTests {
|
|
|
|
@Test("Add-git initializes a repository at the board root and commits the whole tree")
|
|
func addGitInitializesAndCommits() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
|
|
#expect(git.mode == .git, "the one commanded mid-session flip")
|
|
|
|
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
|
// "The root commit has its own subject" (06 ▸ Rules ▸ Abnormal repo states) — never a folded
|
|
// diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
|
|
#expect(head.subject == "Initial board state")
|
|
#expect(head.parentCount == 0, "it is the root commit")
|
|
|
|
// The whole tree, not a hand-picked set: the board, the lane, the card.
|
|
let tracked = GitRepository.trackedPaths(at: fixture.root)
|
|
#expect(tracked.contains("index.md"))
|
|
#expect(tracked.contains("\(Ident.lane1)/index.md"))
|
|
#expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
|
|
}
|
|
|
|
@Test("Add-git seeds a `.gitignore` into the initial commit")
|
|
func addGitSeedsTheIgnoreFile() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
// The one file the app ever writes into a board because of git, and the one moment it writes
|
|
// it (06 ▸ Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule — the
|
|
// untouched existing file, the `.DS_Store` that never enters history, adoption seeding
|
|
// nothing; here it is only the fact that add-git's tree includes it.
|
|
#expect(fixture.exists(".gitignore"))
|
|
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
|
|
}
|
|
|
|
@Test("The root commit is authored by the derived default when the repo names nobody")
|
|
func theRootCommitCarriesTheDerivedIdentity() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
let derived = GitIdentity.derivedDefault()
|
|
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
|
#expect(head.authorName == derived.name)
|
|
#expect(head.authorEmail == derived.email)
|
|
}
|
|
|
|
@Test("The branch is deterministic, and the popover's display line reads it")
|
|
func theBranchIsMainAndReadable() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
// Deterministic rather than inherited: libgit2's initial branch comes from an
|
|
// `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
|
|
#expect(git.branch == "main")
|
|
#expect(GitRepository.branchName(at: fixture.root) == "main")
|
|
|
|
await git.refreshBranch()
|
|
#expect(git.branch == "main")
|
|
}
|
|
|
|
@Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
|
|
func anUnbornHeadIsNormal() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
// `git init` and nothing else: the shape an adopted repository can genuinely be in
|
|
// (06 ▸ Rules ▸ Abnormal repo states: "an unborn HEAD is normal git mode").
|
|
_ = try Repository.create(at: fixture.root)
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(git.mode == .git)
|
|
#expect(GitRepository.branchName(at: fixture.root) != nil)
|
|
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
|
|
}
|
|
|
|
@Test("Add-git refuses a board that already has a repository, and changes nothing")
|
|
func addGitRefusesAnAdoptedBoard() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await first.addGit())
|
|
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
|
|
|
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await second.addGit() == false, "there is nothing to add")
|
|
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
|
|
}
|
|
|
|
@Test("Add-git refuses a repo-nested board — no nested repository, ever")
|
|
func addGitRefusesANestedBoard() async throws {
|
|
let outer = try WriterFixture()
|
|
defer { outer.tearDown() }
|
|
try plantGitDirectory(in: outer)
|
|
|
|
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
|
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
|
|
#expect(await git.addGit() == false)
|
|
#expect(git.mode == .repoNested)
|
|
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
|
|
}
|
|
|
|
@Test("A refused add-git says why, in libgit2's words where it has any")
|
|
func aRefusalIsExplained() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try plantGitDirectory(in: fixture)
|
|
|
|
// Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
|
|
// popover would show comes from the layer that *would* have run it.
|
|
let failure = GitRepository.create(at: fixture.root)
|
|
guard case .failure(let reason) = failure else {
|
|
Issue.record("initializing over an existing repository must be refused")
|
|
return
|
|
}
|
|
#expect(reason.operation == "Adding git to this board")
|
|
#expect(!reason.message.isEmpty)
|
|
}
|
|
|
|
@Test("Create re-runs full detection and refuses a board that became repo-nested")
|
|
func createRefusesAStaleModeNone() async throws {
|
|
// **The hardening** (06 ▸ Rules ▸ Detection, ruled 2026-07-31): "add-git's create re-runs full
|
|
// detection and refuses unless it reads clean none, so the forbidden nested init is impossible
|
|
// even on a raced or stale read."
|
|
let outer = try WriterFixture()
|
|
defer { outer.tearDown() }
|
|
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
|
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
|
|
|
// Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
|
|
// that is the reading that goes stale.
|
|
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
|
|
#expect(git.mode == .none)
|
|
|
|
// A terminal `git init` one level up, after the detection the store is holding.
|
|
try plantGitDirectory(in: outer)
|
|
|
|
#expect(await git.addGit() == false, "a root-only check would have let this through")
|
|
#expect(
|
|
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
|
|
"no nested repository, ever"
|
|
)
|
|
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
|
|
}
|
|
|
|
@Test("A failure answers at the form when it is up, and at the banner when it is not")
|
|
@MainActor
|
|
func aFailureAnswersAtTheFormOrTheBanner() async throws {
|
|
// **Form-anchored operations answer at the form first** (06 ▸ Interaction with external
|
|
// writers, ruled 2026-07-31) — "inline is the primary surface, never a silence trap".
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
// Mode is read once, at composition — so a store composed before a `.git` appeared still says
|
|
// `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
|
|
// question is where the answer lands.
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
try plantGitDirectory(in: fixture)
|
|
var banners: [String] = []
|
|
git.reportFailure = { banners.append($0.message) }
|
|
|
|
// The form is up: inline, and nothing on the strip.
|
|
git.noteFormVisible(true)
|
|
#expect(await git.addGit() == false)
|
|
#expect(git.lastFailure != nil)
|
|
#expect(banners.isEmpty, "the user is looking at the form the answer belongs in")
|
|
|
|
// Dismissing it dismisses the stale error.
|
|
git.noteFormVisible(false)
|
|
#expect(git.lastFailure == nil)
|
|
|
|
// Asked again with no form on screen, the answer takes the banner instead of nobody.
|
|
#expect(await git.addGit() == false)
|
|
#expect(git.lastFailure == nil)
|
|
#expect(banners.count == 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - The loader's history ranker
|
|
|
|
@MainActor
|
|
@Suite("HistoryStore ▸ the git-backed identity ranker")
|
|
struct GitPathHistoryTests {
|
|
|
|
@Test("A path that entered history earlier ranks lower; an untracked one has no rank")
|
|
func ranksFollowHistory() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
// A second card, arriving in a later commit — the whole point of the rung.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
try commitEverything(at: fixture.root, message: "Add card 'Second'")
|
|
|
|
// A third, on disk but never committed.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
|
|
|
let ranker = try #require(git.identityHistoryRanker)
|
|
let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
|
|
let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))
|
|
|
|
#expect(first < second, "lower is earlier")
|
|
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
|
|
#expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
|
|
}
|
|
|
|
@Test("No ranker where the app manages no git")
|
|
func noRankerWithoutARepository() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
|
|
|
|
// And the free tier has no `HistoryStore` to ask in the first place — pinned in the
|
|
// composition suite above.
|
|
}
|
|
|
|
@Test("Git history decides a real duplicate-id collision through the loader")
|
|
func historyDecidesTheDuplicateWinner() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
|
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await git.addGit())
|
|
|
|
// The same identity, arriving later in a second lane — the copy the duplicate rule is about.
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
|
|
try commitEverything(at: fixture.root, message: "Copy the card")
|
|
|
|
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
|
|
let duplicates: [DuplicateIdentity] = result.defects.compactMap {
|
|
if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
|
|
return nil
|
|
}
|
|
|
|
let duplicate = try #require(duplicates.first)
|
|
#expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
|
|
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
|
|
}
|
|
}
|
|
|
|
// MARK: - Session composition
|
|
|
|
@MainActor
|
|
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
|
let folder = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
let model = AppModel(
|
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
|
)
|
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
|
}
|
|
|
|
@MainActor
|
|
@discardableResult
|
|
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
|
let ref = BoardWindowRef(url: url)
|
|
let recordID = model.boardRegistry.recordOpen(of: url)
|
|
let store = try model.storeRegistry.acquire(url)
|
|
model.boardRegistry.setOpenNow(id: recordID)
|
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
|
return ref
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("Board sessions ▸ the git state they compose")
|
|
struct BoardSessionGitTests {
|
|
|
|
@Test("A free-tier session carries no git state, even on a board that has a repository")
|
|
func freeSessionsCarryNoGitState() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try plantGitDirectory(in: fixture)
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
model.currentTier = { .free }
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let session = try #require(model.session(for: ref))
|
|
|
|
#expect(session.git == nil)
|
|
#expect(session.gitMode == .none, "the free tier ships exactly one mode")
|
|
#expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader")
|
|
}
|
|
|
|
@Test("A Pro session on a git board composes git mode and wires the loader's ranker")
|
|
func proSessionsCarryTheDetectedMode() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
// A real repository, so the ranker has something to read.
|
|
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
|
#expect(await seed.addGit())
|
|
|
|
model.currentTier = { .pro }
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let session = try #require(model.session(for: ref))
|
|
|
|
#expect(session.gitMode == .git)
|
|
let provider = try #require(session.store.makeIdentityHistoryRanker)
|
|
let ranker = try #require(provider())
|
|
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
|
|
|
|
// The provider binding arrived with the undo/redo card: a Pro session on a git board binds
|
|
// the git substrate over exactly this mode (12-editions.md ▸ The provider seam).
|
|
#expect(session.history is GitHistoryProvider)
|
|
}
|
|
|
|
@Test("A Pro session on a plain board is mode none and injects nothing")
|
|
func proSessionsOnPlainBoardsInjectNothing() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
|
|
model.currentTier = { .pro }
|
|
let ref = try openBoard(model, at: fixture.root)
|
|
let session = try #require(model.session(for: ref))
|
|
|
|
#expect(session.gitMode == .none)
|
|
let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
|
|
#expect(provider() == nil, "and it answers nothing on a board with no repository")
|
|
}
|
|
}
|