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:
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user