From c87487625d5227a26569b2fcba5e1103f969c9b3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 08:58:06 -0400 Subject: [PATCH 1/8] fix(tests): rewrite the buy/sell regressions as convert flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab-bar UI has no Buy or Sell tile on a currency the account holds — `CurrencyInfoContentV2` offers Give / Convert / Withdraw, and Convert is what replaced both. It moves value between balances you already hold, so each of the three skipped tests is a convert in one direction: token → Dollars was Sell, Dollars → token was buy-paying-with-reserves, and token → token was buy-with-currency. The tests are renamed for the direction they exercise, and `SellConfirmationScreen` goes with the v1 sell sheet that was its only subject. Two of the three need no picker interaction: `ConvertAmountViewModel` defaults a non-Dollars source to Dollars and a Dollars source to the largest other holding. `ConvertBetweenTokensRegressionTests` is the one that opens the picker, so `CurrencyPickerSheet` rows get `currency-picker-row` with Dollars distinguished as `currency-picker-row-usdf` — otherwise picking "the first token" could land on Dollars depending on how balances sort. Two v1 assertions are dropped rather than ported. The nested-sheet swipe-down regression can't recur: convert is pushed, so there is no sheet behind the processing screen. And a finished convert now pops to the Wallet root instead of returning to the currency page, per `ConvertFlowDestinationView`. `CurrencyInfoUIScreen` splits `assertReached` into held and unheld variants, since which tiles exist depends on the balance. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 29 +++++- .../CurrencyPickerSheet.swift | 8 ++ .../AddMoneyGateRegressionTests.swift | 8 +- .../BuyReservesRegressionTests.swift | 92 ------------------- .../BuyWithCurrencyRegressionTests.swift | 68 -------------- .../ConvertBetweenTokensRegressionTests.swift | 70 ++++++++++++++ .../ConvertFromDollarsRegressionTests.swift | 67 ++++++++++++++ .../ConvertToDollarsRegressionTests.swift | 57 ++++++++++++ .../CurrencySellRegressionTests.swift | 47 ---------- .../Support/Screens/ConvertFlowScreen.swift | 69 ++++++++++++++ .../Support/Screens/CurrencyInfoScreen.swift | 32 +++++-- .../Screens/SellConfirmationScreen.swift | 32 ------- .../Support/Screens/WalletScreen.swift | 10 ++ 13 files changed, 334 insertions(+), 255 deletions(-) delete mode 100644 FlipcashUITests/Regression/BuyReservesRegressionTests.swift delete mode 100644 FlipcashUITests/Regression/BuyWithCurrencyRegressionTests.swift create mode 100644 FlipcashUITests/Regression/ConvertBetweenTokensRegressionTests.swift create mode 100644 FlipcashUITests/Regression/ConvertFromDollarsRegressionTests.swift create mode 100644 FlipcashUITests/Regression/ConvertToDollarsRegressionTests.swift delete mode 100644 FlipcashUITests/Regression/CurrencySellRegressionTests.swift create mode 100644 FlipcashUITests/Support/Screens/ConvertFlowScreen.swift delete mode 100644 FlipcashUITests/Support/Screens/SellConfirmationScreen.swift 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..b15af762b 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 @@ -20,6 +20,7 @@ call site. | `discover-create-currency-card` promo | Wallet tab → "Create a Currency" tile — `CurrencyDiscoveryScreen.hidesPromo` hides the card | | Settings "Add Money" / "Withdraw Money" rows | Wallet tab tiles of the same name | | `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 @@ -39,11 +40,22 @@ call site. 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 +66,10 @@ 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 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. 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 index b1a3cce5d..51f75cbb4 100644 --- a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift +++ b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift @@ -25,14 +25,14 @@ final class AddMoneyGateRegressionTests: BaseUITestCase { app.buttons.matching(identifier: "discover-leaderboard-row").firstMatch, "Expected the Discover leaderboard to list at least one currency" ) - currencyInfo.assertReached() + currencyInfo.assertUnheldCurrencyReached() - // Buy always opens the amount sheet; on a $0 account the action + // Get always opens the amount sheet; on a $0 account the action // button becomes an Add Money CTA instead of Next. - waitAndTap(currencyInfo.buyButton) + waitAndTap(currencyInfo.getButton) XCTAssertTrue( app.navigationBars["Amount"].waitForExistence(timeout: 10), - "The buy amount sheet must open even when the account has no balance" + "The Get amount sheet must open even when the account has no balance" ) // Add Money → the Add Money With picker. This flow enters from 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/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/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/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..679a4a4fb 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,31 @@ 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 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"] } + var viewTransactionButton: XCUIElement { app.buttons["Transaction History"] } // 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/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index f15c7f003..687df5ca3 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -69,4 +69,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() + } } From eaea3c1cdba0b5c5db116703c1eed882ae725940 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 10:24:28 -0400 Subject: [PATCH 2/8] fix(tests): route the settings, wallet-tile and give tests through the tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine more `skipPendingTabBarRewrite` call sites, in three groups. Settings rows: the You tab's list has only My Account and Advanced, so each test opens the You tab and scrolls to its row. Access Key and Application Logs are on Advanced, not My Account — `SettingsMyAccountScreen` keeps the account-level actions off itself and says so in its header doc. Blocked stays on My Account. Money rows: Add Money and Withdraw Money are Wallet tiles now, so `SettingsUIScreen` loses both properties and the four tests enter through `wallet-tile-add-money` / `wallet-tile-withdraw-money`. Give: `navigateToGiveAmount()` goes Wallet → first currency card → its Give tile. The keypad pops itself as the bill appears — `GiveScreen.onBillPresented` when `isPushed` — so both callers end on `CurrencyInfoScreen` rather than a tab root, and the cash link reaches its history from there without a second trip through the wallet. That history is the "Recent" section header in the tab-bar UI; the v1 "Transaction History" button went with the old footer. `GiveDiscoverGateRegressionTests` is deleted rather than rewritten: USDF is giveable now, so `GiveCashGate.discoverCurrencies` is unreachable. The "No Balance Yet" sibling keeps its skip with a corrected reason — the gate has no fresh-account entry either, since the only caller that gated a give was `ScanBottomBar`, which the embedded Scan tab does not render. Also drops the balance-retry loop in `navigateToGiveAmount()`: the Give tile is only drawn for a currency the account holds, so that path raises no gate dialog. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 43 ++++++++++++++++--- .../ApplicationLogsRegressionTests.swift | 6 +-- .../Regression/CashLinkRegressionTests.swift | 26 +++++------ .../GiveDiscoverGateRegressionTests.swift | 42 ------------------ .../Regression/GiveRegressionTests.swift | 2 +- .../WithdrawPickerEmptyRegressionTests.swift | 10 ++--- .../Smoke/AccessKeyBackupSmokeTests.swift | 29 ++++++------- .../Smoke/BlockedUsersSmokeTests.swift | 10 ++--- FlipcashUITests/Smoke/DepositSmokeTests.swift | 24 +++++------ FlipcashUITests/Smoke/GiveSmokeTests.swift | 16 +++++-- .../Smoke/WithdrawSmokeTests.swift | 23 ++++------ FlipcashUITests/Support/BaseUITestCase.swift | 36 ++++++++++------ .../Support/Screens/CurrencyInfoScreen.swift | 19 +++++++- .../Support/Screens/SettingsScreen.swift | 25 +++++++---- .../Support/Screens/WalletScreen.swift | 9 ++++ 15 files changed, 167 insertions(+), 153 deletions(-) delete mode 100644 FlipcashUITests/Regression/GiveDiscoverGateRegressionTests.swift 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 b15af762b..ebaa90324 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 @@ -19,6 +19,7 @@ call site. | `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 | | 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. | @@ -29,12 +30,30 @@ 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: + `GiveDiscoverGateRegressionTests` doubly so, since USDF is giveable now + (`BetaFlags.allowsDollarsGive`) and `GiveCashGate.discoverCurrencies` is unreachable + either way. Deleted. `GiveRegressionTests` (the "No Balance Yet" gate) is the same + shape and stays skipped pending the same call. +- **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. +- **The Chat tab is chats only — it is not a door into profile creation.** + `TipsScreen(isEmbedded: true)` renders `TipConversationsScreen` unconditionally, so + `TipsIntroScreen` and its `start-receiving-tips-button` never appear in v2. The v2 door + is the You tab's `you-start-receiving-tips-button`, which pushes `.changeDisplayName` + → `ProfileNameScreen(completion: .back)`: name only, returning to the You tab. +- **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. Any rewrite drops `profile-photo-picker` and + `profile-photo-next-button` rather than re-routing to them. - **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. @@ -66,6 +85,18 @@ 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 three buy/sell tests, rewritten as the convert routes that replaced them and renamed for what they now exercise: `CurrencySellRegressionTests` → `ConvertToDollarsRegressionTests`, `BuyReservesRegressionTests` → 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/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/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 index 052870b48..26afde398 100644 --- a/FlipcashUITests/Regression/GiveRegressionTests.swift +++ b/FlipcashUITests/Regression/GiveRegressionTests.swift @@ -17,7 +17,7 @@ import XCTest final class GiveRegressionTests: BaseUITestCase { func testGiveWithNoBalance_showsAddMoneyWithoutPresentingAmountEntry() throws { - try skipPendingTabBarRewrite("the no-balance gate is no longer reached by tapping Cash on the scanner") + try skipPendingTabBarRewrite("no fresh-account entry raises the gate — give starts from a held currency's Give tile, which an empty account has no card for") // Walk the fastest fresh-account path: write-down branch, no Photos // permission needed. 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/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/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/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..1732e5451 100644 --- a/FlipcashUITests/Support/BaseUITestCase.swift +++ b/FlipcashUITests/Support/BaseUITestCase.swift @@ -176,24 +176,34 @@ class BaseUITestCase: XCTestCase { 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. + /// 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. + /// + /// 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 } + wallet.open(from: self) + wallet.selectFirstCurrency() + currencyInfo.assertHeldCurrencyReached(timeout: 30) + waitAndTap(currencyInfo.giveButton) - let ok = app.buttons["OK"] - if ok.exists { ok.tap() } - - 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 } diff --git a/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift b/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift index 679a4a4fb..32b528f19 100644 --- a/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift +++ b/FlipcashUITests/Support/Screens/CurrencyInfoScreen.swift @@ -30,7 +30,24 @@ struct CurrencyInfoUIScreen { /// The only tile shown for a currency the account doesn't hold. var getButton: XCUIElement { app.buttons["Get"] } - var viewTransactionButton: XCUIElement { app.buttons["Transaction History"] } + /// 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 diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index be2cd4ac1..60dbba5b2 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,11 +22,12 @@ 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 Blocked list. @@ -30,18 +35,20 @@ struct SettingsUIScreen { // 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/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index 687df5ca3..c87692ee4 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -43,6 +43,10 @@ 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"] } + // MARK: - Actions /// Opens the Wallet tab and waits for it to load. @@ -61,6 +65,11 @@ 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) + } + /// Selects the first currency card and verifies CurrencyInfoScreen is reached. func selectFirstCurrency() { XCTAssertTrue( From 2525ea36c238dc564952b52f58dd6e1f8a9364fc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 10:49:08 -0400 Subject: [PATCH 3/8] fix(tests): route the chat-group UI tests through the tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BlockUnblockSmokeTests` opened the Tips list as a sheet off the scanner's bottom bar and closed it through `navigationBars["Tips"]`. Embedded as the Chat tab, that list has no toolbar and no leading tip-card cell, so the page object now opens `app.buttons["Chat"]`, waits on the "Chats" title, and treats every cell as a conversation. Its `tearDown` unblock also still tapped `app.buttons["Settings"]`, which the tab bar removed — so a run that failed after the block left the shared account with a user blocked. It goes through You › My Account › Blocked now, with a non-asserting scroll of its own: `scrollUpToAndTap` would fail the teardown and mask the real failure. `ProfileCreationSmokeTests` walked Tips intro → name → photo → tipcard, and none of those four steps has an entry left. `OnboardingNameScreen` is mandatory after the access key, so no account reaches the app without a name and both name-less prompts stay gated off; `ProfileNameScreen` skips the photo step for every caller because the card omits the photo. Replaced by `DisplayNameSmokeTests`, covering what survived: a freshly registered account lands on the You tab with a card it can share, and the name behind it changes through My Account. `selectFirstPhotoFromLibrary` went with its last caller. Both pass on iPhone 17. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 29 ++++-- .../Smoke/BlockUnblockSmokeTests.swift | 52 ++++++---- .../Smoke/DisplayNameSmokeTests.swift | 98 +++++++++++++++++++ .../Smoke/ProfileCreationSmokeTests.swift | 90 ----------------- FlipcashUITests/Support/BaseUITestCase.swift | 33 ------- .../Support/Screens/SettingsScreen.swift | 3 + .../Support/Screens/TipsUIScreen.swift | 42 ++++---- 7 files changed, 175 insertions(+), 172 deletions(-) create mode 100644 FlipcashUITests/Smoke/DisplayNameSmokeTests.swift delete mode 100644 FlipcashUITests/Smoke/ProfileCreationSmokeTests.swift 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 ebaa90324..cd4af897c 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 @@ -18,6 +18,7 @@ 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. | @@ -45,15 +46,23 @@ call site. - **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. -- **The Chat tab is chats only — it is not a door into profile creation.** - `TipsScreen(isEmbedded: true)` renders `TipConversationsScreen` unconditionally, so - `TipsIntroScreen` and its `start-receiving-tips-button` never appear in v2. The v2 door - is the You tab's `you-start-receiving-tips-button`, which pushes `.changeDisplayName` - → `ProfileNameScreen(completion: .back)`: name only, returning to the You tab. +- **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. Any rewrite drops `profile-photo-picker` and - `profile-photo-next-button` rather than re-routing to them. + 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. @@ -97,6 +106,12 @@ call site. 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` → 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/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/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/Support/BaseUITestCase.swift b/FlipcashUITests/Support/BaseUITestCase.swift index 1732e5451..8394fc470 100644 --- a/FlipcashUITests/Support/BaseUITestCase.swift +++ b/FlipcashUITests/Support/BaseUITestCase.swift @@ -279,39 +279,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/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index 60dbba5b2..bc76781c7 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -30,6 +30,9 @@ struct SettingsUIScreen { var accessKeyRow: XCUIElement { app.buttons["Access Key"] } 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"] } 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) - } } From f8359986cd791e174591fbe6f4cea2f3693eb17b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 11:18:49 -0400 Subject: [PATCH 4/8] test(give): delete the no-balance gate regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GiveCashGate`'s "No Balance Yet" branch has no entry a test can reach. The only caller that gated a give from a tab root was `ScanBottomBar`, which renders under `if !isEmbedded`, and the Scan tab embeds `ScanScreen`. The gate still fires from a chat's Send Cash, `TipFlow`, and the give deeplink, but the test's premise was a fresh empty account, which reaches none of those — and give now starts from a held currency's Give tile, which an empty account has no card for. Same call as `GiveDiscoverGateRegressionTests`, which went for the same reason. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 8 +-- .../Regression/GiveRegressionTests.swift | 60 ------------------- 2 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 FlipcashUITests/Regression/GiveRegressionTests.swift 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 cd4af897c..6661db99e 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 @@ -35,11 +35,11 @@ call site. 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: - `GiveDiscoverGateRegressionTests` doubly so, since USDF is giveable now + 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. Deleted. `GiveRegressionTests` (the "No Balance Yet" gate) is the same - shape and stays skipped pending the same call. + 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. - **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. diff --git a/FlipcashUITests/Regression/GiveRegressionTests.swift b/FlipcashUITests/Regression/GiveRegressionTests.swift deleted file mode 100644 index 26afde398..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("no fresh-account entry raises the gate — give starts from a held currency's Give tile, which an empty account has no card for") - - // 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" - ) - } -} From 8926071345571c6b13842155d1b2e5b275b04feb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 11:50:49 -0400 Subject: [PATCH 5/8] test(discover): route the discover-group UI tests through the wallet tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover moved from a scanner sheet to a Wallet tile, and currency creation moved from Discover's promo card to a sibling tile, so the two entries are now checked side by side instead of one through the other. Both tiles are gated on funding: WalletScreen draws walletTiles only for session.hasEverAddedMoney(), and gives an unfunded account the new-user tutorial in their place. So DiscoverCurrenciesSmokeTests takes the standing account rather than creating a fresh one, and the currency-creation gate moves out of AddMoneyGateRegressionTests into its own class on the same account. That gate still fires there because shouldAddMoneyBeforeLaunch is a shortfall check, not a $0 check — the account holds money but not the launch cost — and the test skips if that ever stops being true. The buy gate has no fixture left and stays skipped, with the reason recorded on the class: flipcash://discover reaches the same destination, but app.open relaunches the app and a freshly created account 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 instead of Add Money. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 22 +++++ .../AddMoneyGateRegressionTests.swift | 83 +++++-------------- .../CurrencyCreationGateRegressionTests.swift | 64 ++++++++++++++ .../Smoke/DiscoverCurrenciesSmokeTests.swift | 66 +++++++++------ .../Support/Screens/WalletScreen.swift | 17 ++++ 5 files changed, 164 insertions(+), 88 deletions(-) create mode 100644 FlipcashUITests/Regression/CurrencyCreationGateRegressionTests.swift 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 6661db99e..f0e970133 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 @@ -40,6 +40,22 @@ call site. (`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: + `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 + 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. @@ -119,3 +135,9 @@ call site. `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 stays skipped: no fixture reaches it. diff --git a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift index 51f75cbb4..ca841441e 100644 --- a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift +++ b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift @@ -5,70 +5,29 @@ 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. +/// Regression test for the reserves-only buy gate: an account with nothing +/// spendable must still reach the Get amount screen, where +/// `BuyAmountViewModel.actionTitle` swaps Next for an Add Money CTA. +/// +/// **Skipped: no fixture can reach it.** The gate needs an account with no +/// spendable balance, and the tab-bar UI gives such an account no door to a +/// currency's Get button: +/// +/// - The Wallet's Discover tile is drawn only for +/// `session.hasEverAddedMoney()`, which is exactly the accounts the gate does +/// not apply to; an unfunded account gets the new-user tutorial instead. +/// - `flipcash://discover` routes to 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". +/// - The standing `FLIPCASH_UI_TEST_ACCESS_KEY` and USDF-only accounts both +/// hold displayable USDF, so `paymentOptions` is non-empty and the button +/// reads Next. +/// +/// The gate itself is live: a funded account spent down to nothing hits it. The +/// test needs a spent-down fixture that the suite doesn't have. 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.assertUnheldCurrencyReached() - - // Get always opens the amount sheet; on a $0 account the action - // button becomes an Add Money CTA instead of Next. - waitAndTap(currencyInfo.getButton) - XCTAssertTrue( - app.navigationBars["Amount"].waitForExistence(timeout: 10), - "The Get 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() + throw XCTSkip("Needs a spent-down account: the Get screen has no entry from a $0 balance in the tab-bar UI") } } 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/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/Support/Screens/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index c87692ee4..d21981ee0 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -47,6 +47,15 @@ struct WalletScreen { /// 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. @@ -70,6 +79,14 @@ struct WalletScreen { 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( From 98a087bb39b21d4b234e4a79735f499b80b44323 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 11:51:34 -0400 Subject: [PATCH 6/8] test(uitests): drop skipPendingTabBarRewrite with its last call site Every test that entered through the v1 scanner chrome has been rewritten or deleted, so the helper has no callers. The rewrite plan keeps the map of where each flow moved. --- .claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md | 12 +++++++----- FlipcashUITests/Support/BaseUITestCase.swift | 11 ----------- 2 files changed, 7 insertions(+), 16 deletions(-) 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 f0e970133..13459c0bd 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,14 @@ # 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. +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. -Grep `skipPendingTabBarRewrite` for the live list. The helper is deleted with the last -call site. +One test is still skipped, for a fixture rather than a route: +`AddMoneyGateRegressionTests` needs an account spent down to nothing, which the suite +cannot produce — see the Wallet-tile gotcha below. ## What moved diff --git a/FlipcashUITests/Support/BaseUITestCase.swift b/FlipcashUITests/Support/BaseUITestCase.swift index 8394fc470..832df7c40 100644 --- a/FlipcashUITests/Support/BaseUITestCase.swift +++ b/FlipcashUITests/Support/BaseUITestCase.swift @@ -165,17 +165,6 @@ class BaseUITestCase: XCTestCase { ) } - /// Skips a test whose entry point was the v1 scanner chrome, removed when - /// the tab-bar UI shipped to everyone. - /// - /// 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 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. From 72c798261f407e9ea3f08229d55f7c15b6e6196f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 11:53:29 -0400 Subject: [PATCH 7/8] test(buy): delete the no-assets add-money gate regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test needs an account with nothing spendable, and the tab-bar UI gives such an account no door to a currency's Get button. The Wallet's Discover tile is drawn only for session.hasEverAddedMoney(); flipcash://discover reaches the same destination, but app.open relaunches the app and a freshly created account comes back on "Create a New Account"; and both standing accounts hold displayable USDF, so BuyAmountViewModel.paymentOptions is non-empty and the button reads Next instead of Add Money. The gate is still live app code — a funded account spent down to nothing hits it. Restoring the test needs a spent-down fixture, which the rewrite plan records. --- .../2026-08-20-ui-test-tab-bar-rewrite.md | 20 +++++------ .../AddMoneyGateRegressionTests.swift | 33 ------------------- 2 files changed, 9 insertions(+), 44 deletions(-) delete mode 100644 FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift 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 13459c0bd..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 @@ -6,10 +6,6 @@ been rewritten or dropped, and `BaseUITestCase.skipPendingTabBarRewrite(_:)` wen the last call site. This is the record of where each flow moved and what the v2 routes cost the suite. -One test is still skipped, for a fixture rather than a route: -`AddMoneyGateRegressionTests` needs an account spent down to nothing, which the suite -cannot produce — see the Wallet-tile gotcha below. - ## What moved | v1 affordance | v2 route | @@ -46,12 +42,13 @@ cannot produce — see the Wallet-tile gotcha below. `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: - `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 - creation gate survives because `shouldAddMoneyBeforeLaunch` is a *shortfall* check, + 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. @@ -142,4 +139,5 @@ cannot produce — see the Wallet-tile gotcha below. 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 stays skipped: no fixture reaches it. + `AddMoneyGateRegressionTests`' buy gate has no fixture that can reach it, so it is + deleted alongside the give gates. diff --git a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift b/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift deleted file mode 100644 index ca841441e..000000000 --- a/FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AddMoneyGateRegressionTests.swift -// FlipcashUITests -// - -import XCTest - -/// Regression test for the reserves-only buy gate: an account with nothing -/// spendable must still reach the Get amount screen, where -/// `BuyAmountViewModel.actionTitle` swaps Next for an Add Money CTA. -/// -/// **Skipped: no fixture can reach it.** The gate needs an account with no -/// spendable balance, and the tab-bar UI gives such an account no door to a -/// currency's Get button: -/// -/// - The Wallet's Discover tile is drawn only for -/// `session.hasEverAddedMoney()`, which is exactly the accounts the gate does -/// not apply to; an unfunded account gets the new-user tutorial instead. -/// - `flipcash://discover` routes to 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". -/// - The standing `FLIPCASH_UI_TEST_ACCESS_KEY` and USDF-only accounts both -/// hold displayable USDF, so `paymentOptions` is non-empty and the button -/// reads Next. -/// -/// The gate itself is live: a funded account spent down to nothing hits it. The -/// test needs a spent-down fixture that the suite doesn't have. -final class AddMoneyGateRegressionTests: BaseUITestCase { - - func testBuyWithNoAssets_offersAddMoneyOnAmountEntry() throws { - throw XCTSkip("Needs a spent-down account: the Get screen has no entry from a $0 balance in the tab-bar UI") - } -} From 1457595106d90f41fdec4e761cf3dc86d63953e3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 12:18:21 -0400 Subject: [PATCH 8/8] chore(api): record ocp-client-protocol 0.2.0 in the workspace lockfile `FlipcashAPI/Package.swift` moved to `exact: "0.2.0"` in #658, but the workspace `Package.resolved` still held 0.1.0, so every build re-resolved it and left the tree dirty. --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" } }, {