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 1ec8cc9..8761471 100644 --- a/Encounter/Views/AdversaryRunnerCard.swift +++ b/Encounter/Views/AdversaryRunnerCard.swift @@ -22,6 +22,11 @@ struct AdversaryRunnerCard: View { let compendium: Compendium let onCollapse: () -> Void + @State private var isEditingName = false + @State private var pendingName = "" + @State private var nameError: String? + @FocusState private var isNameFieldFocused: Bool + private var displayName: String { let adversary = compendium.adversary(id: slot.adversaryID) return slot.customName ?? adversary?.name ?? "Unknown (\(slot.adversaryID))" @@ -36,13 +41,39 @@ 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) + .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) + .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 +124,34 @@ struct AdversaryRunnerCard: View { } .padding(.vertical, 8) } + + private func beginRename() { + pendingName = displayName + nameError = nil + isEditingName = true + isNameFieldFocused = true + } + + private func commitRename() { + let trimmed = pendingName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + cancelRename() + return + } + if session.renameAdversary(id: slot.id, name: trimmed) { + isNameFieldFocused = false + isEditingName = false + nameError = nil + } else { + nameError = "Name already in use" + } + } + + private func cancelRename() { + isNameFieldFocused = false + isEditingName = false + nameError = nil + } } #Preview { 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/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 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 new file mode 100644 index 0000000..01e9b47 --- /dev/null +++ b/EncounterUITests/RenameAdversaryUITests.swift @@ -0,0 +1,185 @@ +// +// 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: EncounterUITestCase { + + // 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 testRenameViaDoneButtonUpdatesDisplay() { + 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["runner.adversary-card.rename-done-button"].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") + + 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: "") + + app.buttons["runner.adversary-card.rename-done-button"].tap() + + let nameButtonAfter = app.buttons["runner.adversary-card.name"] + XCTAssertTrue(nameButtonAfter.waitForExistence(timeout: 2)) + XCTAssertEqual( + nameButtonAfter.label, originalName, + "Empty commit should revert to original name") + 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["runner.adversary-card.rename-cancel-button"].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() + + 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["runner.adversary-card.rename-done-button"].tap() + + 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") + + XCTAssertTrue( + app.textFields["runner.adversary-card.name-field"].exists, + "Name field should stay visible after rejected rename") + } +} 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) - } - } }