Device QA found three regressions. The simulator had approved them all.

The calendar is open. I tap a day, the view loads, I tap Back.

I’m in Accounts.

Not the calendar. Not the month view I came from. The root screen, two navigation levels above where Back should have put me. The Xcode simulator had never reproduced this. The A8 finding in my dated round-2 QA notes captured it the first time I ran the flow on my test iPhone (iPhone 15 Pro Max), the device I use for QA on bb finance.

That was the first of three.

Round-2 device QA

Round-2 QA is not scripted. It is a session on my test iPhone with the branch loaded, walking every major flow and noting what is wrong. The findings go into a dated file; the findings drive the next branch.

Round-1 had produced a set of fixes. Round-2 was verification that those fixes held. What it found instead was three failures the simulator had never flagged: navigation, frame-rate gating, and category matching. Each was in a different subsystem. Each required a real device and real conditions to observe.

A8: navigation stack popping past its target

The Accounts → Calendar → Day stack has a clear contract: Back from Day returns to Calendar. What was happening on device: Back popped two levels and landed in Accounts.

The simulator renders navigation transitions. It does not reproduce the exact navigation stack state the device maintains under real timing conditions. The regression was invisible there, reproducible immediately on my test iPhone. Isolating it pointed to how the day view was being pushed onto the stack; correcting that and re-testing on device confirmed the single-level pop.

One finding, one fix, one re-test.

ProMotion: a frame-rate cap the simulator cannot emulate

The second finding was a sensation before it was a diagnosis. my test iPhone is an iPhone 15 Pro Max with a 120Hz ProMotion panel. The app felt 60Hz: animations that should have been fluid had a perceptible step to them.

Root cause via systematic debugging, not guess-fixing: iOS caps Core Animation to 60fps on ProMotion iPhones unless the app explicitly opts in to adaptive refresh. The simulator runs on the host Mac at the Mac’s display rate. It has no concept of the ProMotion cap, no way to emulate it, and nothing to surface as a failure. The simulator showed nothing wrong. On my test iPhone the cap was visible on every animated transition.

The fix is an opt-in configuration change in the app target. After it lands, animations run at 120fps on my test iPhone. The simulator stays indifferent: no frame-rate model to check against.

Out of scope: non-ProMotion devices. The cap is specific to ProMotion hardware; older hardware is unaffected.

Category matching: three QA rounds, three edge cases

The category over-reach finding in round-2 is the third iteration of the same discovery pattern. Each iteration was a device QA observation. None appeared in the simulator.

Phase Q: device QA found B-051. Three identical “Driver & Vehicle Licensing Agency” −£17.06 transactions in the same account, one categorised and two not. Expected: set a category once, have it propagate to identical transactions. That wasn’t implemented. This observation drove the app’s first persistent categorisation layer, a rules engine that remembers decisions plus a smart-propagation pass that applies them on load.

Phase R v1: device QA found B-054. Every Halifax card payee ends in CD <last4>. The v1 match builder was including the CD suffix in the token set, so two payees that shared a card suffix were being linked as similar on that basis alone. Token collision, not semantic match. This drove the replacement of Phase Q’s fuzzy propagation list with a user-curated, part-based Match Builder where the user selects which tokens constitute the rule.

Phase R v2: device QA of v1. A rule containing GOOGLE and CLOUD pulled in SoundCloud (via “cloud”) and two Google One rows (via “google”). ANY-match semantics over individual tokens will always do this. Wherever two payees share a common word, a rule containing that word over-reaches.

Improving on the coverage in “SoundCloud showed up under my Google Cloud rule” (which documented this specific incident and the part-based matching approach), round-2 QA confirmed the over-reach class was still reachable in new rule combinations even after v1 shipped. The fix in v2 adds exclude-word controls and per-row select-all so the user can correct over-reach when they encounter it. The problem class is manageable, not eliminated.

The simulator confirmed that the categorisation UI rendered correctly at every phase. It had no transaction data, no way to run actual matching, and no means to surface a result that was wrong on the merits.

What the simulator cannot surface

Three subsystems. Three failures. One round-2 QA session.

The simulator is accurate for layout, transition firing, and runtime stability. It cannot surface:

  • Navigation stack state under real device timing
  • Frame-rate gating on ProMotion hardware (requires a physical 120Hz panel)
  • Category match correctness against real payee names (requires a populated account)

These are not simulator bugs. The simulator does what it was built to do: fast iteration on layout and code execution without a physical device. The gap is between what it verifies (mechanism) and what device QA verifies (result against real conditions). For a personal finance app, results are what matter. Categorisation correctness has no meaning in isolation; it only means something against actual bank data. Navigation correctness is trivial to assert in the abstract. The edge case lives in stack state under real timing. Frame-rate quality is invisible on a Mac display that doesn’t expose the ProMotion cap.

The pattern across all three findings is consistent: the simulator confirms a mechanism exists; the device confirms whether the mechanism produces the right result.

Round-3 will follow the same process. The findings file will get another dated entry. The simulator will continue to clear things it cannot see.

All writing