diff --git a/.claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md b/.claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md index e778d4d21..01f2ba8c6 100644 --- a/.claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md +++ b/.claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md @@ -1,12 +1,10 @@ # UI test rewrite for the tab-bar UI Shipping the tab-bar UI to everyone (`BetaFlags.Option.newUI` → `.shipped`) removed the -v1 scanner chrome that the XCUITest suite navigated through. The affected tests are -skipped via `BaseUITestCase.skipPendingTabBarRewrite(_:)` so the release can run; this -is the map for putting them back. - -Grep `skipPendingTabBarRewrite` for the live list. The helper is deleted with the last -call site. +v1 scanner chrome that the XCUITest suite navigated through. Every affected test has +been rewritten or dropped, and `BaseUITestCase.skipPendingTabBarRewrite(_:)` went with +the last call site. This is the record of where each flow moved and what the v2 routes +cost the suite. ## What moved @@ -18,8 +16,11 @@ call site. | `scan-tips-button` | Chat tab (`app.buttons["Chat"]`) — embedded, so no `navigationBars["Tips"]` Close button | | `scan-discover-button` | Wallet tab → "Discover Currencies" tile (a push, not a sheet) | | `discover-create-currency-card` promo | Wallet tab → "Create a Currency" tile — `CurrencyDiscoveryScreen.hidesPromo` hides the card | +| Tips intro → name → photo → tipcard (profile creation) | nothing — onboarding sets the name and the You tab draws the card. The name is editable at You → My Account → **Change Display Name**. | | Settings "Add Money" / "Withdraw Money" rows | Wallet tab tiles of the same name | +| `CurrencyInfoScreen` "Transaction History" button | the **"Recent"** section header (`RecentActivitySection.onShowAll`); the rows under it are a non-interactive preview | | `CurrencyInfoScreen` "Buy" / "Sell" / "Give" footer | `CurrencyInfoContentV2` action tiles — **Give / Convert / Withdraw** for a currency you hold, **Get** for one you don't. No Buy, no Sell. | +| Buy paying with reserves / Sell to Dollars | **Convert**, from either end. Open the balance you are paying *from* and convert to the one you want: token → Dollars replaces Sell, Dollars → token replaces buy-with-reserves, token → token replaces buy-with-currency. | ## Gotchas @@ -28,22 +29,76 @@ call site. `assertMainScreenReached()` (now the Wallet tab) only holds at a tab root — pop first. - **The You tab's settings rows sit below the fold.** They render under the tip card, so scroll them into view (`scrollUpToAndTap(_:in:)`) rather than tapping blind. -- **`GiveDiscoverGateRegressionTests` tests behavior that no longer exists.** USDF is - giveable now (`BetaFlags.allowsDollarsGive`), so `GiveCashGate.discoverCurrencies` is - unreachable and the "No Community Currencies Yet" dialog never shows. Delete the test - with the phase-2 teardown rather than rewriting it. -- **`BaseUITestCase.navigateToGiveAmount()` still taps the v1 Cash button.** Its only - callers are skipped; rewrite it against whichever give entry the tests should cover. +- **`GiveCashGate` has no v2 entry from a tab root.** `ScanBottomBar` — the only caller + that gated a *give* — renders under `if !isEmbedded`, and the Scan tab embeds + `ScanScreen`. The gate still fires from a chat's Send Cash (`ConversationScreen`), + `TipFlow`, and the give deeplink, none of which a fresh empty account can reach. So + both dialog regressions built on the Cash button lose their subject, and both are + deleted. `GiveDiscoverGateRegressionTests` doubly so, since USDF is giveable now + (`BetaFlags.allowsDollarsGive`) and `GiveCashGate.discoverCurrencies` is unreachable + either way. `GiveRegressionTests` covered the "No Balance Yet" gate: an empty account + has no currency card, so it cannot reach the Give tile that would raise it. +- **The Wallet tiles are gated on funding, and a $0 account has no way around it.** + `WalletScreen` draws `walletTiles` only when `session.hasEverAddedMoney()` + (`holdsBalance || database.hasEverAddedMoney()`); an unfunded account gets the + new-user tutorial in their place. That gate collides with the two add-money + regressions, which need an empty balance by definition. The buy gate has no way out, + so `AddMoneyGateRegressionTests` is deleted: `flipcash://discover` reaches the same + destination, but `app.open` relaunches the app and a freshly created account does not + survive the relaunch — the app comes back on "Create a New Account" — while both + standing accounts hold displayable USDF, so `BuyAmountViewModel.paymentOptions` is + non-empty and the button reads Next. The gate itself is live, and a spent-down + fixture would restore the test. The creation gate survives because `shouldAddMoneyBeforeLaunch` is a *shortfall* check, + not a $0 check: the standing account holds money but not the launch cost + (`newCurrencyPurchaseAmount` + `newCurrencyFeeAmount`), so Get Started still raises the + prompt. That is fixture-dependent, so the test skips if the account can afford it. +- **Currency creation has exactly two doors, both on the funded path.** + `.currencyCreationSummary` is pushed from the Wallet tile and from Discover's promo + card, which `CurrencyDiscoveryScreen.hidesPromo` hides in v2. There is no deeplink. +- **Per-token history moved into the "Recent" header.** `CurrencyInfoContentV2` has no + "Transaction History" button; the header button is the only way in, and it sits below + the hero card and the action tiles, so it needs scrolling into view. +- **The Access Key row is on Advanced, not My Account.** `SettingsMyAccountScreen` says + so in its own header doc: it keeps Access Key, Log Out and Delete Account off itself, + and holds only Change Display Name, Blocked, and the beta-gated Switch Accounts. +- **Profile creation has no entry left, in either UI.** `OnboardingNameScreen` is + mandatory after the access key, so every account reaches the app already named. Both + name-less prompts — `TipsIntroScreen.start-receiving-tips-button` and the You tab's + `you-start-receiving-tips-button` — are gated on an empty `profile.displayName` and + so are unreachable from a test. On top of that, `TipsScreen(isEmbedded: true)` renders + `TipConversationsScreen` unconditionally, so the Chat tab never shows the intro at all. + The reachable name route is You → My Account → **Change Display Name** + (`.changeDisplayName` → `ProfileNameScreen(completion: .back)`), which pops just itself + and lands back on My Account, not on the tab root. +- **The profile photo step is dead in the app, not just in v2.** `ProfileNameScreen`'s + `.tipcard` completion pushes straight past it — "the card omits the profile photo" — + so `.profilePhoto` has no caller at all. `selectFirstPhotoFromLibrary` went with its + last caller. +- **The embedded Chats list has no tip-card row and no toolbar.** `isEmbedded` drops the + `show-my-tipcard-button` cell (the card has its own tab) and hides the navigation bar, + so `navigationBars["Tips"].buttons["Close"]` does not exist and every cell in the list + is a conversation — page objects must not skip a leading row. - **A wallet card opens `CurrencyInfoScreen` as an overlay, not a push.** `TokenCardStack` reports the tap through `onCardTap`; the wallet lifts the page over the deck with a close box instead of a back chevron, so there is no `navigationBars` entry to wait on. - **The v1 buy/sell entries are gone from an owned currency.** `CurrencyInfoContentV2` gates its tiles on `isOwned` (balance has displayable value), and the owned branch is - Give/Convert/Withdraw. Buying more of a currency you already hold has no entry point - there, and buy pushes `.buyCurrency(mint)` onto the stack rather than presenting the - `.buy` nested sheet the reserves test asserts. Deciding what the v2 equivalents should - assert is a product question, not a selector swap — `BuyReservesRegressionTests`, - `BuyWithCurrencyRegressionTests` and `CurrencySellRegressionTests` are skipped on that. + Give/Convert/Withdraw. The replacement for both is **Convert**, entered from the + balance you are paying *from* rather than the one you are acquiring — so a test buys + more of a currency it already holds by opening Dollars and converting into it. +- **Convert only offers balances you already hold.** `ConvertAmountViewModel.destinationOptions` + is `session.balances(for:)` minus the source, so convert cannot acquire a currency the + account has never held — that is what **Get** (`.buyCurrency(mint)`, on an unheld + currency) is for. The defaults matter for tests: a non-Dollars source defaults to + Dollars, and a Dollars source defaults to the largest other holding, so only a + token→token convert has to open the picker. +- **A finished convert lands on the Wallet, not back on the currency.** + `ConvertFlowDestinationView` gives the processing screen a `dismissParentContainer` + that calls `popToRoot()` + `dismissExpandedCard()`. Assert the Wallet root after OK. +- **Convert is pushed, so the v1 nested-sheet dismissal regressions can't recur.** + `BuyReservesRegressionTests` swiped down on the processing screen to prove the `.buy` + sheet survived; there is no sheet in the convert stack, so that assertion was dropped + rather than ported. ## Rewritten so far @@ -54,3 +109,35 @@ call site. verified-contact gate *before* "Amount to Add". - `WalletUsdfRowRegressionTests` — the v2 deck's cards carry `currency-row` / `currency-row-usdf`, so the wallet page object matches again. +- The You-tab settings group — `AccessKeyBackupSmokeTests` (×3), `BlockedUsersSmokeTests`, + `ApplicationLogsRegressionTests` — enters through the You tab and scrolls to the row. + Access Key and Application Logs are on **Advanced**; Blocked is on **My Account**. + `SettingsUIScreen` lost `addMoneyButton` / `withdrawMoneyButton` with the rows. +- The wallet money-tile group — `WithdrawSmokeTests`, + `WithdrawPickerEmptyRegressionTests`, `DepositSmokeTests` (×2) — enters through + `wallet-tile-withdraw-money` / `wallet-tile-add-money` instead of the Settings rows. +- `GiveSmokeTests` and `CashLinkRegressionTests` — `navigateToGiveAmount()` now goes + Wallet → first currency card → its **Give** tile. The keypad pops itself as the bill + appears (`GiveScreen.onBillPresented`, when `isPushed`), so both flows end on + `CurrencyInfoScreen` rather than a tab root — the cash link reaches its history from + there without a second trip through the wallet. +- The chat group — `BlockUnblockSmokeTests` now blocks from the Chat tab and unblocks + through You › My Account › Blocked; its `tearDown` unblock tapped the removed Settings + button, so it had been silently no-opping. `ProfileCreationSmokeTests` became + `DisplayNameSmokeTests`: a fresh account's card on the You tab, then a rename through + My Account. The intro screen and the photo step were dropped, not re-routed — neither + has a caller left. +- The three buy/sell tests, rewritten as the convert routes that replaced them and + renamed for what they now exercise: `CurrencySellRegressionTests` → + `ConvertToDollarsRegressionTests`, `BuyReservesRegressionTests` → + `ConvertFromDollarsRegressionTests`, `BuyWithCurrencyRegressionTests` → + `ConvertBetweenTokensRegressionTests`. `CurrencyPickerSheet` rows gained + `currency-picker-row` / `currency-picker-row-usdf`; `SellConfirmationScreen` went with + the v1 sell sheet. +- The discover group — `DiscoverCurrenciesSmokeTests` checks the Discover Currencies and + Create a Currency tiles side by side (v1 reached creation *through* Discover's promo + card), and the currency-creation gate moved to its own + `CurrencyCreationGateRegressionTests`. Both now need the standing account, because + both entries are Wallet tiles and the tiles are gated on funding — see below. + `AddMoneyGateRegressionTests`' buy gate has no fixture that can reach it, so it is + deleted alongside the give gates. diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 76cf4f875..892a9cdc8 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "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/code-payments/ocp-client-protocol", "state" : { - "revision" : "7ebdcdff462307abd2bb7548010d455e6eca1f3a", - "version" : "0.1.0" + "revision" : "b08adb951fcaaa463da21a4dfaa775577254d052", + "version" : "0.2.0" } }, { diff --git a/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift b/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift index 231a93e61..30da3e6a3 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift @@ -38,6 +38,14 @@ struct CurrencyPickerSheet: View { row(for: balance) } .buttonStyle(.plain) + // Dollars carries its own identifier so a test can pick + // it, or any token, without depending on where balances + // sort. + .accessibilityIdentifier( + balance.stored.mint == .usdf + ? "currency-picker-row-usdf" + : "currency-picker-row" + ) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) diff --git a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift deleted file mode 100644 index b1a3cce5d..000000000 --- a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// AddMoneyGateRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression tests for the reserves-only gates: an account with no USDF must -/// route through "No Balance Yet" → Add Money instead of entering the buy or -/// create flow. Uses fresh-account creation for a guaranteed $0 balance, so -/// no auth keys are required. -final class AddMoneyGateRegressionTests: BaseUITestCase { - - func testBuyWithNoAssets_offersAddMoneyOnAmountEntry() throws { - try skipPendingTabBarRewrite("Discover moved from the scanner bottom bar to a Wallet tile") - - let addMoney = AddMoneyStartScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - - createFreshAccount() - - // Discover → first leaderboard currency → CurrencyInfoScreen. - waitAndTap(app.buttons["scan-discover-button"]) - waitUntilHittableAndTap( - app.buttons.matching(identifier: "discover-leaderboard-row").firstMatch, - "Expected the Discover leaderboard to list at least one currency" - ) - currencyInfo.assertReached() - - // Buy always opens the amount sheet; on a $0 account the action - // button becomes an Add Money CTA instead of Next. - waitAndTap(currencyInfo.buyButton) - XCTAssertTrue( - app.navigationBars["Amount"].waitForExistence(timeout: 10), - "The buy amount sheet must open even when the account has no balance" - ) - - // Add Money → the Add Money With picker. This flow enters from - // Discover, so the sheet's CTA is the only Add Money button on screen. - waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch) - addMoney.assertMethodPickerReached() - } - - func testCreateCurrencyWithNoAssets_gatesOnAddMoney() throws { - try skipPendingTabBarRewrite("currency creation starts from a Wallet tile now, not the Discover promo card") - - let addMoney = AddMoneyStartScreen(app: app) - - createFreshAccount() - - // Discover → promo card → Create Your Currency summary. - waitAndTap(app.buttons["scan-discover-button"]) - waitUntilHittableAndTap( - app.buttons["discover-create-currency-card"], - "Expected the Create-Your-Own-Currency promo card" - ) - XCTAssertTrue( - app.navigationBars["Create Your Currency"].waitForExistence(timeout: 10), - "Expected the currency creation summary screen" - ) - - // Get Started on a $0 account must gate on Add Money, not enter the wizard. - waitUntilHittableAndTap(app.buttons["Get Started"]) - addMoney.assertNoBalanceReached() - XCTAssertTrue( - app.staticTexts["Add money to create a currency"].exists, - "Expected the create-context subtitle on the No Balance prompt" - ) - - // Add Money → the Add Money With picker. - addMoney.tapAddMoney(from: self) - addMoney.assertMethodPickerReached() - } -} diff --git a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift index 147f265cf..beab4c6fd 100644 --- a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift +++ b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift @@ -13,14 +13,12 @@ final class ApplicationLogsRegressionTests: BaseUITestCase { // MARK: - Tests - func testApplicationLogs_shareLogsPresentsShareSheet() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testApplicationLogs_shareLogsPresentsShareSheet() { let settings = SettingsUIScreen(app: app) assertMainScreenReached() - // Navigate: Main → Settings → Advanced Features → Application Logs + // Navigate: Wallet → You → Advanced → Application Logs settings.open(from: self) settings.navigateToAdvancedFeatures(from: self) waitAndTap(settings.applicationLogsRow) diff --git a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift b/FlipcashUITests/Regression/BuyReservesRegressionTests.swift deleted file mode 100644 index be6502e05..000000000 --- a/FlipcashUITests/Regression/BuyReservesRegressionTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// BuyReservesRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for the full buy flow paying with USDF. Asserts that: -/// -/// - The buy nested sheet opens on top of CurrencyInfoScreen. -/// - Next pushes the Select Payment Currency step; picking USDF lands on the -/// Buy summary in its simple (no fee breakdown) variant. -/// - A covered entry routes straight to the swap-processing screen (the Add -/// Money "Add Money With" picker never appears). -/// - After OK on the processing screen, the user lands back on -/// CurrencyInfoScreen — not the Wallet root, not the Scanner. -/// -/// **Prerequisites:** -/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must have non-zero USDF reserves -/// - The test account must have at least one non-USDF currency visible in -/// Wallet (the first row is used as the buy target) -final class BuyReservesRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - func testBuyCurrency_fullFlowWithReserves() throws { - try skipPendingTabBarRewrite( - "an owned currency's info page offers Give/Convert/Withdraw in the tab-bar UI — there is no Buy, and the buy flow pushes instead of opening a nested sheet" - ) - - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) - let paymentCurrency = PaymentCurrencyUIScreen(app: app) - let confirmation = BuyConfirmationUIScreen(app: app) - let processing = SwapProcessingUIScreen(app: app) - - assertMainScreenReached() - - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - - // Buy → enter $0.01 → Next → USDF → summary → Buy. The amount is well - // below any plausible USDF balance, so USDF is always eligible. - waitAndTap(currencyInfo.buyButton) - amountEntry.enterMinimumAmount() - waitUntilHittableAndTap(amountEntry.nextButton) - - paymentCurrency.assertReached() - waitAndTap(paymentCurrency.usdfRow) - - confirmation.assertReached() - // The USDF variant is the simple summary — no fee breakdown. - XCTAssertFalse( - confirmation.exchangeFeeRow.exists, - "USDF-paid buys must not show an Exchange fee row" - ) - waitUntilHittableAndTap(confirmation.buyButton) - - // A covered amount must not detour through the Add Money flow — the - // "Add Money With" picker must never appear. - XCTAssertFalse( - app.staticTexts["Add Money With"].waitForExistence(timeout: 2), - "A covered amount must route straight to the swap, not the Add Money sheet" - ) - - processing.assertReached() - - // Swipe-down on the processing screen must NOT dismiss the .buy sheet. - // Two known regressions break this: - // 1) The recursive `.appRouterNestedSheet(...)` call inside the - // depth-1 sheet content swallows `interactiveDismissDisabled` - // preferences from descendants. - // 2) A source-level `.interactiveDismissDisabled(false)` on - // BuyAmountScreen overrides the destination's `true`. - // After the swipe, the processing title must still be visible. - app.swipeDown() - processing.assertReached(timeout: 5) - - // Wait for the swap to settle and dismiss via OK. - processing.waitForCompletionAndDismiss() - - // OK on the processing screen pops the entire .buy nested sheet, - // revealing CurrencyInfoScreen underneath. A regression here means - // OK dismissed the wallet too (the cascading-dismiss bug fixed by - // guarding the nested binding's setter against post-dismiss nil). - currencyInfo.assertReached() - } -} diff --git a/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift b/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift deleted file mode 100644 index a632efedd..000000000 --- a/FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// BuyWithCurrencyRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for buying a currency paying with another launchpad -/// currency: the multi-step flow (amount → Select Payment Currency → summary -/// with fee breakdown → processing) with a real $0.01 swap. The insufficient -/// sheet and the fee-affordable entry correction are covered deterministically -/// by `BuyConfirmationViewModelTests` and `BuyAmountViewModelTests` — a UI -/// rendition proved too balance-dependent to keep stable. -/// -/// **Prerequisites:** -/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The account holds USDF and at least TWO launchpad currencies -final class BuyWithCurrencyRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - /// Buys $0.01 of the first wallet currency paying with another launchpad - /// token, all the way through the processing screen. Moves ~$0.01 of real - /// dev-environment value per run. - func testBuyCurrency_payingWithToken_fullFlow() throws { - try skipPendingTabBarRewrite( - "an owned currency's info page offers Give/Convert/Withdraw in the tab-bar UI — there is no Buy, and the buy flow pushes instead of opening a nested sheet" - ) - - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) - let paymentCurrency = PaymentCurrencyUIScreen(app: app) - let confirmation = BuyConfirmationUIScreen(app: app) - let processing = SwapProcessingUIScreen(app: app) - - assertMainScreenReached() - - // Target = first wallet currency; payment = first eligible token row - // on the selector (the target's own row renders disabled and carries a - // different identifier, so it can never be matched). - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - - waitAndTap(currencyInfo.buyButton) - amountEntry.enterMinimumAmount() - waitUntilHittableAndTap(amountEntry.nextButton) - - paymentCurrency.assertReached() - XCTAssertTrue( - paymentCurrency.firstTokenRow.waitForExistence(timeout: 10), - "Fixture requires a second launchpad currency with a spendable balance" - ) - waitAndTap(paymentCurrency.firstTokenRow) - - confirmation.assertReached() - XCTAssertTrue( - confirmation.exchangeFeeRow.waitForExistence(timeout: 5), - "Token-paid buys must show the Exchange fee breakdown" - ) - waitUntilHittableAndTap(confirmation.buyButton) - - processing.assertReached() - processing.waitForCompletionAndDismiss() - currencyInfo.assertReached() - } -} diff --git a/FlipcashUITests/Regression/CashLinkRegressionTests.swift b/FlipcashUITests/Regression/CashLinkRegressionTests.swift index ce8afce94..aae11fcb5 100644 --- a/FlipcashUITests/Regression/CashLinkRegressionTests.swift +++ b/FlipcashUITests/Regression/CashLinkRegressionTests.swift @@ -20,15 +20,13 @@ final class CashLinkRegressionTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - func testCashLink_createAndCancel() throws { - try skipPendingTabBarRewrite("the cash link is created from the give flow, which no longer starts from a Cash button") - - let wallet = WalletScreen(app: app) + func testCashLink_createAndCancel() { let currencyInfo = CurrencyInfoUIScreen(app: app) assertMainScreenReached() - // Step 1: Create a cash link via the Give flow. + // Step 1: Create a cash link via the Give flow — Wallet → the first + // currency card → its Give tile. let amountEntry = navigateToGiveAmount() amountEntry.enterMinimumAmount() waitAndTap(amountEntry.nextButton) @@ -52,19 +50,15 @@ final class CashLinkRegressionTests: BaseUITestCase { // "Did You Send The Link?" confirmation — tap "Yes" waitAndTap(app.buttons["Yes"], timeout: 10, "Expected 'Did You Send The Link?' confirmation") - // Back on main screen - assertMainScreenReached(timeout: 10, "Expected to return to main screen after sending cash link") - // Step 2: Navigate to the pending cash link via transaction history. - wallet.open(from: self) - wallet.selectFirstCurrency() + // Sending lands back on the currency the give started from — the keypad + // popped itself as the bill appeared — so the history is one tap away + // rather than a fresh trip through the wallet. + currencyInfo.assertHeldCurrencyReached(timeout: 30) - // CurrencyInfoScreen — tap "Transaction History" to open history - waitAndTap( - currencyInfo.viewTransactionButton, - timeout: 10, - "Expected 'Transaction History' button on CurrencyInfoScreen" - ) + // CurrencyInfoScreen — the "Recent" section header opens the full + // per-token history. + currencyInfo.tapRecentActivityHeader(from: self) // Step 3: Tap the first "Sending" row to trigger the cancel dialog. // Rows are List cells containing "Sending" as a static text label. diff --git a/FlipcashUITests/Regression/ConvertBetweenTokensRegressionTests.swift b/FlipcashUITests/Regression/ConvertBetweenTokensRegressionTests.swift new file mode 100644 index 000000000..ea46f20d6 --- /dev/null +++ b/FlipcashUITests/Regression/ConvertBetweenTokensRegressionTests.swift @@ -0,0 +1,70 @@ +// +// ConvertBetweenTokensRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for converting one held token into another — the tab-bar +/// UI's route for what the v1 UI called buying with a currency. This is the +/// only convert direction that has to open the destination picker: a +/// non-Dollars source defaults to Dollars, so reaching another token means +/// choosing it. +/// +/// Asserts that the picker lists a token other than the source (it filters the +/// source out), and that the chosen destination carries through amount entry to +/// confirmation and the swap. +/// +/// Moves ~$0.01 of real dev-environment value per run. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The account holds at least TWO non-USDF currencies, the source with a +/// balance > 0 +final class ConvertBetweenTokensRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testConvert_tokenToToken_fullFlow() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let convert = ConvertFlowScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Wallet tab → first non-USDF card → its info page. + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertHeldCurrencyReached() + + waitAndTap(currencyInfo.convertButton) + convert.assertAmountStepReached() + + // Override the Dollars default with a token. The source is filtered out + // of the options, so the first non-Dollars row is always a different + // currency. + waitUntilHittableAndTap(convert.destinationButton) + XCTAssertTrue( + convert.pickerTitle.waitForExistence(timeout: 10), + "Expected the Select Currency sheet" + ) + XCTAssertTrue( + convert.pickerFirstTokenRow.waitForExistence(timeout: 10), + "Fixture requires a second non-USDF currency to convert into" + ) + waitUntilHittableAndTap(convert.pickerFirstTokenRow) + + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.nextButton) + + convert.assertConfirmationReached() + waitUntilHittableAndTap(convert.confirmButton) + + processing.assertReached() + processing.waitForCompletionAndDismiss() + + assertMainScreenReached("Expected the Wallet root after a completed convert") + } +} diff --git a/FlipcashUITests/Regression/ConvertFromDollarsRegressionTests.swift b/FlipcashUITests/Regression/ConvertFromDollarsRegressionTests.swift new file mode 100644 index 000000000..c72a61b8d --- /dev/null +++ b/FlipcashUITests/Regression/ConvertFromDollarsRegressionTests.swift @@ -0,0 +1,67 @@ +// +// ConvertFromDollarsRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for converting Dollars into a currency the account already +/// holds — the tab-bar UI's route for what the v1 UI called Buy paying with +/// reserves. Asserts that: +/// +/// - The Dollars card's info page offers Convert like any other holding. +/// - Converting *from* Dollars defaults the destination to the largest other +/// holding rather than to Dollars, so the flow reaches confirmation without +/// touching the picker. +/// - A covered amount routes straight to the swap: the Add Money picker must +/// never appear. +/// - After OK the user lands on the Wallet root, not back on the Dollars page. +/// +/// Moves ~$0.01 of real dev-environment value per run. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must have non-zero USDF reserves +/// - The account must hold at least one non-USDF currency, as the convert +/// destination — Convert only offers balances the account already has +final class ConvertFromDollarsRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testConvert_dollarsToHeldToken_fullFlow() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let convert = ConvertFlowScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Wallet tab → the Dollars card → its info page. + wallet.open(from: self) + wallet.selectUsdfCurrency() + currencyInfo.assertHeldCurrencyReached() + + // Convert → $0.01 → Next. Dollars can't be its own destination, so the + // amount screen opens on a token already. + waitAndTap(currencyInfo.convertButton) + convert.assertAmountStepReached() + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.nextButton) + + convert.assertConfirmationReached() + waitUntilHittableAndTap(convert.confirmButton) + + // $0.01 is well inside any plausible USDF balance, so the flow must not + // detour through Add Money. + XCTAssertFalse( + app.staticTexts["Add Money With"].waitForExistence(timeout: 2), + "A covered amount must route straight to the swap, not the Add Money sheet" + ) + + processing.assertReached() + processing.waitForCompletionAndDismiss() + + assertMainScreenReached("Expected the Wallet root after a completed convert") + } +} diff --git a/FlipcashUITests/Regression/ConvertToDollarsRegressionTests.swift b/FlipcashUITests/Regression/ConvertToDollarsRegressionTests.swift new file mode 100644 index 000000000..79a41b423 --- /dev/null +++ b/FlipcashUITests/Regression/ConvertToDollarsRegressionTests.swift @@ -0,0 +1,57 @@ +// +// ConvertToDollarsRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for converting a held token into Dollars — the tab-bar UI's +/// route for what the v1 UI called Sell. Asserts that: +/// +/// - A held currency's info page offers Convert, and Convert pushes the amount +/// screen onto the same stack rather than presenting a sheet. +/// - A non-Dollars source defaults its destination to Dollars, so the flow +/// reaches confirmation without touching the picker. +/// - After OK on the processing screen the user lands on the Wallet root: +/// `ConvertFlowDestinationView` pops the convert stack and dismisses the +/// token-info card overlay it launched from, so the currency page does not +/// come back. +/// +/// Moves ~$0.01 of real dev-environment value per run. +/// +/// **Prerequisites:** +/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` +/// - The test account must hold at least one non-USDF currency with balance > 0 +final class ConvertToDollarsRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testConvert_heldTokenToDollars_fullFlow() { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) + let convert = ConvertFlowScreen(app: app) + let amountEntry = AmountEntryScreen(app: app) + let processing = SwapProcessingUIScreen(app: app) + + assertMainScreenReached() + + // Wallet tab → first non-USDF card → its info page. + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertHeldCurrencyReached() + + // Convert → $0.01 → Next. The destination is already Dollars. + waitAndTap(currencyInfo.convertButton) + convert.assertAmountStepReached() + amountEntry.enterMinimumAmount() + waitUntilHittableAndTap(amountEntry.nextButton) + + convert.assertConfirmationReached() + waitUntilHittableAndTap(convert.confirmButton) + + processing.assertReached() + processing.waitForCompletionAndDismiss() + + assertMainScreenReached("Expected the Wallet root after a completed convert") + } +} diff --git a/FlipcashUITests/Regression/CurrencyCreationGateRegressionTests.swift b/FlipcashUITests/Regression/CurrencyCreationGateRegressionTests.swift new file mode 100644 index 000000000..d1c8af99d --- /dev/null +++ b/FlipcashUITests/Regression/CurrencyCreationGateRegressionTests.swift @@ -0,0 +1,64 @@ +// +// CurrencyCreationGateRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Regression test for the currency-creation launch gate: Get Started must open +/// the Add Money prompt, not the wizard, when no single balance covers the +/// launch cost (`shouldAddMoneyBeforeLaunch`). +/// +/// **Fixture.** Needs the standing `FLIPCASH_UI_TEST_ACCESS_KEY` account even +/// though the gate is about being short of money. The only entry to the +/// creation summary in the tab-bar UI is the Wallet tile, and `WalletScreen` +/// draws its tiles only when `session.hasEverAddedMoney()` — v1's other door, +/// the promo card on Discover, is hidden by `CurrencyDiscoveryScreen.hidesPromo` +/// and there is no deeplink. So the account has to hold money, and the gate is +/// a shortfall check rather than a $0 check, which leaves the outcome dependent +/// on the account's balance against the server's `newCurrencyPurchaseAmount` + +/// `newCurrencyFeeAmount`. The test skips when the account can afford the +/// launch, the same way `BlockUnblockSmokeTests` skips without a tip DM. +/// +/// The test only navigates — it stops at the Add Money picker, or backs out of +/// the wizard without entering anything. +final class CurrencyCreationGateRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + func testCreateCurrencyBelowLaunchCost_gatesOnAddMoney() throws { + let wallet = WalletScreen(app: app) + let addMoney = AddMoneyStartScreen(app: app) + + // Wallet → Create a Currency → Create Your Currency summary. + wallet.open(from: self) + wallet.tapCreateCurrencyTile(from: self) + XCTAssertTrue( + app.navigationBars["Create Your Currency"].waitForExistence(timeout: 10), + "Expected the currency creation summary screen" + ) + + waitUntilHittableAndTap(app.buttons["Get Started"]) + + // Get Started branches on the balance: the prompt when nothing covers + // the launch cost, the wizard when something does. + let prompt = addMoney.noBalanceTitle + let wizard = app.textFields["Currency Name"] + let deadline = Date().addingTimeInterval(20) + while !prompt.exists && !wizard.exists && Date() < deadline { + Thread.sleep(forTimeInterval: 0.5) + } + guard prompt.exists else { + throw XCTSkip("The standing account can afford the currency launch cost — the gate can't fire") + } + + XCTAssertTrue( + app.staticTexts["Add money to create a currency"].exists, + "Expected the create-context subtitle on the No Balance prompt" + ) + + // Add Money → the Add Money With picker. + addMoney.tapAddMoney(from: self) + addMoney.assertMethodPickerReached() + } +} diff --git a/FlipcashUITests/Regression/CurrencySellRegressionTests.swift b/FlipcashUITests/Regression/CurrencySellRegressionTests.swift deleted file mode 100644 index 7eddf9dd7..000000000 --- a/FlipcashUITests/Regression/CurrencySellRegressionTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CurrencySellRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for the full currency sell flow with confirmation. -/// -/// **Prerequisites:** -/// - A valid `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig` -/// - The test account must hold at least one non-USDF currency with balance > 0 -final class CurrencySellRegressionTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - func testSellCurrency_fullFlowWithConfirmation() throws { - try skipPendingTabBarRewrite( - "Sell is Convert in the tab-bar UI, pushed onto the wallet stack rather than presented as the sell sheet this asserts" - ) - - let wallet = WalletScreen(app: app) - let currencyInfo = CurrencyInfoUIScreen(app: app) - let amountEntry = AmountEntryScreen(app: app) - let confirmation = SellConfirmationScreen(app: app) - let processing = SwapProcessingUIScreen(app: app) - - assertMainScreenReached() - - // Navigate: Main → Wallet → first currency → CurrencyInfoScreen - wallet.open(from: self) - wallet.selectFirstCurrency() - currencyInfo.assertReached() - - // Sell → enter $0.01 → Next → confirm → submit - waitAndTap(currencyInfo.sellButton, timeout: 10, "Expected Sell button — test account must hold this currency") - amountEntry.enterMinimumAmount() - waitAndTap(amountEntry.nextButton) - confirmation.confirmSell(from: self) - - // Wait for swap to complete and dismiss - processing.waitForCompletionAndDismiss() - - // Verify we returned to CurrencyInfoScreen - currencyInfo.assertReached() - } -} diff --git a/FlipcashUITests/Regression/GiveDiscoverGateRegressionTests.swift b/FlipcashUITests/Regression/GiveDiscoverGateRegressionTests.swift deleted file mode 100644 index 3f69069f2..000000000 --- a/FlipcashUITests/Regression/GiveDiscoverGateRegressionTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// GiveDiscoverGateRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression: tapping Cash while holding USDF but no community currency must -/// surface the "No Community Currencies Yet" dialog routing to Discover — not -/// the Add Money deposit prompt, and not the give amount entry. -/// -/// **Prerequisites:** -/// - `FLIPCASH_UI_TEST_USDF_ONLY_ACCESS_KEY` set in `secrets.local.xcconfig` -/// (the account holds USDF and no other currency). -final class GiveDiscoverGateRegressionTests: BaseUITestCase { - - override var requiresUsdfOnlyAccount: Bool { true } - - func testGiveWithUsdfOnly_routesToDiscover() throws { - try skipPendingTabBarRewrite("USDF is giveable in the tab-bar UI, so the Discover nudge this asserts no longer exists") - - assertMainScreenReached() - - waitAndTap(app.buttons["Cash"]) - - XCTAssertTrue( - app.staticTexts["No Community Currencies Yet"].waitForExistence(timeout: 10), - "Expected the Discover nudge when the account holds USDF but no community currency" - ) - XCTAssertFalse( - app.staticTexts["No Balance Yet"].exists, - "A USDF-funded account must not be routed to the Add Money deposit prompt" - ) - - waitUntilHittableAndTap(app.buttons["Discover Currencies"]) - - XCTAssertTrue( - app.navigationBars["Discover Currencies"].waitForExistence(timeout: 10), - "Expected the Discover sheet after tapping Discover Currencies" - ) - } -} diff --git a/FlipcashUITests/Regression/GiveRegressionTests.swift b/FlipcashUITests/Regression/GiveRegressionTests.swift deleted file mode 100644 index 052870b48..000000000 --- a/FlipcashUITests/Regression/GiveRegressionTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// GiveRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression: tapping Cash on an account with no giveable balance must -/// surface the standard "No Balance Yet" `Dialog` *without* presenting the give -/// amount entry sheet behind it. Previously the give sheet was presented -/// unconditionally, leaving the user staring at a $0 keypad once the prompt was -/// dismissed. The dialog's "Add Money" action opens the deposit method picker -/// (`.addMoney`); "Cancel" returns to the main screen. -/// -/// Uses fresh-account creation to guarantee a $0 balance — no auth keys -/// required, runs the same on Xcode Cloud as locally. -final class GiveRegressionTests: BaseUITestCase { - - func testGiveWithNoBalance_showsAddMoneyWithoutPresentingAmountEntry() throws { - try skipPendingTabBarRewrite("the no-balance gate is no longer reached by tapping Cash on the scanner") - - // Walk the fastest fresh-account path: write-down branch, no Photos - // permission needed. - waitAndTap(app.buttons["Create a New Account"]) - waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) - waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - enterDisplayNameIfNeeded() - allowPushNotificationsIfNeeded() - assertMainScreenReached() - - waitAndTap(app.buttons["Cash"]) - - let noBalanceTitle = app.staticTexts["No Balance Yet"] - XCTAssertTrue( - noBalanceTitle.waitForExistence(timeout: 10), - "Expected the 'No Balance Yet' Add Money prompt after tapping Cash on an empty account" - ) - - // The Add Money prompt's primary CTA — confirms we routed to the Add - // Money flow, not the give amount entry or the legacy deposit dialog. - XCTAssertTrue( - app.buttons["Add Money"].exists, - "Expected the 'Add Money' CTA on the No Balance prompt" - ) - - // "Next" stands in for the give amount-entry sheet being in the hierarchy. - XCTAssertFalse( - app.buttons["Next"].exists, - "Give amount entry must not present when the balance check fails — 'Next' button found alongside the prompt" - ) - - waitAndTap(app.buttons["Cancel"]) - - assertMainScreenReached(timeout: 5, "Expected to return to the main screen after dismissing the No Balance prompt") - XCTAssertFalse( - app.buttons["Next"].exists, - "Dismissing 'No Balance Yet' revealed a hidden Give amount entry — sheet was presented behind the dialog" - ) - } -} diff --git a/FlipcashUITests/Regression/WithdrawPickerEmptyRegressionTests.swift b/FlipcashUITests/Regression/WithdrawPickerEmptyRegressionTests.swift index 53272fb8b..71ae048b4 100644 --- a/FlipcashUITests/Regression/WithdrawPickerEmptyRegressionTests.swift +++ b/FlipcashUITests/Regression/WithdrawPickerEmptyRegressionTests.swift @@ -18,15 +18,13 @@ final class WithdrawPickerEmptyRegressionTests: BaseUITestCase { override var requiresUsdfOnlyAccount: Bool { true } - func testWithdrawPicker_showsUsdfRow_onUsdfOnlyAccount() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - - let settings = SettingsUIScreen(app: app) + func testWithdrawPicker_showsUsdfRow_onUsdfOnlyAccount() { + let wallet = WalletScreen(app: app) assertMainScreenReached() - settings.open(from: self) - waitAndTap(settings.withdrawMoneyButton) + wallet.open(from: self) + wallet.tapWithdrawMoneyTile(from: self) XCTAssertTrue( app.staticTexts["Select Currency"].waitForExistence(timeout: 10), diff --git a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift index 9c243dcee..9525dc1b6 100644 --- a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift +++ b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift @@ -5,7 +5,8 @@ import XCTest -/// Smoke tests for viewing and interacting with the Access Key from Settings. +/// Smoke tests for viewing and interacting with the Access Key, reached +/// through the You tab's Advanced list. final class AccessKeyBackupSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } @@ -13,16 +14,16 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { // MARK: - Tests - func testAccessKeyBackup_viewFromSettings() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testAccessKeyBackup_viewFromSettings() { let settings = SettingsUIScreen(app: app) assertMainScreenReached() - // Navigate: Main → Settings → My Account → Access Key + // Navigate: Wallet → You → Advanced → Access Key. The row is on + // Advanced, not My Account — `SettingsMyAccountScreen` keeps the + // account-level actions (Access Key, Log Out, Delete Account) off itself. settings.open(from: self) - settings.navigateToMyAccount(from: self) + settings.navigateToAdvancedFeatures(from: self) waitAndTap(settings.accessKeyRow) // Confirmation dialog: "View Your Access Key?" @@ -38,16 +39,14 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { ) } - func testAccessKeyBackup_copyToClipboard() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testAccessKeyBackup_copyToClipboard() { let settings = SettingsUIScreen(app: app) assertMainScreenReached() - // Navigate to the Access Key screen + // Wallet → You → Advanced → Access Key. settings.open(from: self) - settings.navigateToMyAccount(from: self) + settings.navigateToAdvancedFeatures(from: self) waitAndTap(settings.accessKeyRow) let dialog = app.otherElements["View Your Access Key?"] @@ -73,16 +72,14 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { waitUntilHittableAndTap(copyButton, timeout: 5, "Expected 'Copy' option in context menu") } - func testAccessKeyBackup_saveToPhotos() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testAccessKeyBackup_saveToPhotos() { let settings = SettingsUIScreen(app: app) assertMainScreenReached() - // Navigate to the Access Key screen + // Wallet → You → Advanced → Access Key. settings.open(from: self) - settings.navigateToMyAccount(from: self) + settings.navigateToAdvancedFeatures(from: self) waitAndTap(settings.accessKeyRow) let dialog = app.otherElements["View Your Access Key?"] diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 611501e67..207147499 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -6,8 +6,8 @@ import XCTest /// End-to-end round-trip for the Blocking feature: block a user from their tip -/// DM, confirm the conversation disappears from the Tips list, then unblock them -/// from Settings and confirm the block is cleared. +/// DM, confirm the conversation disappears from the Chat tab, then unblock them +/// from the You tab and confirm the block is cleared. /// /// **Fixture (non-mutating by design).** The Block affordance only exists on a /// tip-DM conversation, so the test drives whatever tip DM the standing @@ -19,7 +19,7 @@ import XCTest /// leaves the shared account with a user blocked. /// /// **Prerequisites:** the standing account needs a tip profile and at least one -/// tip DM in its Tips list. +/// tip DM in its Chat tab. @MainActor final class BlockUnblockSmokeTests: BaseUITestCase { @@ -35,11 +35,9 @@ final class BlockUnblockSmokeTests: BaseUITestCase { executionTimeAllowance = 600 } - /// Blocks the first tip-DM counterpart, asserts their chat leaves the Tips - /// list, then unblocks them from Settings and asserts the block is gone. + /// Blocks the first tip-DM counterpart, asserts their chat leaves the Chat + /// tab, then unblocks them from the You tab and asserts the block is gone. func testBlock_hidesTipConversation_thenUnblockRestores() throws { - try skipPendingTabBarRewrite("the Tips list is the Chat tab now — no sheet to open or close") - let tips = TipsUIScreen(app: app) let settings = SettingsUIScreen(app: app) let blocked = BlockedUsersUIScreen(app: app) @@ -49,7 +47,7 @@ final class BlockUnblockSmokeTests: BaseUITestCase { // MARK: Reach a tip DM (skip when the account has none). tips.open(from: self) guard let row = tips.firstConversationRow() else { - throw XCTSkip("No tip DM in the standing account's Tips list — skipping the block/unblock round-trip") + throw XCTSkip("No tip DM in the standing account's Chat tab — skipping the block/unblock round-trip") } // The row label is the counterpart's display name, plus an ", unread // messages" suffix when unread. Strip it to the bare name, which the @@ -83,17 +81,17 @@ final class BlockUnblockSmokeTests: BaseUITestCase { blockDialog.buttons["Block"].tap() blockedName = name - // MARK: The chat leaves the Tips list. - // Block returns to the Tips root; the reconcile hides the conversation, - // and there is no empty-state label, so assert the row's absence. + // MARK: The chat leaves the Chat tab. + // Block returns to the Chat tab's root; the reconcile hides the + // conversation, so assert the row's absence rather than an empty state — + // the account may still hold other tip DMs. let hiddenRow = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch XCTAssertTrue( hiddenRow.waitForNonExistence(timeout: 20), - "Expected '\(name)' tip conversation to disappear from the Tips list after blocking" + "Expected '\(name)' tip conversation to disappear from the Chat tab after blocking" ) - // MARK: Unblock from Settings › My Account › Blocked. - tips.close(from: self) + // MARK: Unblock from You › My Account › Blocked. settings.open(from: self) settings.navigateToMyAccount(from: self) waitAndTap(settings.blockedRow) @@ -133,15 +131,17 @@ final class BlockUnblockSmokeTests: BaseUITestCase { try await super.tearDown() } - /// Navigates Settings › My Account › Blocked and unblocks `name` if present, - /// tolerating every step so a failed test's teardown stays quiet. + /// Navigates You › My Account › Blocked and unblocks `name` if present, + /// tolerating every step so a failed test's teardown stays quiet. It cannot + /// reuse `SettingsUIScreen`'s navigation helpers: those assert, and a + /// teardown assertion would mask the failure that brought us here. private func bestEffortUnblock(named name: String) { let settings = SettingsUIScreen(app: app) - guard app.buttons["Settings"].waitForExistence(timeout: 30) else { return } - app.buttons["Settings"].tap() - guard settings.myAccountRow.waitForExistence(timeout: 10) else { return } + guard app.buttons["You"].waitForExistence(timeout: 30) else { return } + app.buttons["You"].tap() + guard scrollTo(settings.myAccountRow, in: settings.scrollView) else { return } settings.myAccountRow.tap() - guard settings.blockedRow.waitForExistence(timeout: 10) else { return } + guard scrollTo(settings.blockedRow, in: settings.scrollView) else { return } settings.blockedRow.tap() let row = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", name)).firstMatch @@ -153,4 +153,16 @@ final class BlockUnblockSmokeTests: BaseUITestCase { guard dialog.waitForExistence(timeout: 10) else { return } dialog.buttons["Unblock"].tap() } + + /// Swipes `container` until `element` is hittable, reporting whether it got + /// there. The non-asserting counterpart of `scrollUpToAndTap`, for teardown. + private func scrollTo(_ element: XCUIElement, in container: XCUIElement, maxSwipes: Int = 6) -> Bool { + guard element.waitForExistence(timeout: 10) else { return false } + var swipes = 0 + while !element.isHittable && swipes < maxSwipes { + container.swipeUp(velocity: .slow) + swipes += 1 + } + return element.isHittable + } } diff --git a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift index 7245d1010..9401e86d4 100644 --- a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift @@ -5,8 +5,8 @@ import XCTest -/// Smoke tests for the Blocked users list reached from Settings › My Account › -/// Blocked. +/// Smoke tests for the Blocked users list reached from the You tab › My Account +/// › Blocked. /// /// **Scope.** These cover the navigation into the list and that the screen loads /// — the parts most likely to regress from a routing change. The @@ -23,11 +23,9 @@ final class BlockedUsersSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - /// Main → Settings → My Account → Blocked lands on the Blocked list and it + /// Wallet → You → My Account → Blocked lands on the Blocked list and it /// loads its state (empty or populated) without hanging. - func testBlockedUsers_reachableFromMyAccount() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testBlockedUsers_reachableFromMyAccount() { let settings = SettingsUIScreen(app: app) let blocked = BlockedUsersUIScreen(app: app) diff --git a/FlipcashUITests/Smoke/DepositSmokeTests.swift b/FlipcashUITests/Smoke/DepositSmokeTests.swift index 6ae6b4ce2..919cd0256 100644 --- a/FlipcashUITests/Smoke/DepositSmokeTests.swift +++ b/FlipcashUITests/Smoke/DepositSmokeTests.swift @@ -9,26 +9,22 @@ final class DepositSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - func testDeposit_landsOnUSDCEducationScreenWithBothButtons() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testDeposit_landsOnUSDCEducationScreenWithBothButtons() { let education = USDCDepositEducationScreen(app: app) assertMainScreenReached() - openDepositFromSettings() + openDeposit() education.assertReached() XCTAssertTrue(education.nextButton.exists) XCTAssertTrue(education.depositOtherCurrenciesButton.exists) } - func testDeposit_pickerKeepsUSDFAndRoutesToDirectAddress() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") - + func testDeposit_pickerKeepsUSDFAndRoutesToDirectAddress() { let education = USDCDepositEducationScreen(app: app) assertMainScreenReached() - openDepositFromSettings() + openDeposit() education.tapDepositOtherCurrencies(from: self) @@ -56,13 +52,13 @@ final class DepositSmokeTests: BaseUITestCase { // MARK: - Helpers - /// Settings → Add Money → Other Wallet, landing on the USDC education - /// screen the old Deposit row used to open directly. - private func openDepositFromSettings() { - let settings = SettingsUIScreen(app: app) + /// Wallet tile → Add Money → Other Wallet, landing on the USDC education + /// screen the old Settings Deposit row used to open directly. + private func openDeposit() { + let wallet = WalletScreen(app: app) let addMoney = AddMoneyStartScreen(app: app) - settings.open(from: self) - waitAndTap(settings.addMoneyButton) + wallet.open(from: self) + wallet.tapAddMoneyTile(from: self) addMoney.assertMethodPickerReached() addMoney.selectOtherWallet(from: self) } diff --git a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift index 1ee9d03a9..b29a7f7d2 100644 --- a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift +++ b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift @@ -5,42 +5,56 @@ import XCTest -/// Tests the Discover top-level sheet entry from the Scan screen and verifies -/// the Create-Your-Own-Currency promo card pushes the creation summary screen. +/// Covers the two currency-discovery tiles on the Wallet tab: Discover +/// Currencies opens the leaderboard, and Create a Currency opens the creation +/// summary. +/// +/// The v1 pair was one route — Discover, opened as a sheet off the scanner, with +/// currency creation reached from the promo card at the top of its list. The +/// tab-bar UI splits them into sibling tiles and hides the promo card +/// (`CurrencyDiscoveryScreen.hidesPromo`), so the entries are checked side by +/// side rather than one through the other. +/// +/// **Fixture.** Needs the standing `FLIPCASH_UI_TEST_ACCESS_KEY` account rather +/// than a fresh one: `WalletScreen` draws the tiles only when +/// `session.hasEverAddedMoney()` is true, and gives an unfunded account the +/// new-user tutorial in their place. The test only navigates, so it leaves the +/// account untouched. final class DiscoverCurrenciesSmokeTests: BaseUITestCase { - func testDiscover_newAccount_tapPromoCard_opensCurrencyCreation() throws { - try skipPendingTabBarRewrite("Discover is a Wallet tile now, and the create-currency promo card is hidden") + override var requiresAuthentication: Bool { true } - // Create a brand new empty account so this test runs without a UITest access key. - waitAndTap(app.buttons["Create a New Account"]) - waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) - waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - enterDisplayNameIfNeeded() - allowPushNotificationsIfNeeded() + func testWalletTiles_openDiscoverAndCurrencyCreation() throws { + let wallet = WalletScreen(app: app) - assertMainScreenReached() + // MARK: Discover Currencies → the leaderboard. + wallet.open(from: self) + wallet.tapDiscoverCurrenciesTile(from: self) - // Tap the new Discover tab on the scan screen. - let discoverButton = app.buttons["scan-discover-button"] - waitAndTap(discoverButton, "Expected Discover tab on scan screen") - - // The Discover sheet should appear with the matching navigation title. - let title = app.navigationBars["Discover Currencies"] XCTAssertTrue( - title.waitForExistence(timeout: 10), - "Expected 'Discover Currencies' sheet to appear after tapping the Discover tab" + app.navigationBars["Discover Currencies"].waitForExistence(timeout: 10), + "Expected the Discover Currencies leaderboard after tapping its tile" + ) + XCTAssertTrue( + app.buttons.matching(identifier: "discover-leaderboard-row").firstMatch + .waitForExistence(timeout: 30), + "Expected the leaderboard to list at least one currency" + ) + XCTAssertFalse( + app.buttons["discover-create-currency-card"].exists, + "The promo card belongs to v1 — creation has its own Wallet tile now" ) - // Tap the promo card to navigate to the currency creation summary. - let promoCard = app.buttons["discover-create-currency-card"] - waitUntilHittableAndTap(promoCard, "Expected Create-Your-Own-Currency promo card to be hittable") + // MARK: Create a Currency → the creation summary. + // Discover is a push onto the wallet's stack, so back out to the tab + // root; the tab bar is hidden while the stack is non-empty. + waitAndTap(app.navigationBars.buttons.firstMatch) + assertMainScreenReached(timeout: 15, "Expected the Wallet root after leaving Discover") - // Verify the creation summary screen is reached. - let creationTitle = app.navigationBars["Create Your Currency"] + wallet.tapCreateCurrencyTile(from: self) XCTAssertTrue( - creationTitle.waitForExistence(timeout: 10), - "Expected 'Create Your Currency' summary screen after tapping the promo card" + app.navigationBars["Create Your Currency"].waitForExistence(timeout: 10), + "Expected the Create Your Currency summary after tapping its tile" ) } } diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift new file mode 100644 index 000000000..bc6f0410e --- /dev/null +++ b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift @@ -0,0 +1,98 @@ +// +// DisplayNameSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Covers the display name and the tip card it earns: a freshly registered +/// account lands on the You tab with a card it can share, and the name behind +/// that card can be changed from My Account. +/// +/// This replaces the old profile-creation walkthrough (Tips intro → name → +/// photo → tipcard), which has no subject left. The name step is mandatory +/// during onboarding now, so no account ever reaches the app without one — the +/// You tab's "Start Receiving Tips" prompt and the Chats tab's intro are both +/// unreachable — and the photo step has no caller: `ProfileNameScreen` goes +/// straight from the name to the card because the card omits the photo. +/// +/// It registers a new account rather than using the standing one: the card's +/// first appearance is only observable on an account that has just been made, +/// and renaming a shared account would leave it renamed. +@MainActor +final class DisplayNameSmokeTests: BaseUITestCase { + + override func setUp() async throws { + try await super.setUp() + // Registration plus a second `SetDisplayName` round-trip runs past + // XCTest's 2-minute default, which kills the test before any assertion + // can report. + executionTimeAllowance = 300 + } + + func testDisplayName_yieldsTipCard_andCanBeChanged() throws { + let settings = SettingsUIScreen(app: app) + + // Onboarding's mandatory name step sets the display name, so the account + // arrives already tippable. + createFreshAccount() + + // MARK: The card is there, with its actions. + settings.open(from: self) + XCTAssertTrue( + app.buttons["you-share-button"].waitForExistence(timeout: 30), + "Expected the tip card's Share action on a named account. On screen: [\(visibleText())]" + ) + XCTAssertTrue( + app.buttons["you-download-button"].exists, + "Expected the tip card's Download action alongside Share" + ) + XCTAssertTrue( + app.buttons["you-fullscreen-button"].exists, + "Expected the card itself, not the name-less setup prompt" + ) + + // MARK: Change the name from My Account. + settings.navigateToMyAccount(from: self) + waitAndTap(settings.changeDisplayNameRow) + + let next = app.buttons["profile-name-next-button"] + XCTAssertTrue(next.waitForExistence(timeout: 30), "Expected the name editor") + + // The editor is seeded with the name already on the profile, so Next + // starts enabled — clear the field to see it disable. + let field = app.textFields["Your Name"] + XCTAssertTrue(field.waitForExistence(timeout: 10), "Expected the name field") + XCTAssertTrue(next.isEnabled, "Next must start enabled with the existing name in the field") + + field.tap() + let seeded = (field.value as? String) ?? "" + XCTAssertFalse(seeded.isEmpty, "Expected the editor to be seeded with the current name") + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: seeded.count)) + XCTAssertFalse(next.isEnabled, "Next must disable while the name is empty") + + field.typeText("Renamed \(Int.random(in: 1_000...9_999))") + XCTAssertTrue(next.isEnabled, "Next must re-enable once the name is valid") + next.tap() + + // `ProfileNameScreen(completion: .back)` pops itself only once + // `SetDisplayName` returns, so landing back on My Account is proof the + // new name was accepted and moderated — a rejection keeps the editor up + // behind a dialog. It pops just the one screen, so this is My Account + // rather than the You tab root. + XCTAssertTrue( + settings.changeDisplayNameRow.waitForExistence(timeout: 60), + "Expected the name editor to pop back to My Account once the name saved. On screen: [\(visibleText())]" + ) + + // MARK: The card survives the rename. + // Unwind the last screen by hand: the tab bar stays hidden while the + // You tab has a stack, so there is no You button to tap back to. + waitAndTap(app.navigationBars.buttons.firstMatch) + assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of My Account") + XCTAssertTrue( + app.buttons["you-share-button"].waitForExistence(timeout: 30), + "Expected the tip card still on the You tab after renaming" + ) + } +} diff --git a/FlipcashUITests/Smoke/GiveSmokeTests.swift b/FlipcashUITests/Smoke/GiveSmokeTests.swift index cbc8ae4cc..e0a073512 100644 --- a/FlipcashUITests/Smoke/GiveSmokeTests.swift +++ b/FlipcashUITests/Smoke/GiveSmokeTests.swift @@ -5,15 +5,18 @@ import XCTest +/// Smoke: a give started from a held currency's Give tile reaches the bill, and +/// cancelling the bill returns to the currency it was started from. final class GiveSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - func testGiveFlow_showsBillWithSendAsLink() throws { - try skipPendingTabBarRewrite("give no longer starts from a Cash button on the scanner") + func testGiveFlow_showsBillWithSendAsLink() { + let currencyInfo = CurrencyInfoUIScreen(app: app) assertMainScreenReached() + // Wallet → first currency card → Give tile → keypad. let amountEntry = navigateToGiveAmount() // Enter $0.01 and proceed to bill @@ -30,7 +33,12 @@ final class GiveSmokeTests: BaseUITestCase { // Dismiss the bill waitAndTap(app.buttons["Cancel"]) - // Should return to main screen - assertMainScreenReached("Expected to return to the main screen after cancelling the bill") + // The keypad popped itself as the bill appeared, so the bill sits over + // the currency's info screen — that, not a tab root, is what cancelling + // reveals. The tab bar stays hidden while the wallet stack is pushed, so + // `assertMainScreenReached` would not hold here. + currencyInfo.assertHeldCurrencyReached( + timeout: 30 + ) } } diff --git a/FlipcashUITests/Smoke/ProfileCreationSmokeTests.swift b/FlipcashUITests/Smoke/ProfileCreationSmokeTests.swift deleted file mode 100644 index 52a078f22..000000000 --- a/FlipcashUITests/Smoke/ProfileCreationSmokeTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ProfileCreationSmokeTests.swift -// FlipcashUITests -// - -import XCTest - -/// Drives profile creation end to end on a freshly registered account: Tips tab -/// → intro → name → photo → tipcard. -/// -/// A profile is created once per account, so this registers a new one rather -/// than logging into a persistent account — otherwise the flow is only -/// reachable on the very first run. -@MainActor -final class ProfileCreationSmokeTests: BaseUITestCase { - - override func setUp() async throws { - try await super.setUp() - // Registration plus a real photo upload runs past XCTest's 2-minute - // default, which kills the test before any assertion can report. - executionTimeAllowance = 360 - } - - func testCreateProfile() throws { - try skipPendingTabBarRewrite("Tips is the Chat tab now, not a sheet off the scanner bottom bar") - - createFreshAccount() - - waitAndTap(app.buttons["scan-tips-button"]) - - let start = app.buttons["start-receiving-tips-button"] - XCTAssertTrue( - start.waitForExistence(timeout: 30), - "Expected the Tips intro on an account with no profile" - ) - XCTAssertTrue(app.staticTexts["Receive Tips From Everyone"].exists) - start.tap() - - let next = app.buttons["profile-name-next-button"] - XCTAssertTrue(next.waitForExistence(timeout: 30), "Expected the name step") - XCTAssertFalse(next.isEnabled, "Next must stay disabled until a name is entered") - - let field = app.textFields["Your Name"] - waitAndTap(field) - field.typeText("Flipcash User \(Int.random(in: 1_000...9_999))") - - XCTAssertTrue(next.isEnabled, "Next must enable once the name is valid") - next.tap() - - // The name step calls SetDisplayName before advancing, so reaching the - // photo step is also proof the name was accepted and moderated. - let picker = app.buttons["profile-photo-picker"] - XCTAssertTrue( - picker.waitForExistence(timeout: 60), - "Expected the photo step — SetDisplayName failed or was rejected" - ) - - let photoNext = app.buttons["profile-photo-next-button"] - XCTAssertFalse(photoNext.isEnabled, "Next must stay disabled until a photo is chosen") - - guard selectFirstPhotoFromLibrary(via: picker) else { - throw XCTSkip("No photo in the simulator library — cannot finish creation") - } - - XCTAssertTrue(photoNext.isEnabled, "Next must enable once a photo is chosen") - photoNext.tap() - - // Upload, finalize, then poll until ready — bounded at 60s in the app. - // Creation lands directly on the tipcard, pushed over the Tips list. - guard app.staticTexts["Share Your Tipcard to Get Tipped"].waitForExistence(timeout: 90) else { - // An upload failure surfaces as a dialog whose copy names the stage - // that failed, so say what is actually on screen. - XCTFail("Tipcard never appeared. On screen: [\(visibleText())]") - return - } - - XCTAssertTrue(app.buttons["tipcard-share-button"].exists, "Expected the Share action") - XCTAssertTrue( - app.buttons["tipcard-export-button"].waitForExistence(timeout: 30), - "Expected the Export action once the card has rendered" - ) - - // Backing off the card reveals the conversation list — the Tips root. - app.navigationBars.buttons.firstMatch.tap() - XCTAssertTrue( - app.buttons["show-my-tipcard-button"].waitForExistence(timeout: 15), - "Expected the Tips conversation list beneath the tipcard" - ) - } -} diff --git a/FlipcashUITests/Smoke/WithdrawSmokeTests.swift b/FlipcashUITests/Smoke/WithdrawSmokeTests.swift index 1f0a00110..93d9b6a63 100644 --- a/FlipcashUITests/Smoke/WithdrawSmokeTests.swift +++ b/FlipcashUITests/Smoke/WithdrawSmokeTests.swift @@ -9,16 +9,17 @@ final class WithdrawSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - /// Settings → Withdraw now lands directly on the "Select Currency" picker, - /// which lists every balance (Dollars included). USDF no longer gets a - /// dedicated intro-first entry; the "Withdraw as USDC" screen is reached by - /// picking Dollars. - func testWithdraw_landsOnCurrencyPicker() throws { - try skipPendingTabBarRewrite("the settings list moved to the You tab; Add/Withdraw Money are Wallet tiles now") + /// The Wallet tab's "Withdraw Money" tile lands directly on the "Select + /// Currency" picker, which lists every balance (Dollars included). USDF no + /// longer gets a dedicated intro-first entry; the "Withdraw as USDC" screen + /// is reached by picking Dollars. + func testWithdraw_landsOnCurrencyPicker() { + let wallet = WalletScreen(app: app) assertMainScreenReached() - openWithdrawFromSettings() + wallet.open(from: self) + wallet.tapWithdrawMoneyTile(from: self) XCTAssertTrue( app.staticTexts["Select Currency"].waitForExistence(timeout: 10), @@ -30,12 +31,4 @@ final class WithdrawSmokeTests: BaseUITestCase { "The 'other currencies' escape hatch should no longer exist — the picker is the entry" ) } - - // MARK: - Helpers - - private func openWithdrawFromSettings() { - let settings = SettingsUIScreen(app: app) - settings.open(from: self) - waitAndTap(settings.withdrawMoneyButton) - } } diff --git a/FlipcashUITests/Support/BaseUITestCase.swift b/FlipcashUITests/Support/BaseUITestCase.swift index 70381ad60..832df7c40 100644 --- a/FlipcashUITests/Support/BaseUITestCase.swift +++ b/FlipcashUITests/Support/BaseUITestCase.swift @@ -165,35 +165,34 @@ class BaseUITestCase: XCTestCase { ) } - /// Skips a test whose entry point was the v1 scanner chrome, removed when - /// the tab-bar UI shipped to everyone. + /// Navigates into the Give flow through a held currency's Give tile — the + /// tab-bar UI's only entry, now that the scanner's Cash button went with the + /// bottom bar. Returns an `AmountEntryScreen` ready for amount entry. /// - /// These flows still exist but are reached differently now, so each call - /// site needs a rewrite verified against a simulator rather than a selector - /// swap. Grep this symbol for the outstanding list; it goes away with the - /// last one. - func skipPendingTabBarRewrite(_ detail: String) throws { - throw XCTSkip("Pending rewrite for the tab-bar UI: \(detail)") - } - - /// Navigates into the Give flow, retrying up to 3 times if the balance hasn't loaded yet. - /// On CI the balance may not be fetched immediately, showing a "No Balance Yet" dialog. - /// Returns an `AmountEntryScreen` ready for amount entry. + /// Picks the first non-USDF card, so the flow runs on a community currency + /// the way the Cash button's default did. No balance retry: the Give tile is + /// only drawn for a currency the account holds, so there is no "No Balance + /// Yet" gate on this path — an unloaded balance shows up as a missing card, + /// which `selectFirstCurrency` already waits out. + /// + /// The keypad is pushed over the currency's info screen and pops itself as + /// the bill appears, so the flow ends up back on `CurrencyInfoScreen` rather + /// than on a tab root. @discardableResult func navigateToGiveAmount() -> AmountEntryScreen { + let wallet = WalletScreen(app: app) + let currencyInfo = CurrencyInfoUIScreen(app: app) let amountEntry = AmountEntryScreen(app: app) - for attempt in 1...3 { - waitAndTap(app.buttons["Cash"]) - if amountEntry.keypadZero.waitForExistence(timeout: 10) { break } - - let ok = app.buttons["OK"] - if ok.exists { ok.tap() } + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertHeldCurrencyReached(timeout: 30) + waitAndTap(currencyInfo.giveButton) - if attempt == 3 { - XCTFail("Balance did not load after 3 attempts") - } - } + XCTAssertTrue( + amountEntry.keypadZero.waitForExistence(timeout: 30), + "Expected the give keypad after tapping the currency's Give tile" + ) return amountEntry } @@ -269,39 +268,6 @@ class BaseUITestCase: XCTestCase { waitUntilHittableAndTap(springboard.buttons["Allow"]) } - /// Picks the newest photo from the system library and commits the crop - /// editor. Returns false when the library is empty, which is the state of a - /// freshly created simulator. - func selectFirstPhotoFromLibrary(via picker: XCUIElement) -> Bool { - picker.tap() - - // The Menu offers Photo Library / Choose File. - waitAndTap(app.buttons["Photo Library"]) - - // The library is a remote view hosted inside the app's own hierarchy, so - // it is reachable from `app` rather than a separate process. Its - // thumbnails are images tagged `PXGGridLayout-Info` — they are not - // collection-view cells, and querying `cells` finds nothing. The head of - // the grid is always on screen; thumbnails further down are in the tree - // but below the fold, so a coordinate tap on them lands nowhere. - let thumbnail = app.images.matching(identifier: "PXGGridLayout-Info").firstMatch - guard thumbnail.waitForExistence(timeout: 30) else { return false } - - if thumbnail.isHittable { - thumbnail.tap() - } else { - thumbnail.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - } - - // `allowsEditing` puts a crop editor in front of the selection; "Choose" - // is what actually returns the image. - let choose = app.buttons["Choose"] - guard choose.waitForExistence(timeout: 20) else { return false } - choose.tap() - - return true - } - /// Everything legible on screen, for failure messages. func visibleText() -> String { app.staticTexts.allElementsBoundByIndex diff --git a/FlipcashUITests/Support/Screens/ConvertFlowScreen.swift b/FlipcashUITests/Support/Screens/ConvertFlowScreen.swift new file mode 100644 index 000000000..b49bdcb4d --- /dev/null +++ b/FlipcashUITests/Support/Screens/ConvertFlowScreen.swift @@ -0,0 +1,69 @@ +// +// ConvertFlowScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the convert flow: the amount screen pushed by +/// `.convertCurrency(mint)`, its destination picker sheet, and the confirmation +/// step. The processing screen is `SwapProcessingUIScreen`. +/// +/// Convert moves value between balances the account already holds — the picker +/// lists every held balance except the source — so it is the tab-bar UI's route +/// for both selling a token into Dollars and buying more of a currency already +/// in the wallet. +@MainActor +struct ConvertFlowScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Amount step + + /// "Convert to [currency ▾]" — opens the destination picker. Both the + /// amount and confirmation steps are titled "Convert", so this selector is + /// what distinguishes the amount step; the confirmation step has no picker. + var destinationButton: XCUIElement { app.buttons["convert-destination-button"] } + + // MARK: - Destination picker + + var pickerTitle: XCUIElement { app.staticTexts["Select Currency"] } + + /// The Dollars (USDF) row. + var pickerDollarsRow: XCUIElement { app.buttons["currency-picker-row-usdf"] } + + /// The first non-Dollars row. The source is filtered out of the options, so + /// this always resolves to a token other than the one being converted. + var pickerFirstTokenRow: XCUIElement { + app.buttons.matching(identifier: "currency-picker-row").firstMatch + } + + // MARK: - Confirmation step + + var confirmButton: XCUIElement { app.buttons["Confirm"] } + + /// Every conversion carries a fee, so this row is unconditional — what the + /// direction changes is whether the fee is added on top of the purchase + /// (from Dollars) or taken out of the proceeds. + var conversionFeeRow: XCUIElement { app.staticTexts["Conversion fee"] } + + // MARK: - Assertions + + func assertAmountStepReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + destinationButton.waitForExistence(timeout: timeout), + "Expected the Convert amount screen with its destination selector" + ) + } + + func assertConfirmationReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + conversionFeeRow.waitForExistence(timeout: timeout), + "Expected the Convert confirmation screen" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift b/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift index 75f0fb167..32b528f19 100644 --- a/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift +++ b/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift @@ -5,8 +5,12 @@ import XCTest -/// Page object for the CurrencyInfoScreen. -/// Provides access to Buy, Sell, and Give actions in the floating footer. +/// Page object for `CurrencyInfoScreen`'s action tiles. +/// +/// The tab-bar UI renders `CurrencyInfoContentV2`, which gates the tiles on +/// whether the account holds the currency: Give / Convert / Withdraw when it +/// does, a lone Get when it doesn't. There is no Buy or Sell tile — acquiring +/// more of a currency you already hold is a Convert from another balance. @MainActor struct CurrencyInfoUIScreen { @@ -18,17 +22,48 @@ struct CurrencyInfoUIScreen { // MARK: - Elements - var buyButton: XCUIElement { app.buttons["Buy"] } - var sellButton: XCUIElement { app.buttons["Sell"] } + /// Tiles shown for a currency the account holds. var giveButton: XCUIElement { app.buttons["Give"] } - var viewTransactionButton: XCUIElement { app.buttons["Transaction History"] } + var convertButton: XCUIElement { app.buttons["Convert"] } + var withdrawButton: XCUIElement { app.buttons["Withdraw"] } + + /// The only tile shown for a currency the account doesn't hold. + var getButton: XCUIElement { app.buttons["Get"] } + + /// The currency's scrolling content, the container the lower sections are + /// scrolled in. + var scrollView: XCUIElement { app.scrollViews.firstMatch } + + /// Opens the per-token transaction history. In the tab-bar UI that is the + /// "Recent" section header (`RecentActivitySection.onShowAll`) — the rows + /// under it are a non-interactive preview, and the v1 "Transaction History" + /// button went with the old footer. + var recentActivityHeader: XCUIElement { app.buttons["Recent"] } + + // MARK: - Actions + + /// Scrolls the "Recent" header into view and taps it, opening the full + /// per-token history. It sits below the hero card and the action tiles, so + /// it starts off-screen. + func tapRecentActivityHeader(from testCase: BaseUITestCase) { + testCase.scrollUpToAndTap(recentActivityHeader, in: scrollView) + } // MARK: - Assertions - func assertReached(timeout: TimeInterval = 10) { + /// Asserts the page for a currency the account holds. + func assertHeldCurrencyReached(timeout: TimeInterval = 10) { + XCTAssertTrue( + convertButton.waitForExistence(timeout: timeout), + "Expected CurrencyInfoScreen for a held currency, with a Convert tile" + ) + } + + /// Asserts the page for a currency the account doesn't hold. + func assertUnheldCurrencyReached(timeout: TimeInterval = 10) { XCTAssertTrue( - buyButton.waitForExistence(timeout: timeout), - "Expected to reach CurrencyInfoScreen with Buy button" + getButton.waitForExistence(timeout: timeout), + "Expected CurrencyInfoScreen for an unheld currency, with a Get tile" ) } } diff --git a/FlipcashUITests/Support/Screens/SellConfirmationScreen.swift b/FlipcashUITests/Support/Screens/SellConfirmationScreen.swift deleted file mode 100644 index 6bbdd0588..000000000 --- a/FlipcashUITests/Support/Screens/SellConfirmationScreen.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// SellConfirmationScreen.swift -// FlipcashUITests -// - -import XCTest - -/// Page object for the CurrencySellConfirmationScreen showing fee breakdown and final Sell button. -@MainActor -struct SellConfirmationScreen { - - private let app: XCUIApplication - - init(app: XCUIApplication) { - self.app = app - } - - // MARK: - Elements - - /// The "Sell" `CodeButton` on the confirmation screen. - /// Index 0 is the CurrencyInfoScreen footer "Sell" button (behind the sheet); - /// index 1 is the confirmation action button (on the sheet). - var sellButton: XCUIElement { - app.buttons.matching(identifier: "Sell").element(boundBy: 1) - } - - // MARK: - Actions - - func confirmSell(from testCase: BaseUITestCase) { - testCase.waitUntilHittableAndTap(sellButton, timeout: 10, "Expected Sell confirmation screen") - } -} diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index be2cd4ac1..bc76781c7 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -5,8 +5,12 @@ import XCTest -/// Page object for the SettingsScreen. -/// Provides access to settings menu items and sub-screens. +/// Page object for the settings list and its sub-screens. +/// +/// The tab-bar UI has no Settings sheet: `YouScreen` renders the list inline +/// under the tip card, so "open settings" is the You tab plus a scroll. Only +/// **My Account** and **Advanced** live here now — Add Money and Withdraw Money +/// moved to the Wallet tab's tiles, on `WalletScreen`. @MainActor struct SettingsUIScreen { @@ -18,30 +22,36 @@ struct SettingsUIScreen { // MARK: - Elements + /// The You tab's scrolling content, the container the rows are scrolled in. + var scrollView: XCUIElement { app.scrollViews.firstMatch } + var myAccountRow: XCUIElement { app.buttons["My Account"] } - var withdrawMoneyButton: XCUIElement { app.buttons["Withdraw Money"] } var advancedFeaturesRow: XCUIElement { app.buttons["Advanced"] } var accessKeyRow: XCUIElement { app.buttons["Access Key"] } - var addMoneyButton: XCUIElement { app.buttons["Add Money"] } var applicationLogsRow: XCUIElement { app.buttons["Application Logs"] } + /// The My Account row that opens the display-name editor. + var changeDisplayNameRow: XCUIElement { app.buttons["Change Display Name"] } + /// The My Account row that opens the Blocked list. var blockedRow: XCUIElement { app.buttons["Blocked"] } // MARK: - Actions - /// Opens Settings from the main screen. + /// Opens the You tab, which hosts the settings list. func open(from testCase: BaseUITestCase) { - testCase.waitAndTap(app.buttons["Settings"]) + testCase.waitAndTap(app.buttons["You"]) } - /// Navigates to My Account sub-screen. + /// Navigates to My Account sub-screen. The rows render below the tip card, + /// so they start off-screen and have to be scrolled to rather than tapped + /// blind. func navigateToMyAccount(from testCase: BaseUITestCase) { - testCase.waitAndTap(myAccountRow) + testCase.scrollUpToAndTap(myAccountRow, in: scrollView) } /// Navigates to Advanced Features sub-screen. func navigateToAdvancedFeatures(from testCase: BaseUITestCase) { - testCase.waitAndTap(advancedFeaturesRow) + testCase.scrollUpToAndTap(advancedFeaturesRow, in: scrollView) } } diff --git a/FlipcashUITests/Support/Screens/TipsUIScreen.swift b/FlipcashUITests/Support/Screens/TipsUIScreen.swift index 4411368c8..f221e5696 100644 --- a/FlipcashUITests/Support/Screens/TipsUIScreen.swift +++ b/FlipcashUITests/Support/Screens/TipsUIScreen.swift @@ -5,8 +5,12 @@ import XCTest -/// Page object for the Tips sheet — the list of tip-DM conversations reached -/// from the ScanBottomBar's Tips tab (always available). +/// Page object for the Chat tab — the list of tip-DM conversations. +/// +/// The tab-bar UI embeds this list as a tab rather than presenting it as a +/// sheet, so there is nothing to close, and `TipsScreen(isEmbedded: true)` +/// renders the conversations unconditionally: the tip-card intro and its inline +/// "Show My Tip Card" button are v1 only, and the tip card has its own tab. @MainActor struct TipsUIScreen { @@ -18,36 +22,35 @@ struct TipsUIScreen { // MARK: - Elements - /// The Tips tab on the ScanBottomBar. - var tab: XCUIElement { app.buttons["scan-tips-button"] } + /// The Chat tab on the tab bar. + var tab: XCUIElement { app.buttons["Chat"] } - /// The always-present call to action at the top of the list — its presence - /// means the Tips list has rendered. - var tipcardButton: XCUIElement { app.buttons["show-my-tipcard-button"] } + /// The tab's large flush title — its presence means the list has rendered, + /// whether or not the account has a conversation. + var title: XCUIElement { app.staticTexts["Chats"] } - /// The Tips sheet's Close button. - var closeButton: XCUIElement { app.navigationBars["Tips"].buttons["Close"] } + /// The empty state, shown until the first tip conversation exists. + var emptyState: XCUIElement { app.staticTexts["No Chats Yet"] } - /// The tip-conversation rows. The list's first cell is the "Show My Tipcard" - /// row; every cell after it is a conversation, so the row buttons are the - /// cells' buttons past index 0. + /// The tip-conversation rows. Every cell is a conversation — the v1 list's + /// leading "Show My Tip Card" row is gone, so none is skipped. private var conversationCells: [XCUIElement] { - Array(app.cells.allElementsBoundByIndex.dropFirst()) + app.cells.allElementsBoundByIndex } // MARK: - Actions - /// Opens the Tips sheet from the main screen and waits for the list to load. + /// Opens the Chat tab and waits for the list to load. func open(from testCase: BaseUITestCase) { testCase.waitAndTap(tab) XCTAssertTrue( - tipcardButton.waitForExistence(timeout: 30), - "Expected the Tips list (the 'Show My Tipcard' button)" + title.waitForExistence(timeout: 30), + "Expected the Chat tab's conversation list" ) } /// The first tip conversation's row button, once at least one exists. Polls - /// because the conversations hydrate asynchronously after the sheet opens. + /// because the conversations hydrate asynchronously after the tab opens. /// Returns `nil` when the account has no tip DM — the caller skips. func firstConversationRow(timeout: TimeInterval = 15) -> XCUIElement? { let deadline = Date().addingTimeInterval(timeout) @@ -60,9 +63,4 @@ struct TipsUIScreen { } return nil } - - /// Closes the Tips sheet, returning to the main screen. - func close(from testCase: BaseUITestCase) { - testCase.waitAndTap(closeButton) - } } diff --git a/FlipcashUITests/Support/Screens/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index f15c7f003..d21981ee0 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -43,6 +43,19 @@ struct WalletScreen { /// that row is completed and therefore disabled. var addMoneyTile: XCUIElement { app.buttons["wallet-tile-add-money"] } + /// The "Withdraw Money" tile, which pushes the currency picker onto the + /// wallet's own stack. + var withdrawMoneyTile: XCUIElement { app.buttons["wallet-tile-withdraw-money"] } + + /// The "Discover Currencies" tile, which pushes the leaderboard onto the + /// wallet's own stack — v1 opened it as a sheet off the scanner. + var discoverCurrenciesTile: XCUIElement { app.buttons["wallet-tile-discover-currencies"] } + + /// The "Create a Currency" tile, which pushes the creation summary directly. + /// It replaces Discover's promo card, which the tab-bar UI hides + /// (`CurrencyDiscoveryScreen.hidesPromo`). + var createCurrencyTile: XCUIElement { app.buttons["wallet-tile-create-currency"] } + // MARK: - Actions /// Opens the Wallet tab and waits for it to load. @@ -61,6 +74,19 @@ struct WalletScreen { testCase.scrollUpToAndTap(addMoneyTile, in: scrollView) } + /// Scrolls the "Withdraw Money" tile into view and taps it. + func tapWithdrawMoneyTile(from testCase: BaseUITestCase) { + testCase.scrollUpToAndTap(withdrawMoneyTile, in: scrollView) + } + + func tapDiscoverCurrenciesTile(from testCase: BaseUITestCase) { + testCase.scrollUpToAndTap(discoverCurrenciesTile, in: scrollView) + } + + func tapCreateCurrencyTile(from testCase: BaseUITestCase) { + testCase.scrollUpToAndTap(createCurrencyTile, in: scrollView) + } + /// Selects the first currency card and verifies CurrencyInfoScreen is reached. func selectFirstCurrency() { XCTAssertTrue( @@ -69,4 +95,14 @@ struct WalletScreen { ) firstCurrencyRow.tap() } + + /// Selects the USDF (Dollars) card — the source for a convert that buys + /// more of a currency the account already holds. + func selectUsdfCurrency() { + XCTAssertTrue( + usdfRow.waitForExistence(timeout: 30), + "Expected the USDF card in the Wallet" + ) + usdfRow.tap() + } }