Files
lanework/KanbanTests/CardWindowShellTests.swift
T
rzen 1e65b7c986 Build the card window shell and lifecycle
The m4 scene plumbing was already honest — one WindowGroup value per
CardWindowRef enforces one-window-per-card, and CardWindowFate's
ancestor walk answered dismissal — so this card fills the window: a
two-column shell whose body column takes all resize flex and whose
sidebar width derives once from font metrics (26 characters of average
body advance plus em gutters), the five 05-ordered section headers as
placeholders, and the card body as selectable plain text until Preview
mode lands. The fate walk now returns a CardPlacement (card + lane), so
one pass answers both liveness and the live board › lane subtitle; a
board rename lands for free through displayName. Card windows remember
their frames per card in the board record (case-folded id keys,
unchanged-writes-nothing), restoring instead of cascading; only
unremembered cards take the last-used size and cascade. Store
acquisition stays gated on liveStore — a card window never opens a
board — and the close-flush hook stands with nothing to flush until the
Edit-session card.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:27:01 -04:00

259 lines
12 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 test host's Application Support.
@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: - 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)
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))
}
}