From 766a5b726b146ca4ab7bca3c91430039e6f8bb4f Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 11 Apr 2026 12:16:46 -0700 Subject: [PATCH 1/3] feat: editable adversary name in runner with uniqueness validation (#68) Tap the adversary name in the expanded card to rename it inline. Validates that the trimmed name is non-empty and unique across all slots; shows an inline error on conflict. Empty input reverts silently. Mutation routes through EncounterSession.renameAdversary(id:name:). --- Encounter/Views/AdversaryRunnerCard.swift | 66 ++++++++++++++++++++--- EncounterTests/EncounterTests.swift | 52 +++++++++++++++++- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/Encounter/Views/AdversaryRunnerCard.swift b/Encounter/Views/AdversaryRunnerCard.swift index 1ec8cc9..a78368f 100644 --- a/Encounter/Views/AdversaryRunnerCard.swift +++ b/Encounter/Views/AdversaryRunnerCard.swift @@ -22,6 +22,10 @@ struct AdversaryRunnerCard: View { let compendium: Compendium let onCollapse: () -> Void + @State private var isEditingName = false + @State private var pendingName = "" + @State private var nameError: String? = nil + private var displayName: String { let adversary = compendium.adversary(id: slot.adversaryID) return slot.customName ?? adversary?.name ?? "Unknown (\(slot.adversaryID))" @@ -36,13 +40,36 @@ struct AdversaryRunnerCard: View { // Header HStack { - Text(displayName) - .font(.headline) - Spacer() - Button("Collapse", systemImage: "chevron.up", action: onCollapse) - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .accessibilityIdentifier("runner.adversary-card.collapse-button") + if isEditingName { + TextField("Name", text: $pendingName) + .font(.headline) + .onSubmit { commitRename() } + .accessibilityIdentifier("runner.adversary-card.name-field") + Button("Done") { commitRename() } + .buttonStyle(.borderless) + Button("Cancel") { cancelRename() } + .buttonStyle(.borderless) + } else { + Button(action: beginRename) { + Text(displayName) + .font(.headline) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + .accessibilityIdentifier("runner.adversary-card.name") + .accessibilityHint("Tap to rename") + Spacer() + Button("Collapse", systemImage: "chevron.up", action: onCollapse) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .accessibilityIdentifier("runner.adversary-card.collapse-button") + } + } + if isEditingName, let error = nameError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("runner.adversary-card.name-error") } // HP pip track @@ -93,6 +120,31 @@ struct AdversaryRunnerCard: View { } .padding(.vertical, 8) } + + private func beginRename() { + pendingName = displayName + nameError = nil + isEditingName = true + } + + private func commitRename() { + let trimmed = pendingName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + cancelRename() + return + } + if session.renameAdversary(id: slot.id, name: trimmed) { + isEditingName = false + nameError = nil + } else { + nameError = "Name already in use" + } + } + + private func cancelRename() { + isEditingName = false + nameError = nil + } } #Preview { diff --git a/EncounterTests/EncounterTests.swift b/EncounterTests/EncounterTests.swift index 06bb158..1009746 100644 --- a/EncounterTests/EncounterTests.swift +++ b/EncounterTests/EncounterTests.swift @@ -263,6 +263,57 @@ struct ConditionTests { #expect(session.adversarySlots[0].isDefeated == false) } + // MARK: - Rename + + @Test func renameAdversary_success() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier, customName: "Alpha") + session.add(adversary: soldier, customName: "Bravo") + let id = session.adversarySlots[0].id + + let result = session.renameAdversary(id: id, name: "Charlie") + + #expect(result == true) + #expect(session.adversarySlots[0].customName == "Charlie") + } + + @Test func renameAdversary_duplicate() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier, customName: "Alpha") + session.add(adversary: soldier, customName: "Bravo") + let id = session.adversarySlots[0].id + + let result = session.renameAdversary(id: id, name: "Bravo") + + #expect(result == false) + #expect(session.adversarySlots[0].customName == "Alpha") + } + + @Test func renameAdversary_emptyName() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier, customName: "Alpha") + let id = session.adversarySlots[0].id + + #expect(session.renameAdversary(id: id, name: "") == false) + #expect(session.renameAdversary(id: id, name: " ") == false) + #expect(session.adversarySlots[0].customName == "Alpha") + } + + @Test func renameAdversary_selfNameAllowed() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier, customName: "Alpha") + let id = session.adversarySlots[0].id + + let result = session.renameAdversary(id: id, name: "Alpha") + + #expect(result == true) + #expect(session.adversarySlots[0].customName == "Alpha") + } + @Test func applyDamageReducesHP() { let session = makeSession() let soldier = makeSoldier() @@ -367,7 +418,6 @@ struct ConditionTests { #expect(session.adversarySlots[0].conditions.contains(.custom("Enraged"))) } - } // MARK: - PlayerState From 828b88ab8a921687ab074351ff676e7c64a7b9f9 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 11 Apr 2026 12:35:05 -0700 Subject: [PATCH 2/3] test: UI tests for editable adversary name (PR #76 test plan) Covers all 7 items from the test plan: tap-to-edit, Done button, Return key, duplicate name error, empty-field revert, Cancel revert, and collapse button accessibility. Includes a two-same-adversary navigation helper for the uniqueness validation scenario. --- EncounterUITests/RenameAdversaryUITests.swift | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 EncounterUITests/RenameAdversaryUITests.swift diff --git a/EncounterUITests/RenameAdversaryUITests.swift b/EncounterUITests/RenameAdversaryUITests.swift new file mode 100644 index 0000000..3f510ce --- /dev/null +++ b/EncounterUITests/RenameAdversaryUITests.swift @@ -0,0 +1,337 @@ +// +// RenameAdversaryUITests.swift +// EncounterUITests +// +// Covers the PR #76 test plan: editable adversary name in runner. +// Items verified: +// 1. Tap name in expanded card → TextField appears pre-filled +// 2. Type unique name, tap Done → card and collapsed row both update +// 3. Press Return → same as Done +// 4. Type name already in use → inline error appears +// 5. Clear field, tap Done → silently reverts +// 6. Tap Cancel → reverts without error +// 7. Collapse button accessible in normal (non-editing) state +// + +import XCTest + +final class RenameAdversaryUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-UITestResetState"] + app.launch() + XCTAssertTrue( + app.navigationBars["Party"].waitForExistence(timeout: 10), + "App should load and show Party screen") + } + + // MARK: - Test Plan Item 7: Collapse button accessible in normal state + + func testCollapseButtonAccessibleBeforeEditing() { + navigateToRunner() + expandFirstCard() + + let collapseButton = app.buttons["runner.adversary-card.collapse-button"] + XCTAssertTrue( + collapseButton.waitForExistence(timeout: 3), + "Collapse button should be visible in the card header") + XCTAssertTrue(collapseButton.isHittable, "Collapse button should be tappable") + } + + // MARK: - Test Plan Item 1: Tap name → TextField appears pre-filled + + func testTapNameShowsPrefilledTextField() { + navigateToRunner() + expandFirstCard() + + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 3), "Name button should be visible") + let preFilled = nameButton.label + XCTAssertFalse(preFilled.isEmpty, "Name button should have a non-empty label") + + nameButton.tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2), "Name field should appear") + XCTAssertEqual( + nameField.value as? String, preFilled, + "Name field should be pre-filled with the current name") + } + + // MARK: - Test Plan Item 2: Unique rename → Done → both views update + + func testRenameViaDonenButtonUpdatesDisplay() { + navigateToRunner() + expandFirstCard() + + app.buttons["runner.adversary-card.name"].tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2)) + replaceText(in: nameField, with: "Grimfang") + + app.buttons["Done"].firstMatch.tap() + + // Card header should show the new name + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 2)) + XCTAssertEqual(nameButton.label, "Grimfang", "Card header should show new name") + + // Collapsed row label also updates (collapse first to expose the row) + app.buttons["runner.adversary-card.collapse-button"].tap() + let row = app.buttons.matching(identifier: "runner.adversary-row").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 2)) + XCTAssertEqual(row.label, "Grimfang", "Collapsed row accessibility label should show new name") + } + + // MARK: - Test Plan Item 3: Return key commits rename + + func testReturnKeyCommitsRename() { + navigateToRunner() + expandFirstCard() + + app.buttons["runner.adversary-card.name"].tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2)) + replaceText(in: nameField, with: "Shadowclaw") + + nameField.typeText("\n") + + // Editing mode should exit and name should update + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 2)) + XCTAssertEqual(nameButton.label, "Shadowclaw", "Return key should commit the rename") + } + + // MARK: - Test Plan Item 5: Clear field → Done → silent revert + + func testEmptyFieldReverts() { + navigateToRunner() + expandFirstCard() + + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 3)) + let originalName = nameButton.label + + nameButton.tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2)) + replaceText(in: nameField, with: "") // clear to empty + + app.buttons["Done"].firstMatch.tap() + + // Editing exits, name reverts to original + let nameButtonAfter = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButtonAfter.waitForExistence(timeout: 2)) + XCTAssertEqual( + nameButtonAfter.label, originalName, + "Empty commit should revert to original name") + + // No error should remain + XCTAssertFalse( + app.staticTexts["runner.adversary-card.name-error"].exists, + "No error should appear for empty-field revert") + } + + // MARK: - Test Plan Item 6: Cancel → reverts without error + + func testCancelReverts() { + navigateToRunner() + expandFirstCard() + + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 3)) + let originalName = nameButton.label + + nameButton.tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2)) + replaceText(in: nameField, with: "SomethingElse") + + app.buttons["Cancel"].firstMatch.tap() + + let nameButtonAfter = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButtonAfter.waitForExistence(timeout: 2)) + XCTAssertEqual(nameButtonAfter.label, originalName, "Cancel should revert to original name") + XCTAssertFalse( + app.staticTexts["runner.adversary-card.name-error"].exists, + "No error should appear after Cancel") + } + + // MARK: - Test Plan Item 4: Duplicate name → inline error + + func testDuplicateNameShowsError() { + navigateToRunnerWithTwoSameAdversaries() + + // Both adversary rows should be present + let rows = app.buttons.matching(identifier: "runner.adversary-row") + XCTAssertTrue( + rows.element(boundBy: 1).waitForExistence(timeout: 5), "Two adversary rows expected") + + // Get the second slot's display name (its accessibility label = customName) + let secondSlotName = rows.element(boundBy: 1).label + + // Expand the first row + rows.firstMatch.tap() + + let nameButton = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButton.waitForExistence(timeout: 3)) + nameButton.tap() + + let nameField = app.textFields["runner.adversary-card.name-field"] + XCTAssertTrue(nameField.waitForExistence(timeout: 2)) + replaceText(in: nameField, with: secondSlotName) + + app.buttons["Done"].firstMatch.tap() + + // Error label should appear inline + let errorLabel = app.staticTexts["runner.adversary-card.name-error"] + XCTAssertTrue(errorLabel.waitForExistence(timeout: 2), "Inline error should appear") + XCTAssertEqual(errorLabel.label, "Name already in use") + + // TextField should still be visible (edit mode persists) + XCTAssertTrue( + app.textFields["runner.adversary-card.name-field"].exists, + "Name field should stay visible after rejected rename") + } + + // MARK: - Navigation helpers + + /// Navigate to the live runner with one adversary and one player. + private func navigateToRunner() { + addOnePlayer() + switchToEncountersTab() + createEncounter() + openFirstEncounter() + addAdversaryFromCompendium() + tapRunEncounter() + } + + /// Navigate to the live runner with two copies of the same adversary, + /// so both slots get auto-assigned customNames ("Name 1", "Name 2"). + private func navigateToRunnerWithTwoSameAdversaries() { + addOnePlayer() + switchToEncountersTab() + createEncounter() + openFirstEncounter() + addAdversaryFromCompendium() // adds first copy, sheet auto-closes + addAdversaryFromCompendium() // adds same (first) adversary again + tapRunEncounter() + } + + private func addOnePlayer() { + app.buttons["party.add-player-button"].tap() + XCTAssertTrue(app.textFields["party.form.name-field"].waitForExistence(timeout: 3)) + fillPlayerForm( + name: "Aric", hp: "6", stress: "6", evasion: "12", + major: "5", severe: "10", armor: "3") + app.buttons["party.form.commit-button"].tap() + XCTAssertTrue( + app.buttons.matching(identifier: "party.active-row").firstMatch + .waitForExistence(timeout: 5)) + } + + private func switchToEncountersTab() { + let tab = app.tabBars.buttons["Encounters"] + XCTAssertTrue(tab.waitForExistence(timeout: 3)) + tab.tap() + XCTAssertTrue(app.navigationBars["Encounters"].waitForExistence(timeout: 5)) + } + + private func createEncounter() { + let createButton = app.buttons.matching(identifier: "library.create-button").firstMatch + XCTAssertTrue(createButton.waitForExistence(timeout: 3)) + createButton.tap() + let confirm = app.alerts["New Encounter"].buttons["Create"] + XCTAssertTrue(confirm.waitForExistence(timeout: 3)) + confirm.tap() + } + + private func openFirstEncounter() { + let row = app.buttons.matching(identifier: "library.row").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 5)) + row.tap() + } + + /// Opens the compendium, taps the first adversary, adds it to the encounter, + /// and waits for the sheet to dismiss (auto-dismissed by showCompendium = false). + private func addAdversaryFromCompendium() { + let browseButton = app.buttons["builder.browse-compendium-button"] + XCTAssertTrue(browseButton.waitForExistence(timeout: 5)) + browseButton.tap() + + let firstRow = app.buttons.matching(identifier: "compendium.adversary-row").firstMatch + XCTAssertTrue(firstRow.waitForExistence(timeout: 5)) + firstRow.tap() + + let addButton = app.buttons["compendium.add-to-encounter-button"] + XCTAssertTrue(addButton.waitForExistence(timeout: 5)) + addButton.tap() + + // Sheet auto-dismisses (showCompendium = false in onSelect closure). + // Wait for browse button to reappear as confirmation. + XCTAssertTrue( + browseButton.waitForExistence(timeout: 5), "Builder should be visible after sheet dismisses") + } + + private func tapRunEncounter() { + let runButton = app.buttons["builder.run-button"] + XCTAssertTrue(runButton.waitForExistence(timeout: 5)) + XCTAssertTrue(runButton.isEnabled, "Run button should be enabled") + runButton.tap() + XCTAssertTrue( + app.collectionViews["runner.adversary-list"].waitForExistence(timeout: 5), + "Runner should appear") + } + + private func expandFirstCard() { + let row = app.buttons.matching(identifier: "runner.adversary-row").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 5), "Adversary row should be in runner") + row.tap() + XCTAssertTrue( + app.buttons["runner.adversary-card.collapse-button"].waitForExistence(timeout: 3), + "Card should expand") + } + + // MARK: - Text field helpers + + /// Replaces the text field's current value with `text`. + /// Triple-taps to select all, then types the replacement (or just deletes if empty). + private func replaceText(in field: XCUIElement, with text: String) { + field.tap(withNumberOfTaps: 3, numberOfTouches: 1) + if text.isEmpty { + field.typeText(XCUIKeyboardKey.delete.rawValue) + } else { + field.typeText(text) + } + } + + // MARK: - Player form helper + + private func fillPlayerForm( + name: String, hp: String, stress: String, evasion: String, + major: String, severe: String, armor: String + ) { + let fields: [(String, String)] = [ + ("party.form.name-field", name), + ("party.form.max-hp-field", hp), + ("party.form.max-stress-field", stress), + ("party.form.evasion-field", evasion), + ("party.form.threshold-major-field", major), + ("party.form.threshold-severe-field", severe), + ("party.form.armor-slots-field", armor), + ] + for (id, value) in fields { + let field = app.textFields[id] + field.tap() + field.typeText(value) + } + } +} From d60a7b59b61a899d78ee61981797f75bdbf79bcf Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 11 Apr 2026 18:26:58 -0700 Subject: [PATCH 3/3] fix: code review fixes for PR #76 and iOS 26 test infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdversaryRunnerCard: add @FocusState for immediate keyboard focus on rename, add AX identifiers to Done/Cancel buttons, clean up nameError init - AdversaryRunnerSection: include active conditions in collapsed row accessibilityLabel (PR #75 broke this by switching from accessibilityValue to an explicit label that omitted condition state) - EncounterUITestCase: new shared base class for RunnerUITests and RenameAdversaryUITests, eliminating ~180 lines of duplicated helpers - Navigation helpers: fix iOS 26 toolbar overflow — builder toolbar items collapse into OverflowBarButtonItem; use builder.add-adversary-button (in-form) for compendium and tap overflow before "Run Encounter" - replaceText: remove redundant initial tap on iOS; triple-tap alone handles both pre-focused (@FocusState) and unfocused states correctly All 9 iOS UI tests and all macOS unit tests pass. --- CLAUDE.md | 20 ++ Encounter/Views/AdversaryRunnerCard.swift | 9 +- Encounter/Views/AdversaryRunnerSection.swift | 16 +- EncounterUITests/AccessibilityTreeDump.swift | 3 +- EncounterUITests/EncounterUITestCase.swift | 195 ++++++++++++++++++ EncounterUITests/PartyManagementUITests.swift | 36 ++-- EncounterUITests/RenameAdversaryUITests.swift | 174 +--------------- EncounterUITests/RunnerUITests.swift | 116 +---------- 8 files changed, 272 insertions(+), 297 deletions(-) create mode 100644 EncounterUITests/EncounterUITestCase.swift diff --git a/CLAUDE.md b/CLAUDE.md index 571755d..b242fd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,25 @@ CLI-driven iteration. --- +## Code Changes + +When making bulk renames or refactors, do NOT make unrequested additional renames. Only change what was explicitly asked for. If you think something else should be renamed, ask first. + +--- + +## Interaction Style + +When working through a list of items (issues, review findings, renames), present them one at a time and wait for user input before proceeding to the next. + + +--- + +## Problem Solving + +When fixing build errors or investigating issues, always research the root cause before attempting a fix. Do not apply quick fixes without understanding the underlying problem (e.g., platform availability, import visibility rules). + +--- + ## Testing Tests use **Swift Testing** (`import Testing`), not XCTest. @@ -188,6 +207,7 @@ Test coverage priorities: 3. `Compendium` loading and lookup correctness 4. UI flows for encounter setup and live tracking (UITests) +Always run a clean build and full test suite after making changes. Never reuse stale test output or cached build results. Use `swift build` and `swift test` fresh each time. --- ## Architecture Decision Records diff --git a/Encounter/Views/AdversaryRunnerCard.swift b/Encounter/Views/AdversaryRunnerCard.swift index a78368f..8761471 100644 --- a/Encounter/Views/AdversaryRunnerCard.swift +++ b/Encounter/Views/AdversaryRunnerCard.swift @@ -24,7 +24,8 @@ struct AdversaryRunnerCard: View { @State private var isEditingName = false @State private var pendingName = "" - @State private var nameError: String? = nil + @State private var nameError: String? + @FocusState private var isNameFieldFocused: Bool private var displayName: String { let adversary = compendium.adversary(id: slot.adversaryID) @@ -43,12 +44,15 @@ struct AdversaryRunnerCard: View { if isEditingName { TextField("Name", text: $pendingName) .font(.headline) + .focused($isNameFieldFocused) .onSubmit { commitRename() } .accessibilityIdentifier("runner.adversary-card.name-field") Button("Done") { commitRename() } .buttonStyle(.borderless) + .accessibilityIdentifier("runner.adversary-card.rename-done-button") Button("Cancel") { cancelRename() } .buttonStyle(.borderless) + .accessibilityIdentifier("runner.adversary-card.rename-cancel-button") } else { Button(action: beginRename) { Text(displayName) @@ -125,6 +129,7 @@ struct AdversaryRunnerCard: View { pendingName = displayName nameError = nil isEditingName = true + isNameFieldFocused = true } private func commitRename() { @@ -134,6 +139,7 @@ struct AdversaryRunnerCard: View { return } if session.renameAdversary(id: slot.id, name: trimmed) { + isNameFieldFocused = false isEditingName = false nameError = nil } else { @@ -142,6 +148,7 @@ struct AdversaryRunnerCard: View { } private func cancelRename() { + isNameFieldFocused = false isEditingName = false nameError = nil } diff --git a/Encounter/Views/AdversaryRunnerSection.swift b/Encounter/Views/AdversaryRunnerSection.swift index 9a7b897..5de0c68 100644 --- a/Encounter/Views/AdversaryRunnerSection.swift +++ b/Encounter/Views/AdversaryRunnerSection.swift @@ -39,9 +39,19 @@ struct AdversaryRunnerSection: View { .disabled(slot.isDefeated) .accessibilityIdentifier("runner.adversary-row") .accessibilityLabel( - slot.isDefeated - ? "\(slot.customName ?? slot.adversaryID), defeated" - : (slot.customName ?? slot.adversaryID) + { + let name = slot.customName ?? slot.adversaryID + var parts: [String] = [name] + if slot.isDefeated { + parts.append("defeated") + } + if !slot.conditions.isEmpty { + let conditionNames = slot.conditions.map(\.displayName).sorted().joined( + separator: ", ") + parts.append("Conditions: \(conditionNames)") + } + return parts.joined(separator: ", ") + }() ) .accessibilityHint(slot.isDefeated ? "" : "Tap to expand") } diff --git a/EncounterUITests/AccessibilityTreeDump.swift b/EncounterUITests/AccessibilityTreeDump.swift index 0b726fc..69ec10d 100644 --- a/EncounterUITests/AccessibilityTreeDump.swift +++ b/EncounterUITests/AccessibilityTreeDump.swift @@ -108,7 +108,8 @@ final class AccessibilityTreeDump: XCTestCase { let createButton = app.buttons.matching(identifier: "library.create-button").firstMatch XCTAssertTrue(createButton.waitForExistence(timeout: 3), "library.create-button not found") XCTAssertTrue(createButton.isEnabled, "Create button should be enabled") - let result = "library.create-button found: true, enabled: \(createButton.isEnabled), frame: \(createButton.frame)\n" + let result = + "library.create-button found: true, enabled: \(createButton.isEnabled), frame: \(createButton.frame)\n" try? result.write(toFile: "/tmp/library_create_button.txt", atomically: true, encoding: .utf8) } diff --git a/EncounterUITests/EncounterUITestCase.swift b/EncounterUITests/EncounterUITestCase.swift new file mode 100644 index 0000000..340e740 --- /dev/null +++ b/EncounterUITests/EncounterUITestCase.swift @@ -0,0 +1,195 @@ +// +// EncounterUITestCase.swift +// EncounterUITests +// +// Base class for Encounter UI tests. Provides shared setUp, navigation +// helpers, and form utilities so test classes don't duplicate this logic. +// + +import XCTest + +class EncounterUITestCase: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-UITestResetState"] + app.launch() + XCTAssertTrue( + app.navigationBars["Party"].waitForExistence(timeout: 10), + "App should load and show Party screen within 10 seconds") + } + + // MARK: - Top-level navigation flows + + /// Navigates from the Party screen all the way into the live encounter runner. + /// + /// Flow: + /// 1. Add one player (required for "Run Encounter" to be enabled). + /// 2. Switch to Encounters tab. + /// 3. Create a new encounter. + /// 4. Open the encounter in the builder. + /// 5. Add one adversary from the compendium. + /// 6. Tap "Run Encounter". + func navigateToRunner() { + addOnePlayer() + switchToEncountersTab() + createEncounter() + openFirstEncounter() + addAdversaryFromCompendium() + tapRunEncounter() + } + + /// Same as `navigateToRunner`, but adds the same (first) adversary twice so + /// both slots receive auto-assigned `customName` values ("Name 1", "Name 2"). + func navigateToRunnerWithTwoSameAdversaries() { + addOnePlayer() + switchToEncountersTab() + createEncounter() + openFirstEncounter() + addAdversaryFromCompendium() + addAdversaryFromCompendium() + tapRunEncounter() + } + + /// Expands the first adversary row in the runner. + func expandFirstCard() { + let row = app.buttons.matching(identifier: "runner.adversary-row").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 5), "Adversary row should be in runner") + row.tap() + XCTAssertTrue( + app.buttons["runner.adversary-card.collapse-button"].waitForExistence(timeout: 3), + "Card should expand after tapping row") + } + + // MARK: - Step helpers + + func addOnePlayer( + name: String = "Aric", hp: String = "6", stress: String = "6", + evasion: String = "12", major: String = "5", severe: String = "10", armor: String = "3" + ) { + app.buttons["party.add-player-button"].tap() + XCTAssertTrue( + app.textFields["party.form.name-field"].waitForExistence(timeout: 3), + "Player form should appear") + fillPlayerForm( + name: name, hp: hp, stress: stress, evasion: evasion, + major: major, severe: severe, armor: armor) + app.buttons["party.form.commit-button"].tap() + XCTAssertTrue( + app.buttons.matching(identifier: "party.active-row").firstMatch + .waitForExistence(timeout: 5), + "Player should appear in active party") + } + + func switchToEncountersTab() { + let tab = app.tabBars.buttons["Encounters"] + XCTAssertTrue(tab.waitForExistence(timeout: 3)) + tab.tap() + XCTAssertTrue( + app.navigationBars["Encounters"].waitForExistence(timeout: 5), + "Encounters screen should appear") + } + + func createEncounter() { + let createButton = app.buttons.matching(identifier: "library.create-button").firstMatch + XCTAssertTrue(createButton.waitForExistence(timeout: 3)) + createButton.tap() + let confirm = app.alerts["New Encounter"].buttons["Create"] + XCTAssertTrue(confirm.waitForExistence(timeout: 3)) + confirm.tap() + } + + func openFirstEncounter() { + let row = app.buttons.matching(identifier: "library.row").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 5)) + row.tap() + } + + /// Opens the compendium browser, taps the first adversary, and adds it. + /// Uses the in-form "Add" button (builder.add-adversary-button) rather than the + /// toolbar button, because on iOS 26 with .toolbarRole(.editor) the toolbar items + /// collapse into an overflow "More" menu and are not directly hittable. + /// The sheet auto-dismisses via `showCompendium = false` in the builder's `onSelect` closure. + func addAdversaryFromCompendium() { + let addButton = app.buttons["builder.add-adversary-button"] + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), "Add adversary button should be visible") + addButton.tap() + + let firstRow = app.buttons.matching(identifier: "compendium.adversary-row").firstMatch + XCTAssertTrue(firstRow.waitForExistence(timeout: 5), "Compendium adversary list should appear") + firstRow.tap() + + let addToEncounterButton = app.buttons["compendium.add-to-encounter-button"] + XCTAssertTrue( + addToEncounterButton.waitForExistence(timeout: 5), "Add to Encounter button should appear") + addToEncounterButton.tap() + + // Sheet auto-closes; wait for the add button to confirm we're back on the builder. + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), + "Builder should be visible after compendium sheet dismisses") + } + + func tapRunEncounter() { + // On iOS 26 with .toolbarRole(.editor), the Run Encounter button is in the toolbar + // overflow menu (OverflowBarButtonItem / "More"). Menu items lose their AX identifiers + // and are only findable by label once the menu is open. + let overflowButton = app.buttons["OverflowBarButtonItem"] + XCTAssertTrue( + overflowButton.waitForExistence(timeout: 5), "Toolbar overflow button should be visible") + overflowButton.tap() + + let runButton = app.buttons["Run Encounter"] + XCTAssertTrue( + runButton.waitForExistence(timeout: 3), "Run Encounter button should appear in overflow menu") + XCTAssertTrue(runButton.isEnabled, "Run Encounter button should be enabled") + runButton.tap() + XCTAssertTrue( + app.collectionViews["runner.adversary-list"].waitForExistence(timeout: 5), + "Runner adversary list should appear") + } + + // MARK: - Form helpers + + func fillPlayerForm( + name: String, hp: String, stress: String, evasion: String, + major: String, severe: String, armor: String + ) { + let fields: [(String, String)] = [ + ("party.form.name-field", name), + ("party.form.max-hp-field", hp), + ("party.form.max-stress-field", stress), + ("party.form.evasion-field", evasion), + ("party.form.threshold-major-field", major), + ("party.form.threshold-severe-field", severe), + ("party.form.armor-slots-field", armor), + ] + for (id, value) in fields { + let field = app.textFields[id] + field.tap() + field.typeText(value) + } + } + + /// Replaces the text field's current value with `text`. + /// Selects all existing text, then types the replacement (or deletes if empty). + func replaceText(in field: XCUIElement, with text: String) { + #if os(iOS) + // Triple-tap selects all text in a UITextField. Works whether the field is + // already focused (via @FocusState) or not — no prior single-tap needed. + field.tap(withNumberOfTaps: 3, numberOfTouches: 1) + #else + field.tap() + field.typeKey("a", modifierFlags: .command) + #endif + if text.isEmpty { + field.typeText(XCUIKeyboardKey.delete.rawValue) + } else { + field.typeText(text) + } + } +} diff --git a/EncounterUITests/PartyManagementUITests.swift b/EncounterUITests/PartyManagementUITests.swift index 25e4505..cdf48af 100644 --- a/EncounterUITests/PartyManagementUITests.swift +++ b/EncounterUITests/PartyManagementUITests.swift @@ -92,8 +92,9 @@ final class PartyManagementUITests: XCTestCase { // stepper is still on-screen and the number-pad keyboard is not yet showing. incrementLevel(by: 2) - fillForm(name: "Aric", hp: "6", stress: "6", evasion: "12", - major: "5", severe: "10", armor: "3") + fillForm( + name: "Aric", hp: "6", stress: "6", evasion: "12", + major: "5", severe: "10", armor: "3") app.buttons["party.form.commit-button"].tap() // The active-party row Button's accessibility label is the concatenation of @@ -116,8 +117,9 @@ final class PartyManagementUITests: XCTestCase { // Set level to 5 (Tier 3) before filling text fields. incrementLevel(by: 4) - fillForm(name: "Lira", hp: "5", stress: "7", evasion: "14", - major: "4", severe: "8", armor: "2") + fillForm( + name: "Lira", hp: "5", stress: "7", evasion: "14", + major: "4", severe: "8", armor: "2") app.buttons["party.form.commit-button"].tap() // Open the edit form by tapping the party row. @@ -127,7 +129,8 @@ final class PartyManagementUITests: XCTestCase { // Verify the edit form shows level 5. let stepper = app.steppers["party.form.level-stepper"] - XCTAssertTrue(stepper.waitForExistence(timeout: 3), "Level stepper should be visible in edit form") + XCTAssertTrue( + stepper.waitForExistence(timeout: 3), "Level stepper should be visible in edit form") XCTAssertEqual(stepper.value as? String, "5", "Edit form should show the saved level of 5") } @@ -142,8 +145,9 @@ final class PartyManagementUITests: XCTestCase { app.textFields["party.form.name-field"].waitForExistence(timeout: 3), "Player form sheet should appear after tapping Add Player") - fillForm(name: "Aric", hp: "6", stress: "6", evasion: "12", - major: "5", severe: "10", armor: "3") + fillForm( + name: "Aric", hp: "6", stress: "6", evasion: "12", + major: "5", severe: "10", armor: "3") // The Add Another button sits in a safeAreaInset outside the Form scroll view so // it is always pinned above the number pad keyboard — no keyboard dismissal step @@ -174,14 +178,16 @@ final class PartyManagementUITests: XCTestCase { // returns the placeholder text as the element's value — so "empty" means // value == placeholderValue, not value == "". let nameField = app.textFields["party.form.name-field"] - XCTAssertTrue(nameField.waitForExistence(timeout: 3), "Name field should be visible after scrolling to top") + XCTAssertTrue( + nameField.waitForExistence(timeout: 3), "Name field should be visible after scrolling to top") XCTAssertEqual( nameField.value as? String, nameField.placeholderValue, "Name field should be empty (showing placeholder) after Add Another resets the form") // Fill the second player and commit. - fillForm(name: "Lira", hp: "5", stress: "7", evasion: "14", - major: "4", severe: "8", armor: "2") + fillForm( + name: "Lira", hp: "5", stress: "7", evasion: "14", + major: "4", severe: "8", armor: "2") app.buttons["party.form.commit-button"].tap() // Both players must appear as active-party rows. @@ -196,8 +202,9 @@ final class PartyManagementUITests: XCTestCase { /// Encounter builder after the user switches tabs and opens a builder. @MainActor func testPartyMembersVisibleInBuilderRoster() throws { - addOnePlayer(name: "Aric", hp: "6", stress: "6", evasion: "12", - major: "5", severe: "10", armor: "3") + addOnePlayer( + name: "Aric", hp: "6", stress: "6", evasion: "12", + major: "5", severe: "10", armor: "3") // Switch to Encounters tab. let encountersTab = app.tabBars.buttons["Encounters"] @@ -281,8 +288,9 @@ final class PartyManagementUITests: XCTestCase { ) { app.buttons["party.add-player-button"].tap() _ = app.textFields["party.form.name-field"].waitForExistence(timeout: 3) - fillForm(name: name, hp: hp, stress: stress, evasion: evasion, - major: major, severe: severe, armor: armor) + fillForm( + name: name, hp: hp, stress: stress, evasion: evasion, + major: major, severe: severe, armor: armor) app.buttons["party.form.commit-button"].tap() _ = app.buttons.matching(identifier: "party.active-row").firstMatch .waitForExistence(timeout: 5) diff --git a/EncounterUITests/RenameAdversaryUITests.swift b/EncounterUITests/RenameAdversaryUITests.swift index 3f510ce..01e9b47 100644 --- a/EncounterUITests/RenameAdversaryUITests.swift +++ b/EncounterUITests/RenameAdversaryUITests.swift @@ -15,19 +15,7 @@ import XCTest -final class RenameAdversaryUITests: XCTestCase { - - var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launchArguments = ["-UITestResetState"] - app.launch() - XCTAssertTrue( - app.navigationBars["Party"].waitForExistence(timeout: 10), - "App should load and show Party screen") - } +final class RenameAdversaryUITests: EncounterUITestCase { // MARK: - Test Plan Item 7: Collapse button accessible in normal state @@ -64,7 +52,7 @@ final class RenameAdversaryUITests: XCTestCase { // MARK: - Test Plan Item 2: Unique rename → Done → both views update - func testRenameViaDonenButtonUpdatesDisplay() { + func testRenameViaDoneButtonUpdatesDisplay() { navigateToRunner() expandFirstCard() @@ -74,14 +62,14 @@ final class RenameAdversaryUITests: XCTestCase { XCTAssertTrue(nameField.waitForExistence(timeout: 2)) replaceText(in: nameField, with: "Grimfang") - app.buttons["Done"].firstMatch.tap() + app.buttons["runner.adversary-card.rename-done-button"].tap() - // Card header should show the new name + // Card header should show the new name. let nameButton = app.buttons["runner.adversary-card.name"] XCTAssertTrue(nameButton.waitForExistence(timeout: 2)) XCTAssertEqual(nameButton.label, "Grimfang", "Card header should show new name") - // Collapsed row label also updates (collapse first to expose the row) + // Collapsed row label also updates (collapse first to expose the row). app.buttons["runner.adversary-card.collapse-button"].tap() let row = app.buttons.matching(identifier: "runner.adversary-row").firstMatch XCTAssertTrue(row.waitForExistence(timeout: 2)) @@ -102,7 +90,6 @@ final class RenameAdversaryUITests: XCTestCase { nameField.typeText("\n") - // Editing mode should exit and name should update let nameButton = app.buttons["runner.adversary-card.name"] XCTAssertTrue(nameButton.waitForExistence(timeout: 2)) XCTAssertEqual(nameButton.label, "Shadowclaw", "Return key should commit the rename") @@ -122,18 +109,15 @@ final class RenameAdversaryUITests: XCTestCase { let nameField = app.textFields["runner.adversary-card.name-field"] XCTAssertTrue(nameField.waitForExistence(timeout: 2)) - replaceText(in: nameField, with: "") // clear to empty + replaceText(in: nameField, with: "") - app.buttons["Done"].firstMatch.tap() + app.buttons["runner.adversary-card.rename-done-button"].tap() - // Editing exits, name reverts to original let nameButtonAfter = app.buttons["runner.adversary-card.name"] XCTAssertTrue(nameButtonAfter.waitForExistence(timeout: 2)) XCTAssertEqual( nameButtonAfter.label, originalName, "Empty commit should revert to original name") - - // No error should remain XCTAssertFalse( app.staticTexts["runner.adversary-card.name-error"].exists, "No error should appear for empty-field revert") @@ -155,7 +139,7 @@ final class RenameAdversaryUITests: XCTestCase { XCTAssertTrue(nameField.waitForExistence(timeout: 2)) replaceText(in: nameField, with: "SomethingElse") - app.buttons["Cancel"].firstMatch.tap() + app.buttons["runner.adversary-card.rename-cancel-button"].tap() let nameButtonAfter = app.buttons["runner.adversary-card.name"] XCTAssertTrue(nameButtonAfter.waitForExistence(timeout: 2)) @@ -170,15 +154,14 @@ final class RenameAdversaryUITests: XCTestCase { func testDuplicateNameShowsError() { navigateToRunnerWithTwoSameAdversaries() - // Both adversary rows should be present let rows = app.buttons.matching(identifier: "runner.adversary-row") XCTAssertTrue( rows.element(boundBy: 1).waitForExistence(timeout: 5), "Two adversary rows expected") - // Get the second slot's display name (its accessibility label = customName) + // Get the second slot's display name (its accessibility label = customName). let secondSlotName = rows.element(boundBy: 1).label - // Expand the first row + // Expand the first row. rows.firstMatch.tap() let nameButton = app.buttons["runner.adversary-card.name"] @@ -189,149 +172,14 @@ final class RenameAdversaryUITests: XCTestCase { XCTAssertTrue(nameField.waitForExistence(timeout: 2)) replaceText(in: nameField, with: secondSlotName) - app.buttons["Done"].firstMatch.tap() + app.buttons["runner.adversary-card.rename-done-button"].tap() - // Error label should appear inline let errorLabel = app.staticTexts["runner.adversary-card.name-error"] XCTAssertTrue(errorLabel.waitForExistence(timeout: 2), "Inline error should appear") XCTAssertEqual(errorLabel.label, "Name already in use") - // TextField should still be visible (edit mode persists) XCTAssertTrue( app.textFields["runner.adversary-card.name-field"].exists, "Name field should stay visible after rejected rename") } - - // MARK: - Navigation helpers - - /// Navigate to the live runner with one adversary and one player. - private func navigateToRunner() { - addOnePlayer() - switchToEncountersTab() - createEncounter() - openFirstEncounter() - addAdversaryFromCompendium() - tapRunEncounter() - } - - /// Navigate to the live runner with two copies of the same adversary, - /// so both slots get auto-assigned customNames ("Name 1", "Name 2"). - private func navigateToRunnerWithTwoSameAdversaries() { - addOnePlayer() - switchToEncountersTab() - createEncounter() - openFirstEncounter() - addAdversaryFromCompendium() // adds first copy, sheet auto-closes - addAdversaryFromCompendium() // adds same (first) adversary again - tapRunEncounter() - } - - private func addOnePlayer() { - app.buttons["party.add-player-button"].tap() - XCTAssertTrue(app.textFields["party.form.name-field"].waitForExistence(timeout: 3)) - fillPlayerForm( - name: "Aric", hp: "6", stress: "6", evasion: "12", - major: "5", severe: "10", armor: "3") - app.buttons["party.form.commit-button"].tap() - XCTAssertTrue( - app.buttons.matching(identifier: "party.active-row").firstMatch - .waitForExistence(timeout: 5)) - } - - private func switchToEncountersTab() { - let tab = app.tabBars.buttons["Encounters"] - XCTAssertTrue(tab.waitForExistence(timeout: 3)) - tab.tap() - XCTAssertTrue(app.navigationBars["Encounters"].waitForExistence(timeout: 5)) - } - - private func createEncounter() { - let createButton = app.buttons.matching(identifier: "library.create-button").firstMatch - XCTAssertTrue(createButton.waitForExistence(timeout: 3)) - createButton.tap() - let confirm = app.alerts["New Encounter"].buttons["Create"] - XCTAssertTrue(confirm.waitForExistence(timeout: 3)) - confirm.tap() - } - - private func openFirstEncounter() { - let row = app.buttons.matching(identifier: "library.row").firstMatch - XCTAssertTrue(row.waitForExistence(timeout: 5)) - row.tap() - } - - /// Opens the compendium, taps the first adversary, adds it to the encounter, - /// and waits for the sheet to dismiss (auto-dismissed by showCompendium = false). - private func addAdversaryFromCompendium() { - let browseButton = app.buttons["builder.browse-compendium-button"] - XCTAssertTrue(browseButton.waitForExistence(timeout: 5)) - browseButton.tap() - - let firstRow = app.buttons.matching(identifier: "compendium.adversary-row").firstMatch - XCTAssertTrue(firstRow.waitForExistence(timeout: 5)) - firstRow.tap() - - let addButton = app.buttons["compendium.add-to-encounter-button"] - XCTAssertTrue(addButton.waitForExistence(timeout: 5)) - addButton.tap() - - // Sheet auto-dismisses (showCompendium = false in onSelect closure). - // Wait for browse button to reappear as confirmation. - XCTAssertTrue( - browseButton.waitForExistence(timeout: 5), "Builder should be visible after sheet dismisses") - } - - private func tapRunEncounter() { - let runButton = app.buttons["builder.run-button"] - XCTAssertTrue(runButton.waitForExistence(timeout: 5)) - XCTAssertTrue(runButton.isEnabled, "Run button should be enabled") - runButton.tap() - XCTAssertTrue( - app.collectionViews["runner.adversary-list"].waitForExistence(timeout: 5), - "Runner should appear") - } - - private func expandFirstCard() { - let row = app.buttons.matching(identifier: "runner.adversary-row").firstMatch - XCTAssertTrue(row.waitForExistence(timeout: 5), "Adversary row should be in runner") - row.tap() - XCTAssertTrue( - app.buttons["runner.adversary-card.collapse-button"].waitForExistence(timeout: 3), - "Card should expand") - } - - // MARK: - Text field helpers - - /// Replaces the text field's current value with `text`. - /// Triple-taps to select all, then types the replacement (or just deletes if empty). - private func replaceText(in field: XCUIElement, with text: String) { - field.tap(withNumberOfTaps: 3, numberOfTouches: 1) - if text.isEmpty { - field.typeText(XCUIKeyboardKey.delete.rawValue) - } else { - field.typeText(text) - } - } - - // MARK: - Player form helper - - private func fillPlayerForm( - name: String, hp: String, stress: String, evasion: String, - major: String, severe: String, armor: String - ) { - let fields: [(String, String)] = [ - ("party.form.name-field", name), - ("party.form.max-hp-field", hp), - ("party.form.max-stress-field", stress), - ("party.form.evasion-field", evasion), - ("party.form.threshold-major-field", major), - ("party.form.threshold-severe-field", severe), - ("party.form.armor-slots-field", armor), - ] - for (id, value) in fields { - let field = app.textFields[id] - field.tap() - field.typeText(value) - } - } } diff --git a/EncounterUITests/RunnerUITests.swift b/EncounterUITests/RunnerUITests.swift index 6e0a86c..d146e28 100644 --- a/EncounterUITests/RunnerUITests.swift +++ b/EncounterUITests/RunnerUITests.swift @@ -15,19 +15,7 @@ import XCTest -final class RunnerUITests: XCTestCase { - - var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launchArguments = ["-UITestResetState"] - app.launch() - XCTAssertTrue( - app.navigationBars["Party"].waitForExistence(timeout: 10), - "App should load and show Party screen within 10 seconds") - } +final class RunnerUITests: EncounterUITestCase { // MARK: - Condition visibility in collapsed row @@ -104,106 +92,4 @@ final class RunnerUITests: XCTestCase { "Stress button should still be visible after incrementing stress") } - // MARK: - Navigation helper - - /// Navigates from the Party screen all the way into the live encounter runner. - /// - /// Flow: - /// 1. Add one player (required for "Run Encounter" to be enabled). - /// 2. Switch to Encounters tab. - /// 3. Create a new encounter. - /// 4. Open the encounter in the builder. - /// 5. Add one adversary from the compendium. - /// 6. Tap "Run Encounter". - private func navigateToRunner() { - // Step 1: Add one player. - app.buttons["party.add-player-button"].tap() - XCTAssertTrue( - app.textFields["party.form.name-field"].waitForExistence(timeout: 3), - "Player form should appear") - fillPlayerForm( - name: "Aric", hp: "6", stress: "6", evasion: "12", - major: "5", severe: "10", armor: "3") - app.buttons["party.form.commit-button"].tap() - XCTAssertTrue( - app.buttons.matching(identifier: "party.active-row").firstMatch - .waitForExistence(timeout: 5), - "Player should appear in active party") - - // Step 2: Switch to Encounters. - let encountersTab = app.tabBars.buttons["Encounters"] - XCTAssertTrue(encountersTab.waitForExistence(timeout: 3)) - encountersTab.tap() - XCTAssertTrue( - app.navigationBars["Encounters"].waitForExistence(timeout: 5), - "Encounters screen should appear") - - // Step 3: Create encounter. - let createButton = app.buttons.matching(identifier: "library.create-button").firstMatch - XCTAssertTrue(createButton.waitForExistence(timeout: 3)) - createButton.tap() - let createAlertButton = app.alerts["New Encounter"].buttons["Create"] - XCTAssertTrue(createAlertButton.waitForExistence(timeout: 3)) - createAlertButton.tap() - - // Step 4: Open the encounter in the builder. - let firstRow = app.buttons.matching(identifier: "library.row").firstMatch - XCTAssertTrue(firstRow.waitForExistence(timeout: 5)) - firstRow.tap() - - // Step 5: Add an adversary from the compendium. - let browseButton = app.buttons["builder.browse-compendium-button"] - XCTAssertTrue( - browseButton.waitForExistence(timeout: 5), "Browse Compendium button should be visible") - browseButton.tap() - - // Wait for compendium list, then tap the first adversary row. - let firstAdversaryRow = app.buttons.matching(identifier: "compendium.adversary-row").firstMatch - XCTAssertTrue( - firstAdversaryRow.waitForExistence(timeout: 5), - "Compendium adversary list should appear") - firstAdversaryRow.tap() - - // Tap "Add to Encounter" on the detail view. - let addButton = app.buttons["compendium.add-to-encounter-button"] - XCTAssertTrue( - addButton.waitForExistence(timeout: 5), "Add to Encounter button should be visible") - addButton.tap() - - // Step 6: Run the encounter. - let runButton = app.buttons["builder.run-button"] - XCTAssertTrue( - runButton.waitForExistence(timeout: 5), "Run Encounter button should be visible and enabled") - XCTAssertTrue( - runButton.isEnabled, - "Run Encounter button should be enabled with one player and one adversary") - runButton.tap() - - // Wait for the runner adversary list to appear. - XCTAssertTrue( - app.collectionViews["runner.adversary-list"].waitForExistence(timeout: 5), - "Runner adversary list should appear after tapping Run Encounter") - } - - // MARK: - Form helper - - private func fillPlayerForm( - name: String, hp: String, stress: String, evasion: String, - major: String, severe: String, armor: String - ) { - let fields: [(String, String)] = [ - ("party.form.name-field", name), - ("party.form.max-hp-field", hp), - ("party.form.max-stress-field", stress), - ("party.form.evasion-field", evasion), - ("party.form.threshold-major-field", major), - ("party.form.threshold-severe-field", severe), - ("party.form.armor-slots-field", armor), - ] - for (id, value) in fields { - let field = app.textFields[id] - field.tap() - field.typeText(value) - } - } }