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
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user