The board chooses where it lives — swipe-open settings move it between iCloud and this iPhone
A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns. With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import XCTest
|
||||
|
||||
/// Where a board is stored, and the one operation that changes it.
|
||||
///
|
||||
/// Both tests drive the real `BoardIndexStore.relocateBoard(at:to:)` over two real directories the
|
||||
/// test owns — the DEBUG stand-in cloud root and the DEBUG device root — so the move that lands is a
|
||||
/// coordinated move of an actual `.kanban` package, not a model edit. The `setUbiquitous` path a
|
||||
/// shipped build takes cannot be exercised without an iCloud account; what these cover is everything
|
||||
/// on either side of it.
|
||||
final class BoardLocationUITests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testMoveBoardOutOfICloudAndBack() throws {
|
||||
let (app, cloudRoot, deviceRoot) = XCUIApplication.launchedWithFixtureBoardInCloud()
|
||||
let inCloud = cloudRoot.appendingPathComponent(RichBoard.packageName, isDirectory: true)
|
||||
let onDevice = deviceRoot.appendingPathComponent(RichBoard.packageName, isDirectory: true)
|
||||
|
||||
// Out of iCloud.
|
||||
app.openBoardSettings(for: RichBoard.title)
|
||||
// `LabeledContent` publishes label and value as one flattened node — "Stored In, iCloud" —
|
||||
// which is the sheet's whole claim about where the board is, in one assertion.
|
||||
XCTAssertTrue(
|
||||
app.staticTexts["Stored In, iCloud"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet did not report the board as stored in iCloud"
|
||||
)
|
||||
app.buttons["Move to Local"].tap()
|
||||
app.confirmMove()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.navigationBars["Board Settings"].waitForNonExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet stayed up after a move that should have succeeded"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForDirectory(at: onDevice),
|
||||
"the package never arrived under the device root at \(onDevice.path)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForDirectory(at: inCloud, toExist: false),
|
||||
"the package was still under the cloud root at \(inCloud.path)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
app.boardRow(RichBoard.title, alsoContaining: "Local")
|
||||
.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the row never picked up the Local marker"
|
||||
)
|
||||
|
||||
// And back.
|
||||
app.openBoardSettings(for: RichBoard.title)
|
||||
XCTAssertTrue(
|
||||
app.staticTexts["Stored In, Local"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet did not report the moved board as stored locally"
|
||||
)
|
||||
let moveToCloud = app.buttons["Move to iCloud"]
|
||||
XCTAssertTrue(
|
||||
moveToCloud.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet did not offer the return trip"
|
||||
)
|
||||
moveToCloud.tap()
|
||||
app.confirmMove()
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForDirectory(at: inCloud),
|
||||
"the package never came back under the cloud root at \(inCloud.path)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForDirectory(at: onDevice, toExist: false),
|
||||
"the package was still under the device root at \(onDevice.path)"
|
||||
)
|
||||
}
|
||||
|
||||
/// The softened wall (2026-08-08): no iCloud is a notice above a working list, not a screen
|
||||
/// instead of one.
|
||||
@MainActor
|
||||
func testLocalBoardWorksWithoutICloud() throws {
|
||||
let (app, _) = XCUIApplication.launchedWithFixtureBoardOnDevice()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.element(labelContaining: "iCloud Unavailable").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the iCloud notice never appeared"
|
||||
)
|
||||
|
||||
let boardRow = app.boardRow(RichBoard.title, alsoContaining: "Local")
|
||||
XCTAssertTrue(
|
||||
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the device-home board never listed — the old wall would have replaced the list here"
|
||||
)
|
||||
|
||||
// It is a whole board, not a listing: it opens and navigates like any other.
|
||||
boardRow.tap()
|
||||
let laneRow = app.element(labelContaining: RichBoard.firstLane)
|
||||
XCTAssertTrue(
|
||||
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the local board's lanes never appeared"
|
||||
)
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
|
||||
// The one thing it cannot do: go somewhere that does not exist.
|
||||
app.openBoardSettings(for: RichBoard.title)
|
||||
XCTAssertTrue(
|
||||
app.staticTexts["Stored In, Local"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet did not report the board as stored locally"
|
||||
)
|
||||
let moveToCloud = app.buttons["Move to iCloud"]
|
||||
XCTAssertTrue(
|
||||
moveToCloud.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the sheet never offered a move to iCloud"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
moveToCloud.isEnabled,
|
||||
"moving to iCloud was offered as available with no iCloud to move to"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ enum RichBoard {
|
||||
static let title = "Rich Demo Board"
|
||||
static let firstLane = "Doing"
|
||||
static let firstLaneFirstCard = "Design the fixture taxonomy"
|
||||
|
||||
/// The package's folder name — the thing a move relocates, and what a disk assertion looks for
|
||||
/// under one root or the other. Not derived from `title`: the fixture's folder and its `title:`
|
||||
/// key deliberately differ (01-storage-format.md § Board naming allows it), and a move preserves
|
||||
/// the folder name rather than re-deriving one.
|
||||
static let packageName = "rich-board.kanban"
|
||||
}
|
||||
|
||||
// MARK: - Driving the app
|
||||
@@ -58,25 +64,133 @@ extension XCUIApplication {
|
||||
/// on disk after driving an edit through the UI.
|
||||
@MainActor
|
||||
static func launchedWithFixtureBoard() -> (app: XCUIApplication, root: URL) {
|
||||
let root = scratchRoot(seeded: true)
|
||||
return (launched(["LANEWORK_LOCAL_ROOT": root.path]), root)
|
||||
}
|
||||
|
||||
/// The same launch with a **second** scratch directory behind `LANEWORK_DEVICE_ROOT`
|
||||
/// (`DeviceHomeResolver`), so both of the app's homes are directories this test created and
|
||||
/// owns — which is what makes a move between them assertable on disk. The fixture starts in the
|
||||
/// stand-in cloud root; the device root starts empty.
|
||||
///
|
||||
/// Overriding the device root matters as much as overriding the cloud one: the real device home
|
||||
/// is the installed app's own `Documents/`, which survives between test runs and between tests.
|
||||
@MainActor
|
||||
static func launchedWithFixtureBoardInCloud() -> (app: XCUIApplication, cloudRoot: URL, deviceRoot: URL) {
|
||||
let cloudRoot = scratchRoot(seeded: true)
|
||||
let deviceRoot = scratchRoot(seeded: false)
|
||||
let app = launched([
|
||||
"LANEWORK_LOCAL_ROOT": cloudRoot.path,
|
||||
"LANEWORK_DEVICE_ROOT": deviceRoot.path,
|
||||
])
|
||||
return (app, cloudRoot, deviceRoot)
|
||||
}
|
||||
|
||||
/// A launch with **no iCloud at all** and the fixture in the device home — the no-account run,
|
||||
/// which must still list, open and edit boards.
|
||||
///
|
||||
/// `LANEWORK_FORCE_NO_ICLOUD` rather than simply omitting `LANEWORK_LOCAL_ROOT`: without the
|
||||
/// override the resolver reaches for the real ubiquity container, and a simulator that happens to
|
||||
/// be signed into an account would resolve one — making this test pass or fail on whose machine
|
||||
/// it ran.
|
||||
@MainActor
|
||||
static func launchedWithFixtureBoardOnDevice() -> (app: XCUIApplication, deviceRoot: URL) {
|
||||
let deviceRoot = scratchRoot(seeded: true)
|
||||
let app = launched([
|
||||
"LANEWORK_FORCE_NO_ICLOUD": "1",
|
||||
"LANEWORK_DEVICE_ROOT": deviceRoot.path,
|
||||
])
|
||||
return (app, deviceRoot)
|
||||
}
|
||||
|
||||
/// A directory no other test shares, optionally holding a copy of the fixture board.
|
||||
@MainActor
|
||||
private static func scratchRoot(seeded: Bool) -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("KanbanMobileUITests-\(UUID().uuidString)", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = fixturesRoot().appendingPathComponent("Valid/rich-board.kanban", isDirectory: true)
|
||||
let destination = root.appendingPathComponent("rich-board.kanban", isDirectory: true)
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
if seeded {
|
||||
let source = fixturesRoot()
|
||||
.appendingPathComponent("Valid/\(RichBoard.packageName)", isDirectory: true)
|
||||
try FileManager.default.copyItem(
|
||||
at: source,
|
||||
to: root.appendingPathComponent(RichBoard.packageName, isDirectory: true)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
fatalError("could not seed the fixture board into a scratch root: \(error)")
|
||||
fatalError("could not seed a scratch root: \(error)")
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func launched(_ environment: [String: String]) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["LANEWORK_LOCAL_ROOT"] = root.path
|
||||
for (key, value) in environment {
|
||||
app.launchEnvironment[key] = value
|
||||
}
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
app.wait(for: .runningForeground, timeout: uiTimeout),
|
||||
"the app did not reach the foreground"
|
||||
)
|
||||
return (app, root)
|
||||
return app
|
||||
}
|
||||
|
||||
/// The board list row for `title`, optionally narrowed to one that also carries `marker` —
|
||||
/// "Local" being the only marker there is. Both fragments have to be matched on the *same*
|
||||
/// element because the row is one flattened accessibility node (see `element(labelContaining:)`),
|
||||
/// so its label is the whole subtitle line concatenated onto the title.
|
||||
@MainActor
|
||||
func boardRow(_ title: String, alsoContaining marker: String? = nil) -> XCUIElement {
|
||||
var predicate = NSPredicate(format: "label CONTAINS %@", title)
|
||||
if let marker {
|
||||
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
predicate,
|
||||
NSPredicate(format: "label CONTAINS %@", marker),
|
||||
])
|
||||
}
|
||||
return descendants(matching: .any).matching(predicate).firstMatch
|
||||
}
|
||||
|
||||
/// Swipes a board row open and taps its Settings action.
|
||||
///
|
||||
/// The label is scoped to the list because the tab bar carries a "Settings" button of its own —
|
||||
/// same word, entirely different destination — and an unscoped `buttons["Settings"]` is a coin
|
||||
/// flip between them.
|
||||
@MainActor
|
||||
func openBoardSettings(for title: String) {
|
||||
let row = boardRow(title)
|
||||
XCTAssertTrue(
|
||||
row.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the \"\(title)\" row was not there to swipe"
|
||||
)
|
||||
row.swipeLeft()
|
||||
|
||||
let action = collectionViews.buttons["Settings"]
|
||||
XCTAssertTrue(
|
||||
action.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the row's Settings swipe action never appeared"
|
||||
)
|
||||
action.tap()
|
||||
|
||||
XCTAssertTrue(
|
||||
navigationBars["Board Settings"].waitForExistence(timeout: Self.uiTimeout),
|
||||
"the Board Settings sheet never appeared"
|
||||
)
|
||||
}
|
||||
|
||||
/// Confirms the move in the `confirmationDialog` the sheet raises. Deliberately labelled with a
|
||||
/// bare verb, which is also what keeps it distinct from the "Move to …" button that raised it.
|
||||
@MainActor
|
||||
func confirmMove() {
|
||||
let confirm = buttons["Move"]
|
||||
XCTAssertTrue(
|
||||
confirm.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the move confirmation dialog never appeared"
|
||||
)
|
||||
confirm.tap()
|
||||
}
|
||||
|
||||
/// Scrolls the frontmost view upward, a little at a time, until `element` is hittable or
|
||||
@@ -108,6 +222,25 @@ func waitForFile(under root: URL, containing substring: String, timeout: TimeInt
|
||||
return 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
|
||||
/// well before either root has settled.
|
||||
func waitForDirectory(at url: URL, toExist shouldExist: Bool = true, timeout: TimeInterval = 15) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if directoryExists(at: url) == shouldExist { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||
} while Date() < deadline
|
||||
return directoryExists(at: url) == shouldExist
|
||||
}
|
||||
|
||||
private func directoryExists(at url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||
return exists && isDirectory.boolValue
|
||||
}
|
||||
|
||||
private func fileExists(under root: URL, containing substring: String) -> Bool {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
|
||||
Reference in New Issue
Block a user