Files
lanework/KanbanTests/BoardLoadingTests.swift
T
rzen f4b2de55fb The board window says its name once — the scene declares the title SwiftUI keeps hiding
`HostedWindowController.hideTitle()` was the same shape of bug a429a7e fixed for
`titlebarAppearsTransparent`: an out-of-band `NSWindow.titleVisibility` write, correct the
instant it ran, undone by SwiftUI's own next pass over the window's configuration — a tree
that declares nothing resolves `.visible`, and SwiftUI writes that back over the out-of-band
`.hidden` on the very next `@State`-driven re-render this board window's own liveness causes.
The system title reappeared beside the board-popover widget, "occasionally" — whenever that
next re-render happened to land.

Confirmed with an A/B harness (no interactive display in this session, so not reproduced on
screen; mechanism established in code, per the card's own fallback): a bare out-of-band write
held indefinitely against resize and key-status changes alone, but reverted on the very next
`@State`-driven render and stayed reverted — reasserting from `body`'s own construction or
from `.onChange` both lost the same race, since SwiftUI's resync runs later than either. The
only thing that held was declaring the posture in the tree itself, mirroring
`.toolbarBackgroundVisibility`'s role in a429a7e.

`KanbanApp`'s board `WindowGroup` now declares `.windowToolbarStyle(.unified(showsTitle:
false))`. It is a scene modifier, not a per-window one, so — unlike `.toolbarBackgroundVisibility`
— it cannot wait for a board's load to finish before taking effect; every board window it
creates keeps the system title hidden from its very first frame. `boardLoadingTitlebarAccessory`
covers the gap that opens before the loading window has a store to build the real widget from: a
small, non-interactive, plain-text stand-in carrying the registry record's cached name, installed
the moment the window attaches and swapped by identity for the real widget the moment the store
loads — so the loading window's chrome still carries a name throughout, per 02-architecture.md.
`hideTitle()`'s own write stays; it is no longer what keeps the title hidden, but it is still
correct for the one render turn before the scene's own re-assertion catches up.

Confined to `BoardWindowHost.swift`, `BoardInfoPopover.swift` and `KanbanApp.swift` —
`WindowAccessor.swift`'s shared `hideTitle()`/`titleVisibility` machinery is untouched, since a
concurrent fix is addressing the card window's version of this same bug through that file.

New regression tests (`BoardLoadingTitlebarStandInTests`, `KanbanTests/BoardLoadingTests.swift`)
pin the stand-in's layout and the identity-based swap. Full suite green (3219 tests) except the
pre-existing, documented environment-sensitive `PointerLatencyTests`, confirmed unaffected by
rerunning them in isolation.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 12:06:01 -04:00

246 lines
11 KiB
Swift

import AppKit
import Foundation
import Testing
@testable import Kanban
/// **The pre-snapshot loading state** (02-architecture.md § Launch and window lifecycle, ruled
/// 2026-07-29): the board window appears immediately, wearing the registry record's cached name,
/// and its content area stays empty until a short grace has passed — "so ordinary fast opens never
/// flash it".
///
/// Both halves are rules about *state*, not about rendering, and both are extracted so they can be
/// asked without a window: `BoardLoadingIndicator` is the grace's state machine and
/// `BoardWindowHost.loadingTitle` is the title rule. What SwiftUI does with either — a `ProgressView`
/// in a `ZStack`, a `navigationTitle` — is one line each and is not what could go quietly wrong.
///
/// **No test here waits the real grace out.** The figure is injectable for exactly that reason
/// (`DragSession.holdTimeout`'s precedent), and the "after" half is also pinned directly through the
/// body the clock runs, so the rule is checkable with no clock at all.
// MARK: - Helpers
/// Polls until `condition` holds or the deadline passes — the file's only wait, and it waits for a
/// *fact* (the spinner arrived) rather than for an interval.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while ContinuousClock.now - start < deadline {
if condition() { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
@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"))
return fixture
}
/// A registry file in temp — app-side state, never inside a board folder.
@MainActor
private func makeRegistry() throws -> (registry: BoardRegistry, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardLoadingTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let registry = BoardRegistry(storageURL: folder.appendingPathComponent("board-registry.json"))
return (registry, { try? FileManager.default.removeItem(at: folder) })
}
// MARK: - Tests
@MainActor
@Suite("Board loading state")
struct BoardLoadingTests {
// MARK: The grace
@Test("Nothing shows before the grace elapses")
func theSurfaceIsEmptyDuringTheGrace() async {
let indicator = BoardLoadingIndicator()
indicator.grace = .seconds(30)
#expect(!indicator.showsSpinner, "at rest")
indicator.begin()
#expect(!indicator.showsSpinner, "the grace has been armed, not elapsed")
}
@Test("The spinner appears once the grace elapses")
func theSpinnerArrivesAfterTheGrace() async {
let indicator = BoardLoadingIndicator()
indicator.grace = .milliseconds(20)
indicator.begin()
await waitUntil { indicator.showsSpinner }
#expect(indicator.showsSpinner)
}
@Test("The grace's body is the whole of the spinner's arrival")
func graceElapsedIsThePinnableHalf() {
// The clock-free half of the rule above: whatever the duration, *this* is what the sleep
// ends in, so a suite can assert the "after" state without a clock (`DragSession.expire`'s
// precedent).
let indicator = BoardLoadingIndicator()
indicator.graceElapsed()
#expect(indicator.showsSpinner)
}
@Test("A board that lands inside the grace never flashes the spinner")
func fastOpenNeverFlashes() async {
let indicator = BoardLoadingIndicator()
indicator.grace = .milliseconds(20)
// The ordinary open: the snapshot arrives before the grace is up.
indicator.begin()
indicator.end()
#expect(!indicator.showsSpinner)
// And it stays away — the disarmed grace must not fire into a window that has moved on.
try? await Task.sleep(for: .milliseconds(60))
#expect(!indicator.showsSpinner)
}
@Test("Ending the surface clears a spinner that had already appeared")
func endClearsTheSpinner() async {
let indicator = BoardLoadingIndicator()
indicator.grace = .milliseconds(20)
indicator.begin()
await waitUntil { indicator.showsSpinner }
indicator.end()
#expect(!indicator.showsSpinner, "the snapshot replaced the surface in place")
}
@Test("Arming twice does not restart the clock")
func beginIsIdempotent() async {
let indicator = BoardLoadingIndicator()
indicator.grace = .milliseconds(20)
indicator.begin()
// A body that evaluates again, or a host that configures itself twice, must not push the
// spinner back by another grace.
indicator.begin()
await waitUntil { indicator.showsSpinner }
#expect(indicator.showsSpinner)
}
// MARK: The loading window's title
@Test("A first-ever open wears the record's provisional folder name")
func loadingTitleIsTheFolderNameOnAFirstOpen() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (registry, tearDown) = try makeRegistry()
defer { tearDown() }
// Exactly what `BoardWindowHost.start()` does before the walk: record, then read the record
// back for the title. The folder name arrives as the record's own provisional display name,
// not as a second rule the window applies for itself.
let recordID = registry.recordOpen(of: fixture.root)
#expect(
BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root)
== AppModel.folderDisplayName(of: fixture.root)
)
}
@Test("A board that has opened before wears its cached title")
func loadingTitleIsTheCachedTitle() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (registry, tearDown) = try makeRegistry()
defer { tearDown() }
// The previous session's successful load, which is what stamps the cached title.
let first = registry.recordOpen(of: fixture.root)
registry.syncDisplayState(id: first, title: "Roadmap", icon: nil, iconColor: nil)
// This session's open: the record is found again by file identity, and its cached title is
// what the window is called while it walks — never the folder name it happens to sit in.
let recordID = registry.recordOpen(of: fixture.root)
#expect(recordID == first, "the same board must find the record it already has")
#expect(BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root) == "Roadmap")
}
@Test("With no record yet the title is still a no-scan name")
func loadingTitleFallsBackToTheFolderName() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The one body evaluation that precedes `recordOpen`. It must not be blank, and it must not
// cost a look inside the board.
#expect(
BoardWindowHost.loadingTitle(record: nil, url: fixture.root)
== AppModel.folderDisplayName(of: fixture.root)
)
}
}
// MARK: - The loading window's titlebar stand-in
/// **The duplicate-name bug's fix** (Pipeline card a73bad86): a board window's system title is kept
/// permanently hidden by `KanbanApp`'s `.windowToolbarStyle(.unified(showsTitle: false))` — a scene
/// modifier that cannot wait for the store, so it hides the system title on the loading window too,
/// before there is a `BoardStore` to build the real widget from. `boardLoadingTitlebarAccessory`
/// is what fills that gap, and `BoardWindowHost.configureWindow` swaps it for the real widget by
/// removing it from the window by identity — the same pattern
/// `HostedWindowController.removeTitlebarAccessory` uses for its own slot, pinned again here because
/// the swap itself runs inline in a private method with no seam of its own to call directly.
@MainActor
@Suite("Board loading state ▸ the titlebar stand-in")
struct BoardLoadingTitlebarStandInTests {
private static func window() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: true
)
}
@Test("It lays out leading, exactly like the real widget it stands in for")
func laysOutLeading() {
let accessory = boardLoadingTitlebarAccessory(title: "Roadmap")
#expect(accessory.layoutAttribute == .leading)
// Wide and tall enough that it is never a zero-size, invisible widget — the intrinsic
// measurement `.intrinsicContentSize` runs settles the exact figure, which is not the part
// worth pinning; not being clipped to nothing is.
#expect(accessory.view.frame.width > 0)
#expect(accessory.view.frame.height > 0)
}
@Test("Removing it by identity leaves any other accessory the window carries untouched")
func removalByIdentityIsTargeted() {
let window = Self.window()
let standIn = boardLoadingTitlebarAccessory(title: "Roadmap")
let other = boardLoadingTitlebarAccessory(title: "Some Other Board")
window.addTitlebarAccessoryViewController(standIn)
window.addTitlebarAccessoryViewController(other)
#expect(window.titlebarAccessoryViewControllers.count == 2)
// `BoardWindowHost.configureWindow`'s own removal: find by identity, remove by index.
if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === standIn }) {
window.removeTitlebarAccessoryViewController(at: index)
}
#expect(window.titlebarAccessoryViewControllers.count == 1)
#expect(window.titlebarAccessoryViewControllers.first === other, "the untargeted one survives")
}
@Test("A window with no stand-in installed is left alone by the same removal")
func removalIsANoOpWithoutOne() {
let window = Self.window()
let other = boardLoadingTitlebarAccessory(title: "Some Other Board")
window.addTitlebarAccessoryViewController(other)
let standIn: NSTitlebarAccessoryViewController? = nil
if let standIn, let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === standIn }) {
window.removeTitlebarAccessoryViewController(at: index)
}
#expect(window.titlebarAccessoryViewControllers.count == 1, "nothing to remove, nothing removed")
}
}