Files
lanework/KanbanTests/CardWindowShellTests.swift
T
rzen 2c6b8fe63a Remove the App Group wholesale — one sandbox, one bookmark, one flag
Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.

BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.

The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.

1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:46:32 -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 app's real Application Support home.
@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))
}
}