Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):
- AppGroup namespace: container resolution with per-edition fallback
when unprovisioned, shared UserDefaults suite, edition identity, and
a unit-test-host redirect (the test host IS the app — its launch
sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
openNow keyed by bundle id; hand-written Codable keeps legacy keys
decoding (adopted in memory as the running edition's slots, upgraded
on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
first click runs an open panel pre-anchored at the recorded path,
prompt "Grant"; recordOpen mints this edition's slot onto the
matched shared record (path fallback only after identity fails and
only against records holding no grant of ours, so re-granting never
forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
registry when the sibling edition wrote it, so one edition's save
never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
popover gains BoardEditionPresence ("Also open in Lanework Pro"),
pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
claims doomed trees by atomic rename into .sweeping/ then deletes,
so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
re-ruling; scalars (quick-style recents, window size) move to the
shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
family group). No pathfinder 1.x migrator: 1.x predates the
registry; state starts fresh in the group container.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
324 lines
15 KiB
Swift
324 lines
15 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The card window's shell is mostly window plumbing, which a unit test cannot see. What it *can*
|
||
/// see is the three seams that plumbing hangs off, and each of them is a thing that fails silently:
|
||
/// a subtitle that stops following its card reads as correct until the card moves, a sidebar width
|
||
/// written down in points looks fine until the system text size changes, and a second window for one
|
||
/// card looks like a window that simply did not focus.
|
||
///
|
||
/// So the seams are pure functions and values, and this is their suite. The dismissal verdict —
|
||
/// vanished, tombstoned, ancestor-tombstoned, cross-board — is `CardWindowFateTests`'.
|
||
|
||
// MARK: - Fixtures
|
||
|
||
/// A two-lane board with the card in the first one, plus an untitled lane for the placeholder case.
|
||
///
|
||
/// - "Todo": one live card
|
||
/// - "Doing": empty, the card's destination
|
||
/// - untitled: empty
|
||
@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.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||
try fixture.item(Ident.lane3, "---\nschema: 1\norder: 3072\n---\nno title here\n")
|
||
return fixture
|
||
}
|
||
|
||
private func snapshot(of fixture: WriterFixture) throws -> BoardModel {
|
||
try BoardLoader.load(boardRoot: fixture.root).model
|
||
}
|
||
|
||
/// Exactly what `CardWindowHost.windowSubtitle` composes — the fate's lane, through the subtitle
|
||
/// rule — so this helper cannot pass while the window shows something else.
|
||
@MainActor
|
||
private func subtitle(forCard cardID: String, boardNamed board: String, in model: BoardModel) -> String? {
|
||
guard case let .shows(placement) = CardWindowHost.cardWindowFate(cardID: cardID, in: model) else {
|
||
return nil
|
||
}
|
||
return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
|
||
}
|
||
|
||
/// A registry whose file lives in temp rather than in the shared App Group container.
|
||
@MainActor
|
||
private struct RegistryStorage {
|
||
let folder: URL
|
||
|
||
var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }
|
||
|
||
init() throws {
|
||
folder = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("CardWindowShellTests-\(UUID().uuidString)", isDirectory: true)
|
||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||
}
|
||
|
||
func tearDown() {
|
||
try? FileManager.default.removeItem(at: folder)
|
||
}
|
||
}
|
||
|
||
// MARK: - Subtitle
|
||
|
||
@MainActor
|
||
@Suite("Card window subtitle")
|
||
struct CardWindowSubtitleTests {
|
||
|
||
@Test("The subtitle is ⟨board⟩ › ⟨lane⟩")
|
||
func subtitleNamesBoardAndLane() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
#expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo")
|
||
}
|
||
|
||
@Test("The subtitle follows the card between lanes")
|
||
func subtitleFollowsTheCard() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
#expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo")
|
||
|
||
// A lane move, as the disk sees it: the card's folder changes parent, its UUID does not.
|
||
// The window's key names neither lane, so the window stays — and the subtitle is the one
|
||
// part of it that has to notice.
|
||
try FileManager.default.moveItem(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
|
||
)
|
||
|
||
#expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Doing")
|
||
}
|
||
|
||
@Test("An untitled lane subtitles with the placeholder, never with a blank")
|
||
func anUntitledLaneRendersThePlaceholder() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
try FileManager.default.moveItem(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
to: fixture.url("\(Ident.lane3)/\(Ident.card1)")
|
||
)
|
||
|
||
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — a subtitle reading
|
||
// "Work › " would look like a bug in the app rather than a lane nobody has named.
|
||
#expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Untitled")
|
||
}
|
||
|
||
@Test("A card with no window has no subtitle to compose")
|
||
func aDismissedCardHasNoSubtitle() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
|
||
#expect(subtitle(forCard: Ident.card4, boardNamed: "Work", in: try snapshot(of: fixture)) == nil)
|
||
}
|
||
|
||
@Test("The board half is whatever the board is currently called")
|
||
func theBoardHalfIsTheDisplayName() {
|
||
// `AppModel.displayName(of:)` is what the host passes in — title, falling back to the folder
|
||
// name — so a board rename lands here for free. The rule itself is `AppModelTests`'.
|
||
#expect(CardWindowHost.subtitle(board: "Renamed", lane: "Todo") == "Renamed › Todo")
|
||
#expect(CardWindowHost.subtitle(board: "Work", lane: nil) == "Work › Untitled")
|
||
}
|
||
}
|
||
|
||
// MARK: - Metrics
|
||
|
||
@Suite("Card window metrics")
|
||
struct CardWindowMetricsTests {
|
||
|
||
@Test("The sidebar's width is a character count in the body font")
|
||
func sidebarWidthIsDerivedFromFontMetrics() {
|
||
// 26 characters at half an em, plus a one-em gutter on each side. Pinned at one point size
|
||
// so a change to the derivation has to be a deliberate one: 26 × 0.5 × 16 + 2 × 16 = 240.
|
||
#expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 16) == 240)
|
||
#expect(
|
||
CardWindowMetrics.sidebarWidth(bodyPointSize: 16)
|
||
== CardWindowMetrics.columnWidth(characters: CardWindowMetrics.sidebarCharacters, bodyPointSize: 16)
|
||
)
|
||
}
|
||
|
||
@Test("The sidebar scales with the body font, in both directions")
|
||
func sidebarWidthScalesWithTheFont() {
|
||
// The whole point of deriving it: at the largest system text sizes the sidebar grows with
|
||
// the text it holds instead of truncating everything in it (10-accessibility.md ▸ Text).
|
||
let small = CardWindowMetrics.sidebarWidth(bodyPointSize: 11)
|
||
let standard = CardWindowMetrics.sidebarWidth(bodyPointSize: 13)
|
||
let large = CardWindowMetrics.sidebarWidth(bodyPointSize: 24)
|
||
|
||
#expect(small < standard)
|
||
#expect(standard < large)
|
||
// And it is the same width every time it is asked, which is what "fixed width" means here:
|
||
// the window's resize flex goes to the body, never to this column.
|
||
#expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 13) == standard)
|
||
}
|
||
|
||
@Test("The sidebar is the narrow column and the body is the wide one")
|
||
func theSidebarIsNarrowerThanTheBodysFloor() {
|
||
for size in [11.0, 13.0, 18.0, 24.0] as [CGFloat] {
|
||
#expect(
|
||
CardWindowMetrics.sidebarWidth(bodyPointSize: size)
|
||
< CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size),
|
||
"even squeezed to its floor, the body column stays the wide one"
|
||
)
|
||
}
|
||
}
|
||
|
||
@Test("The window's minimum is the sidebar plus the body's floor")
|
||
func theWindowMinimumIsTheSumOfTheColumns() {
|
||
let size: CGFloat = 13
|
||
#expect(
|
||
CardWindowMetrics.minimumSize(bodyPointSize: size).width
|
||
== CardWindowMetrics.sidebarWidth(bodyPointSize: size)
|
||
+ CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size),
|
||
"the minimum is what the two columns need, not a number somebody liked"
|
||
)
|
||
#expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0)
|
||
}
|
||
|
||
@Test("A first card window opens larger than the minimum")
|
||
func theDefaultSizeIsRoomier() {
|
||
let size: CGFloat = 13
|
||
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size)
|
||
let initial = CardWindowMetrics.defaultSize(bodyPointSize: size)
|
||
|
||
#expect(initial.width > minimum.width)
|
||
#expect(initial.height > minimum.height)
|
||
}
|
||
}
|
||
|
||
// MARK: - The body column's mode
|
||
|
||
/// 05-card-window.md ▸ Mode grammar, as the two rules a unit test can hold: **Preview is the resting
|
||
/// state unless the body is empty**, and **the flip is one flip** whichever key produced it.
|
||
///
|
||
/// Both are silent failures of exactly the kind this file exists for. A card that opened in Preview
|
||
/// with an empty body would look like a working window showing nothing, and the user would have to
|
||
/// discover ⌘E to write the first word of a card they just made — which is the ceremony the rule was
|
||
/// settled to remove ("a new card has nothing to preview, so ⌘↩ during creation flows title → body
|
||
/// without a mode stop").
|
||
@MainActor
|
||
@Suite("Card body mode")
|
||
struct CardBodyModeTests {
|
||
|
||
@Test("A card with a body opens in Preview")
|
||
func aCardWithABodyOpensInPreview() {
|
||
#expect(CardBodyMode.opening(body: "# Notes\n") == .preview)
|
||
#expect(CardBodyMode.opening(body: "x") == .preview)
|
||
}
|
||
|
||
@Test("An empty body opens straight into Edit — whitespace included")
|
||
func anEmptyBodyOpensInEdit() {
|
||
#expect(CardBodyMode.opening(body: "") == .edit)
|
||
#expect(CardBodyMode.opening(body: "\n") == .edit)
|
||
// A card the app itself just minted has exactly this body: `BoardWriter.newDocumentText`
|
||
// writes frontmatter and nothing after the closing delimiter.
|
||
#expect(CardBodyMode.opening(body: " \n\t\n") == .edit)
|
||
}
|
||
|
||
@Test("⌘E, Return in Preview and Escape in Edit are one flip")
|
||
func theToggleIsSymmetric() {
|
||
#expect(CardBodyMode.preview.toggled == .edit)
|
||
#expect(CardBodyMode.edit.toggled == .preview)
|
||
#expect(CardBodyMode.preview.toggled.toggled == .preview)
|
||
}
|
||
|
||
@Test("The opening rule runs once, not on every snapshot")
|
||
func theOpeningRuleIsAppliedOnce() {
|
||
let presentation = CardBodyPresentation()
|
||
#expect(presentation.openIfNeeded(body: "# Notes\n") == .preview)
|
||
|
||
// The user presses ⌘E …
|
||
presentation.toggleMode()
|
||
#expect(presentation.mode == .edit)
|
||
|
||
// … and a watcher reload arrives with the same body. Re-deciding here would throw the user
|
||
// out of the surface they just asked for.
|
||
#expect(presentation.openIfNeeded(body: "# Notes\n") == .edit)
|
||
#expect(presentation.mode == .edit)
|
||
}
|
||
|
||
@Test("A window that opened into Edit is not dragged back to Preview when the body fills up")
|
||
func aLaterBodyDoesNotReopenTheRule() {
|
||
let presentation = CardBodyPresentation()
|
||
#expect(presentation.openIfNeeded(body: "") == .edit)
|
||
|
||
// The user types; the reload brings the text back. The rule is about *opening* a card, and
|
||
// this window is already open.
|
||
#expect(presentation.openIfNeeded(body: "first words") == .edit)
|
||
}
|
||
}
|
||
|
||
// MARK: - Identity and frames
|
||
|
||
@MainActor
|
||
@Suite("Card window identity")
|
||
struct CardWindowIdentityTests {
|
||
|
||
@Test("Opening the same card twice is one window's worth of bookkeeping")
|
||
func reopeningACardDoesNotDuplicateIt() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let storage = try RegistryStorage()
|
||
defer { storage.tearDown() }
|
||
|
||
let model = AppModel(
|
||
registryStorageURL: storage.url,
|
||
clipboardStagingRoot: storage.folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||
)
|
||
let board = BoardWindowRef(url: fixture.root)
|
||
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||
let store = try model.storeRegistry.acquire(fixture.root)
|
||
model.beginSession(ref: board, store: store, recordID: recordID, access: nil)
|
||
defer { model.storeRegistry.release(store) }
|
||
|
||
// The dedup itself is the scene's: `openWindow(value:)` with an equal ref focuses the window
|
||
// that ref already has (`WindowRefsTests` pins the equality). What this side must not do is
|
||
// *record* a second one — a duplicate here would have the close flush wait on a window that
|
||
// does not exist.
|
||
let first = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1))
|
||
let reopened = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1.uppercased()))
|
||
#expect(first == reopened)
|
||
|
||
model.registerCardWindow(first, session: CardWindowSession())
|
||
model.registerCardWindow(reopened, session: CardWindowSession())
|
||
#expect(model.session(for: board)?.cardRefs.count == 1)
|
||
|
||
// And one unregister drains it, because there was only ever one.
|
||
model.unregisterCardWindow(reopened)
|
||
#expect(model.session(for: board)?.cardRefs.isEmpty == true)
|
||
}
|
||
|
||
@Test("A card window's frame is remembered per card, on its board's record")
|
||
func cardFramesArePerCardAndSurviveARelaunch() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let storage = try RegistryStorage()
|
||
defer { storage.tearDown() }
|
||
|
||
let registry = BoardRegistry(storageURL: storage.url)
|
||
let recordID = registry.recordOpen(of: fixture.root)
|
||
let one = ItemID(rawValue: Ident.card1)
|
||
let two = ItemID(rawValue: Ident.card2)
|
||
|
||
#expect(registry.cardWindowFrame(id: recordID, cardID: one) == nil, "an unopened card cascades instead")
|
||
|
||
registry.updateCardWindowFrame(id: recordID, cardID: one, frame: WindowFrame(x: 10, y: 20, width: 700, height: 600))
|
||
registry.updateCardWindowFrame(id: recordID, cardID: two, frame: WindowFrame(x: 90, y: 80, width: 500, height: 400))
|
||
|
||
// A new instance is a relaunch: the frames come back from the file, per card, which is the
|
||
// whole of "frames restore per card across relaunch where state restoration allows" in an
|
||
// app whose scene restoration is deliberately off.
|
||
let relaunched = BoardRegistry(storageURL: storage.url)
|
||
#expect(relaunched.cardWindowFrame(id: recordID, cardID: one) == WindowFrame(x: 10, y: 20, width: 700, height: 600))
|
||
#expect(relaunched.cardWindowFrame(id: recordID, cardID: two) == WindowFrame(x: 90, y: 80, width: 500, height: 400))
|
||
|
||
// Keyed the way the window itself is keyed: a folder respelled in caps is the same card, so
|
||
// it must find the frame the other spelling stored rather than opening somewhere else.
|
||
let respelled = ItemID(rawValue: Ident.card1.uppercased())
|
||
#expect(relaunched.cardWindowFrame(id: recordID, cardID: respelled) == WindowFrame(x: 10, y: 20, width: 700, height: 600))
|
||
}
|
||
}
|