Files
lanework/KanbanTests/CardWindowShellTests.swift
T
rzen 566deab506 Home app-side state in the shared App Group container
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
2026-07-29 20:18:15 -04:00

324 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}