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)) } }