Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions .claude/plans/2026-08-20-ui-test-tab-bar-rewrite.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,45 @@ call site.

| v1 affordance | v2 route |
|---|---|
| `ScanTopBar` Settings button (`app.buttons["Settings"]`) | You tab (`app.buttons["You"]`) → `YouScreen` settings list |
| `ScanTopBar` Settings button (`app.buttons["Settings"]`) | You tab (`app.buttons["You"]`) → `YouScreen` settings list ("My Account", "Advanced"); **Log Out is under Advanced** |
| `ScanBottomBar` Cash button (`app.buttons["Cash"]`) | no scanner give entry; per-currency **Give** on `CurrencyInfoScreen`, or the `flipcash://give` deeplink |
| `scan-wallet-button` | Wallet tab (`app.buttons["Wallet"]`) |
| `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 |
| 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. |

## Gotchas

- **The tab bar hides on push.** `HomeTabView.isTabBarHidden` is true when a card is
expanded, a bill is showing, or the active tab's stack is non-empty. So
`assertMainScreenReached()` (now the Wallet tab) only holds at a tab root — pop first.
- **The You tab gates on a tip profile.** `YouScreen` (and therefore the whole settings
list) only renders when `session.profile?.isTippable == true`; otherwise the tab shows
`TipCardSetupPrompt`. Fresh-account tests cannot reach Settings this way.
- **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.

## Product gap worth confirming separately

Settings is reachable *only* from the You tab, which requires a tippable profile. An
account without one appears to have no route to Settings at all.
- **A wallet card opens `CurrencyInfoScreen` as an overlay, not a push.** `TokenCardStack`
reports the tap through `onCardTap`; the wallet lifts the page over the deck with a
close box instead of a back chevron, so there is no `navigationBars` entry to wait on.
- **The v1 buy/sell entries are gone from an owned currency.** `CurrencyInfoContentV2`
gates its tiles on `isOwned` (balance has displayable value), and the owned branch is
Give/Convert/Withdraw. Buying more of a currency you already hold has no entry point
there, and buy pushes `.buyCurrency(mint)` onto the stack rather than presenting the
`.buy` nested sheet the reserves test asserts. Deciding what the v2 equivalents should
assert is a product question, not a selector swap — `BuyReservesRegressionTests`,
`BuyWithCurrencyRegressionTests` and `CurrencySellRegressionTests` are skipped on that.

## Rewritten so far

- `LoginSmokeTests.testRelogin_viaAccountSelection` — You tab → Advanced → Log Out.
- `BuyApplePayRegressionTests`, `BuyDepositRegressionTests`, `BuyPhantomRegressionTests`
— enter through the Wallet tab's `wallet-tile-add-money` tile. The picker's heading is
**"Add Money With"** in v2, not "Select Method", and the debit-card row runs the
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.
3 changes: 3 additions & 0 deletions Flipcash/Core/Screens/Main/Home/TokenCardStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ struct TokenCardStack: View {
TokenCardView(data: item, height: cardHeight)
}
.buttonStyle(.plain)
// USDF carries its own identifier so a test can target an
// investable token without depending on where USDF sorts.
.accessibilityIdentifier(item.isUSDF ? "currency-row-usdf" : "currency-row")
// Resting fan, and the reorganisation when a card is opened, are
// both expressed here so they interpolate as one animation.
.opacity(item.mint == hiddenMint ? 0 : 1)
Expand Down
15 changes: 10 additions & 5 deletions Flipcash/Core/Screens/Main/Home/WalletScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -562,22 +562,22 @@ private struct WalletScreenContent: View {
// `.deposit`/`.withdraw` are the app's canonical design-system
// glyphs for these actions (see Settings' `.card(icon:)` buttons) —
// a matched arrow-to-baseline pair, not SF Symbols.
walletTile(icon: .asset(.deposit), title: "Add Money") {
walletTile(icon: .asset(.deposit), title: "Add Money", identifier: "wallet-tile-add-money") {
router.presentAddMoney(.general, source: .balance)
}
// `.withdrawCurrency(nil)` (not `.withdraw`) so the flow pops back
// to the wallet's own stack on finish — `.withdraw` hardcodes a
// return to the settings stack and would strand the user here. A
// nil mint opens the currency picker (all balances).
walletTile(icon: .asset(.withdraw), title: "Withdraw Money") {
walletTile(icon: .asset(.withdraw), title: "Withdraw Money", identifier: "wallet-tile-withdraw-money") {
router.push(.withdrawCurrency(nil))
}
}
HStack(spacing: 12) {
walletTile(icon: .symbol("globe"), title: "Discover Currencies") {
walletTile(icon: .symbol("globe"), title: "Discover Currencies", identifier: "wallet-tile-discover-currencies") {
router.push(.discoverCurrencies)
}
walletTile(icon: .asset(.coinsAdd), title: "Create a Currency") {
walletTile(icon: .asset(.coinsAdd), title: "Create a Currency", identifier: "wallet-tile-create-currency") {
router.push(.currencyCreationSummary)
}
}
Expand All @@ -593,7 +593,11 @@ private struct WalletScreenContent: View {

/// A tile-style entry point: icon top-leading, label pinned bottom-leading,
/// inside a translucent rounded card. They tile two-up per row.
private func walletTile(icon: TileGlyph, title: String, action: @escaping () -> Void) -> some View {
///
/// `identifier` is what tests target: several of these titles also appear on
/// the new-user tutorial rows above, where a completed milestone renders
/// disabled, so matching on the label alone can resolve to an untappable row.
private func walletTile(icon: TileGlyph, title: String, identifier: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 0) {
tileGlyph(icon)
Expand All @@ -612,6 +616,7 @@ private struct WalletScreenContent: View {
.clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityIdentifier(identifier)
}

/// Renders a `TileGlyph` at a consistent ~24pt: SF Symbols sized by font,
Expand Down
8 changes: 4 additions & 4 deletions FlipcashUITests/Regression/AddMoneyGateRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ final class AddMoneyGateRegressionTests: BaseUITestCase {
"The buy amount sheet must open even when the account has no balance"
)

// Add Money → the Select Method picker. This flow enters from
// 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.assertSelectMethodReached()
addMoney.assertMethodPickerReached()
}

func testCreateCurrencyWithNoAssets_gatesOnAddMoney() throws {
Expand Down Expand Up @@ -67,8 +67,8 @@ final class AddMoneyGateRegressionTests: BaseUITestCase {
"Expected the create-context subtitle on the No Balance prompt"
)

// Add Money → the Select Method picker.
// Add Money → the Add Money With picker.
addMoney.tapAddMoney(from: self)
addMoney.assertSelectMethodReached()
addMoney.assertMethodPickerReached()
}
}
38 changes: 17 additions & 21 deletions FlipcashUITests/Regression/BuyApplePayRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,45 @@

import XCTest

/// Regression test for the Add Money Apple Pay (Coinbase) path's
/// verified-contact gate: an unverified account tapping Add Money must land
/// on the verification sheet's first step, not the Apple Pay overlay. Which
/// step shows (phone vs email) depends on the account's server-side phone
/// state, so the test accepts either. Stops short of completing
/// verification — SMS / email links are out of scope for the simulator.
/// Regression test for the Add Money debit-card (Coinbase) path's
/// verified-contact gate: an unverified account picking Debit Card must land
/// on the verification flow's first step, not on an amount screen or the
/// Apple Pay overlay. The gate runs up front — before "Amount to Add" — so
/// no empty screen appears ahead of it. Which step shows (phone vs email)
/// depends on the account's server-side phone state, so the test accepts
/// either. Stops short of completing verification — SMS / email links are
/// out of scope for the simulator.
///
/// Entry is the Balance screen's own Add Money button — buy entry is capped
/// Entry is the Wallet tab's own Add Money tile — buy entry is capped
/// at the highest spendable balance, so the old buy-shortfall vehicle into
/// Add Money no longer exists.
///
/// **Prerequisites:**
/// - `FLIPCASH_UI_TEST_ACCESS_KEY` set in `secrets.local.xcconfig`
/// - The account behind the access key must have **no verified email**. A
/// verified email skips the gate and routes straight to Apple Pay, which
/// would fail this assertion.
/// - The account must have the Coinbase onramp enabled, or the Pay
/// verified email skips the gate and routes straight to "Amount to Add",
/// which would fail this assertion.
/// - The account must have the Coinbase onramp enabled, or the Debit Card
/// row is hidden.
final class BuyApplePayRegressionTests: BaseUITestCase {

override var requiresAuthentication: Bool { true }

func testApplePay_unverifiedAccount_showsVerificationSheet() {
let wallet = WalletScreen(app: app)
let amountEntry = AmountEntryScreen(app: app)
let addMoney = AddMoneyStartScreen(app: app)
let verifyInfo = VerifyInfoUIScreen(app: app)

assertMainScreenReached()

// Navigate: Main → Wallet → Add Money → Select Method → Pay.
// Navigate: Wallet tab → Add Money tile → Add Money With → Debit Card.
wallet.open(from: self)
waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch)
addMoney.assertSelectMethodReached()
wallet.tapAddMoneyTile(from: self)
addMoney.assertMethodPickerReached()
addMoney.selectPayDebitCard(from: self)

// Amount to Add → enter $10 → the verified-contact gate opens the
// verification sheet.
addMoney.assertAmountToAddReached()
amountEntry.keypadButton("1").tap()
amountEntry.keypadButton("0").tap()
waitUntilHittableAndTap(addMoney.amountToAddActionButton)

// The gate fires on selection, so the verification step is the next
// screen — there is no amount entry to step through first.
verifyInfo.assertVerificationStepReached()
}
}
10 changes: 5 additions & 5 deletions FlipcashUITests/Regression/BuyDepositRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import XCTest
/// the USDC education pre-flight; Next pushes the deposit-address screen.
/// Exercises:
///
/// - The Balance screen's Add Money button opens "Select Method".
/// - The Wallet tab's Add Money tile opens the "Add Money With" picker.
/// - Selecting Other Wallet shows the USDC education screen; Next pushes the
/// deposit-address screen with the Copy Address button hittable. The address
/// is derived from the session's owner key — its exact value isn't asserted,
/// just that the CTA renders.
///
/// Entry is the Balance screen's own Add Money button — buy entry is capped
/// Entry is the Wallet tab's own Add Money tile — buy entry is capped
/// at the highest spendable balance, so the old buy-shortfall vehicle into
/// Add Money no longer exists.
///
Expand All @@ -34,11 +34,11 @@ final class BuyDepositRegressionTests: BaseUITestCase {

assertMainScreenReached()

// Navigate: Main → Wallet → Add Money → Select Method → Other Wallet →
// Navigate: Wallet tab → Add Money tile → Add Money With → Other Wallet →
// USDC education pre-flight → Next → USDC deposit-address screen.
wallet.open(from: self)
waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch)
addMoney.assertSelectMethodReached()
wallet.tapAddMoneyTile(from: self)
addMoney.assertMethodPickerReached()
addMoney.selectOtherWallet(from: self)

education.assertReached()
Expand Down
10 changes: 5 additions & 5 deletions FlipcashUITests/Regression/BuyPhantomRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import XCTest
/// Exercises the in-app flow as far as can be tested without a real Phantom
/// install:
///
/// - The Balance screen's Add Money button opens "Select Method".
/// - The Wallet tab's Add Money tile opens the "Add Money With" picker.
/// - Selecting Phantom opens the "Add Money With Phantom" education screen
/// with the "Connect Your Phantom Wallet" CTA.
///
/// The test stops at the education screen: its CTA fires the Phantom connect
/// deeplink, and only a successful connect pushes "Amount to Add" — out of
/// scope for the local simulator without a real Phantom install.
///
/// Entry is the Balance screen's own Add Money button — buy entry is capped
/// Entry is the Wallet tab's own Add Money tile — buy entry is capped
/// at the highest spendable balance, so the old buy-shortfall vehicle into
/// Add Money no longer exists.
///
Expand All @@ -33,10 +33,10 @@ final class BuyPhantomRegressionTests: BaseUITestCase {

assertMainScreenReached()

// Navigate: Main → Wallet → Add Money → Select Method → Phantom.
// Navigate: Wallet tab → Add Money tile → Add Money With → Phantom.
wallet.open(from: self)
waitUntilHittableAndTap(app.buttons["Add Money"].firstMatch)
addMoney.assertSelectMethodReached()
wallet.tapAddMoneyTile(from: self)
addMoney.assertMethodPickerReached()
addMoney.selectPhantom(from: self)

addMoney.assertPhantomEducationReached()
Expand Down
12 changes: 8 additions & 4 deletions FlipcashUITests/Regression/BuyReservesRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import XCTest
/// - 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 "Select Method" sheet never appears).
/// 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.
///
Expand All @@ -24,7 +24,11 @@ final class BuyReservesRegressionTests: BaseUITestCase {

override var requiresAuthentication: Bool { true }

func testBuyCurrency_fullFlowWithReserves() {
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)
Expand Down Expand Up @@ -57,9 +61,9 @@ final class BuyReservesRegressionTests: BaseUITestCase {
waitUntilHittableAndTap(confirmation.buyButton)

// A covered amount must not detour through the Add Money flow — the
// "Select Method" sheet must never appear.
// "Add Money With" picker must never appear.
XCTAssertFalse(
app.staticTexts["Select Method"].waitForExistence(timeout: 2),
app.staticTexts["Add Money With"].waitForExistence(timeout: 2),
"A covered amount must route straight to the swap, not the Add Money sheet"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ final class BuyWithCurrencyRegressionTests: BaseUITestCase {
/// 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() {
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)
Expand Down
6 changes: 5 additions & 1 deletion FlipcashUITests/Regression/CurrencySellRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ final class CurrencySellRegressionTests: BaseUITestCase {

override var requiresAuthentication: Bool { true }

func testSellCurrency_fullFlowWithConfirmation() {
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)
Expand Down
2 changes: 1 addition & 1 deletion FlipcashUITests/Smoke/DepositSmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ final class DepositSmokeTests: BaseUITestCase {
let addMoney = AddMoneyStartScreen(app: app)
settings.open(from: self)
waitAndTap(settings.addMoneyButton)
addMoney.assertSelectMethodReached()
addMoney.assertMethodPickerReached()
addMoney.selectOtherWallet(from: self)
}
}
9 changes: 5 additions & 4 deletions FlipcashUITests/Smoke/LoginSmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class LoginSmokeTests: BaseUITestCase {

func testLoginViaAccessKey_reachesMainScreen() {
assertMainScreenReached(
"Expected to reach the main screen with the Cash button after login"
"Expected to reach the main screen with the Wallet tab after login"
)

let walletButton = app.buttons["Wallet"]
Expand All @@ -24,9 +24,10 @@ final class LoginSmokeTests: BaseUITestCase {
// Verify we're on the main screen
assertMainScreenReached()

// Open Settings, go to My Account, and log out
waitAndTap(app.buttons["Settings"])
waitAndTap(app.buttons["My Account"])
// Settings lives on the You tab now: You → Advanced → Log Out. The
// settings rows sit under the tip card, so scroll them into view.
waitAndTap(app.buttons["You"])
scrollUpToAndTap(app.buttons["Advanced"], in: app.scrollViews.firstMatch)
waitAndTap(app.buttons["Log Out"])

// Confirmation dialog — scoped to the dialog container
Expand Down
Loading