Cards open to be read — the mobile detail screen turns read-only, editing moves behind a transactional Save

CardDetailScreen renders title, inline-Markdown body, and a quiet dates
footer; all writing moves to the new CardEditScreen, a full-screen cover
with segmented Details/Body panes. Drafts commit in one perform bracket
on Save only — Cancel guards dirty drafts with a discard confirmation,
and a failed write keeps the sheet and its drafts and raises an alert
instead of dismissing. CardAttributesSection becomes a pure
binding-driven editor with no write path of its own. The UI test walk
crosses the new split, with body-pane and discard coverage, and a
deterministic replaceAllText helper retires the flaky ⌘A select-all.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 14:47:50 -04:00
parent 28abaac2d9
commit c4591ead2c
6 changed files with 580 additions and 164 deletions
+134 -34
View File
@@ -1,43 +1,31 @@
import XCTest
/// Board list lanes cards card detail, then an edit that has to reach disk the one
/// end-to-end walk of the mobile MVP's navigation stack (`BoardRoute`'s three destinations).
/// end-to-end walk of the mobile MVP's navigation stack (`BoardRoute`'s three destinations), plus
/// the transactional edit sheet `CardDetailScreen`'s Edit button presents.
final class BoardsNavigationUITests: XCTestCase {
@MainActor
func testBoardsNavigationAndTitleEdit() throws {
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
app.navigateToFirstCard()
// Boards is the app's root screen (`BoardsTabView`), so no navigation is needed to reach
// it. The row's label is the flattened `BoardSummaryRow` title plus the "N lanes · N
// cards · modified" subtitle `BoardIndexStore`'s first scan fills in.
let boardRow = app.element(labelContaining: RichBoard.title)
// CardDetailScreen is read-only: the fixture card's title renders as plain text, not a
// field the read/edit split this suite now has to cross to reach any field at all.
XCTAssertTrue(
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
app.staticTexts[RichBoard.firstLaneFirstCard].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the read view's title text never appeared"
)
boardRow.tap()
let laneRow = app.element(labelContaining: RichBoard.firstLane)
XCTAssertTrue(
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.firstLane)\" lane row never appeared"
)
laneRow.tap()
app.openCardEdit()
let cardRow = app.element(labelContaining: RichBoard.firstLaneFirstCard)
XCTAssertTrue(
cardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
)
cardRow.tap()
// CardDetailScreen: the only `textField` on this screen is the title
// (`Section("Title")`) the body is a `TextEditor`, which is a `textView`.
// CardEditScreen opens on the Details pane; its only textField is the title
// (`Section("Title")`) the body pane is a bare `TextEditor`, a `textView`, and is not
// this pane's concern.
let titleField = app.textFields.firstMatch
XCTAssertTrue(
titleField.waitForExistence(timeout: XCUIApplication.uiTimeout),
"CardDetailScreen's title field never appeared"
"CardEditScreen's title field never appeared"
)
XCTAssertEqual(
titleField.value as? String, RichBoard.firstLaneFirstCard,
@@ -45,21 +33,133 @@ final class BoardsNavigationUITests: XCTestCase {
)
let sentinel = "Retitled by UI test"
titleField.tap()
// Select-all via a hardware-keyboard shortcut (iOS answers A the same as macOS when a
// keyboard is attached, which the Simulator always presents one as) rather than a
// backspace-per-character workaround, whose delete count only works if the tap happened
// to land the cursor at the end of the existing text.
titleField.typeKey("a", modifierFlags: .command)
titleField.typeText(sentinel)
titleField.replaceAllText(with: sentinel)
// Commit the way the screen commits (`CardDetailScreen.body`'s `.confirmationAction`):
// the Done button folds `commitAll()` in and drops focus, without navigating back.
app.navigationBars.buttons["Done"].tap()
app.navigationBars.buttons["Save"].tap()
// The cover dismisses back to the read view underneath, which should already show the new
// title `session.perform` awaits its own reload before `Save` dismisses.
XCTAssertTrue(
app.staticTexts[sentinel].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the read view never picked up the retitled card"
)
XCTAssertTrue(
waitForFile(under: root, containing: sentinel),
"the retitled card never landed on disk under \(root.path)"
)
}
@MainActor
func testCardEditBodyPaneWritesToDisk() throws {
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
app.navigateToFirstCard()
app.openCardEdit()
app.buttons["Body"].tap()
let bodyEditor = app.textViews.firstMatch
XCTAssertTrue(
bodyEditor.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the Body pane's text editor never appeared"
)
let sentinel = "Rewritten body by UI test"
bodyEditor.replaceAllText(with: sentinel)
app.navigationBars.buttons["Save"].tap()
// The read view renders the body as Markdown; a plain sentinel with no markup renders
// back out as itself.
XCTAssertTrue(
app.staticTexts[sentinel].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the read view never picked up the rewritten body"
)
XCTAssertTrue(
waitForFile(under: root, containing: sentinel),
"the rewritten body never landed on disk under \(root.path)"
)
}
@MainActor
func testCardEditCancelWithDirtyDraftsDiscardsOnDisk() throws {
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
app.navigateToFirstCard()
app.openCardEdit()
let titleField = app.textFields.firstMatch
XCTAssertTrue(
titleField.waitForExistence(timeout: XCUIApplication.uiTimeout),
"CardEditScreen's title field never appeared"
)
let sentinel = "Abandoned edit"
titleField.replaceAllText(with: sentinel)
// Cancel on a dirty sheet raises a confirmation rather than dismissing outright the
// sheet's only exit, since a `fullScreenCover` has no interactive swipe-dismiss.
app.navigationBars.buttons["Cancel"].tap()
let discardButton = app.buttons["Discard"]
XCTAssertTrue(
discardButton.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the discard confirmation never appeared for a dirty Cancel"
)
discardButton.tap()
// Back on the read view, still showing the fixture's own title nothing was ever written.
XCTAssertTrue(
app.staticTexts[RichBoard.firstLaneFirstCard].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the read view did not return to the card's original title after Discard"
)
XCTAssertTrue(
fileDoesNotContain(under: root, substring: sentinel),
"the discarded title leaked onto disk under \(root.path)"
)
}
}
private extension XCUIApplication {
/// Boards the fixture's first lane its first card, landing on `CardDetailScreen`'s read
/// view. Shared by every test in this file so the navigation-and-assert boilerplate is written
/// once.
@MainActor
func navigateToFirstCard() {
let boardRow = element(labelContaining: RichBoard.title)
XCTAssertTrue(
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
)
boardRow.tap()
let laneRow = element(labelContaining: RichBoard.firstLane)
XCTAssertTrue(
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.firstLane)\" lane row never appeared"
)
laneRow.tap()
let cardRow = element(labelContaining: RichBoard.firstLaneFirstCard)
XCTAssertTrue(
cardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
)
cardRow.tap()
}
/// Taps `CardDetailScreen`'s Edit button and waits for `CardEditScreen`'s cover to land.
@MainActor
func openCardEdit() {
let editButton = navigationBars.buttons["Edit"]
XCTAssertTrue(
editButton.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the read view's Edit button never appeared"
)
editButton.tap()
XCTAssertTrue(
navigationBars.buttons["Save"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"CardEditScreen's cover never appeared"
)
}
}
@@ -208,6 +208,39 @@ extension XCUIApplication {
}
}
extension XCUIElement {
/// Replaces this field's/editor's entire text with `text`, wholesale.
///
/// **Neither A nor the long-press callout survived contact with this suite.** A is delivered
/// as a `UIKeyCommand` down the responder chain, and that routing is not guaranteed wired up the
/// instant `tap()` returns behind a `fullScreenCover`'s own presentation transition, or right
/// after a `TextEditor` is freshly mounted by a pane switch, A sent too early is silently
/// dropped while ordinary character input (a different, lower-level path) still lands, so the
/// retyped text ends up inserted at whatever the stale cursor position was rather than replacing
/// anything and a fixed settle before it only wins *sometimes*, because under a full suite
/// run's extra load the gap it needs to cover stretches past any delay worth hard-coding. The
/// long-press "Select All" callout fares worse: it never appeared at all in this environment (the
/// Simulator's hardware keyboard appears to suppress the touch selection UI outright).
///
/// What is left, and has been reliable through every run: plain character `typeText` always
/// lands, so the fix sidesteps selection entirely. A tap near the field's trailing/bottom edge
/// past wherever the current text actually ends is where both `UITextField` and `UITextView`
/// place the caret at the *end* of the text (the standard "tap in the empty run-out" behavior,
/// not an assumption this suite is inventing), which is what makes a plain backspace-per-character
/// safe here where the original in-place editor's own comment once ruled it out: that caveat was
/// about a tap landing *mid-text*, not about the technique itself.
@MainActor
func replaceAllText(with text: String) {
let trailingEdge = coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5))
trailingEdge.tap()
if let current = value as? String, !current.isEmpty {
typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count))
}
typeText(text)
}
}
// MARK: - Polling the filesystem
/// Waits up to `timeout` for some file under `root` (searched recursively) to contain
@@ -223,6 +256,14 @@ func waitForFile(under root: URL, containing substring: String, timeout: TimeInt
return fileExists(under: root, containing: substring)
}
/// A point-in-time negative check `waitForFile`'s substring test with no polling, for asserting a
/// draft was never written. Discarding a `CardEditScreen` never calls `session.perform` at all, so
/// there is no async write to wait out; polling the full timeout for something that never becomes
/// true would only slow the suite down for no better an answer than one immediate look.
func fileDoesNotContain(under root: URL, substring: String) -> Bool {
!fileExists(under: root, containing: substring)
}
/// Waits up to `timeout` for the directory at `url` to exist or, with `toExist: false`, to be
/// gone. The package-shaped counterpart to `waitForFile(under:containing:)`, and what a move
/// assertion needs: `relocateBoard` hands the actual transfer to a detached task and answers the UI