Skip to content

feat(parity): the capture harness can photograph dialogs, so a dialog change is verifiable - #220

Merged
tylerkron merged 1 commit into
mainfrom
builder/issue-213-dialog-capture
Sep 3, 2026
Merged

tylerkron merged 1 commit into
mainfrom
builder/issue-213-dialog-capture

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review. closes #213.

What a reviewer can now verify that they could not before

Every Window the app shows through IDialogService was outside the visual gate. The harness rendered the desktop panes, the drawers and the mobile shell — 18 screens — and constructed no dialog at all.

That gap is worse here than it would be in most codebases: XAML in this repo is not compile-checked. The views carry no x:DataType, so bindings resolve by reflection at run time. A green build cannot tell you whether a new TextBlock renders, whether its binding resolves, or whether a StaticResource brush exists. Dialogs were therefore the surface where a green build proved least, and the one surface nobody could photograph. #209, #218 and #219 each shipped with an explicit "I did not render this dialog" disclosure.

Six dialog states are now gated, byte-for-byte, on every pull request:

screen what it pins
dialog-connect-wifi-scanning the empty state every open of the connect dialog starts in
dialog-connect-usb-idle USB tab, one discovered device, no error
dialog-connect-usb-error the same plus SerialConnectError — the exact surface #209 could not verify
dialog-connect-manual-usb-error Manual USB tab with ManualPortError
dialog-export-configure the export dialog's configuration form
dialog-export-failed its result state with ExportSucceeded false

The attached dialog-connect-usb-error.png is the proof the capability works: it is a render of the real ConnectionDialog with a real ConnectionDialogViewModel, produced by run.sh, and it is one of the six now in the committed baseline.

Beyond the pixels, the harness reads the element back out of the visual tree and says so, because a TextBlock whose binding silently failed and one that rendered an empty string are indistinguishable in a screenshot:

[INFO] dialog-connect-usb-error: SerialConnectError is a TextBlock, IsVisible=True
       Bounds=0, 338, 528, 27 Foreground=#fff43f5e
       Text="Could not connect to 'DAQiFi Nq1'. The device may be in use by another
             application or not responding."

That settles three things a green build cannot: the binding resolves, NotNullToVis drives IsVisible, and StatusRed resolves to #fff43f5e.

How to invoke it

Unchanged — the existing commands now cover dialogs too:

DOTNET=~/.dotnet/dotnet tools/parity-audit/run.sh /some/out       # 24 screens
DOTNET=~/.dotnet/dotnet tools/parity-audit/run.sh --determinism /some/out
DOTNET=~/.dotnet/dotnet tools/parity-audit/run.sh --check-captured /some/out/determinism/r1

Adding a dialog state is appending a DialogScreen to DialogScreens() and its name to ExpectedScreens, then re-recording the baseline and bumping expected= in the macOS CI step. Documented in tools/parity-audit/README.md under a new Dialogs section, with the four rules that each cost somebody real time:

  • Never start discovery. A real SerialDeviceFinder opens every DAQiFi VID/PID COM port on the machine. Discovery is a separate call from construction (StartConnectionFinders()), so building a dialog view-model is inert; scenarios seed the bound collections directly with fixed literals.
  • An indeterminate ProgressBar never settles. It burns all 40 settle rounds and fails with a message that says nothing about progress bars. The connect dialog has one behind a "Scanning…" overlay on three of its five tabs, so this is the default state of the dialog most likely to be captured next. SettleIndeterminateProgress now fails by name and offers two remedies: seed the state that hides it (preferred — that is a state the app really has), or opt into FreezeIndeterminateProgress, which stops the animation and logs that it did, so the empty track in the PNG is never read as a rendering bug.
  • Assert the element, not just the picture (RequireRenderedText, by AutomationProperties.AutomationId).
  • Select tabs by header, not index (SelectTab) — the wrong tab under the right filename passes every check this tool has.

Determinism evidence

Determinism is a hard requirement here, so these are inside the baseline set rather than exempt from it, and the six new screens are committed to baselines/macos-arm64.sha256.

  • --determinism with DETERMINISM_RUNS=10, run twice: 24/24 screens byte-identical both times. 20 clean runs in total, on macOS 26.5 build 25F71, Apple silicon, .NET SDK 10.0.302, Avalonia 12.1.1. Every dialog settled in 1 round.
  • The existing 18 still reproduce byte-for-byte. shasum -c of the pre-change manifest against a post-change capture: 18 OK, 0 failures. The manifest diff is consequently purely additive — 6 lines added, no existing hash touched — which is the evidence that the new phase moved nothing that was already gated. CaptureDialogs() runs last, after both existing phases, precisely so the 18 are a function of the same sequence that produced their baselines.
  • --check-captured passes over a fresh determinism set with the new manifest.
  • A second Apple-silicon machine agrees. The Desktop head on macOS job on this PR is green, including both visual-gate steps: "Visual capture harness must be deterministic" (5 captures, plus its independent expected=24 count assertion) and "Committed baseline must be reproduced on this runner" (--check-captured against the new 24-line manifest). So the six dialog hashes are cross-machine reproducible on macos-latest, not just on the Mac that recorded them — the same standard tooling: the macos-arm64 baseline is named for OS+arch but has only ever been reproduced on one Mac #188 set for the original eighteen, met on the first run rather than assumed.

The settle rule itself is not loosened for anything: SettleSampleInterval, Quiesce and the render-the-tree Encode are untouched. CI caught a real six-pixel race with that rule in #201 and it stays load-bearing.

What is deliberately NOT covered, and why

Neither is a matter of adding another DialogScreen; both are recorded in the README.

  • The autosizing alert family (ErrorDialog, SuccessDialog, DuplicateDeviceDialog, MessageDialog). I built and captured these first, then measured that headless does not size them the way a real window does: DuplicateDeviceDialog declares MinWidth="480" MinHeight="280", and headless its Bounds, ClientSize, DesiredSize and Width/Height all come back 460×248 — under its own stated minimum. A capture at that size is a picture of a dialog no user has, and pinning an arbitrary size instead photographs a layout the app never produces. So I removed them rather than ship a screen I could not vouch for. DialogScreen.Size is required to keep that decision from being made by accident.
  • FirmwareDialog. FirmwareDialogViewModel's constructor starts a network fetch (LoadFirmwareOptionsAsync) that populates a bound ComboBox asynchronously, so its rendered state is a race against github.com. Capturing it needs an injected stub IFirmwareDownloadService, which makes the picture a picture of a stub.

One stale safety claim corrected

The README's side-effects section guaranteed no serial/WiFi discovery by asserting "StartConnectionFinders() has exactly one caller ... and the harness never opens that dialog." This change makes that sentence false. It now names the actual guarantee — discovery is a separate call from construction, and no capture scenario may start a finder — which is the invariant a future scenario has to preserve. This one is load-bearing: it is what keeps a capture from rendering an attached board's serial number into a screen that has to be byte-reproducible.

Build gate

gate result
dotnet build Daqifi.Avalonia.Desktop succeeded
dotnet restore Daqifi.Avalonia.iOS (no -r) ok
dotnet build Daqifi.Avalonia.iOS -c Release -r iossimulator-arm64 --no-restore succeeded
git status --short -- '**/packages.lock.json' empty
Android head not buildable on this Mac — CI covers it; no status claimed

I make no warning-count claim: I did not build a matched merge-base baseline with cold third_party/oxyplot-avalonia/obj on both sides, so I have no honest delta.

Diff: 610 added / 31 removed across 5 files — AvaloniaCapture/Program.cs (468/8), README.md (123/18), run.sh (8/2), build.yml (5/3), baselines/macos-arm64.sha256 (6/0). No test project is touched: this change is test infrastructure, and its own gate is the CI job above.

Baseline refresh on other merges

baselines/macos-arm64.sha256 is per-commit truth, so any merge that changes rendered pixels needs it re-recorded. Of the PRs open alongside this one:

Nothing in this change alters the pixels of the 18 existing screens, so a baseline conflict with any of the four would be a content conflict in the same file, resolved by re-recording once after both land.

The connect dialog USB tab, rendered headless by the capture harness: one discovered device DAQiFi Nq1 on COM7, and the red SerialConnectError message wrapped over two lines above the separator with the Connect button still live.

The export dialog failed-result state, rendered by the same harness: red alert icon, the failure message, the destination path, and Try Again next to Done rather than Open Folder.

… change is verifiable

Every Window reached through IDialogService was outside the visual gate: the harness
rendered the desktop panes, the drawers and the mobile shell, and constructed no dialog
at all. That mattered more here than it would elsewhere, because XAML in this repo is not
compile-checked — the views carry no x:DataType, so bindings resolve by reflection at run
time and a green build cannot tell you whether a TextBlock rendered, whether its binding
resolved, or whether a StaticResource brush exists. #209, #218 and #219 each shipped with
an explicit "I did not render this dialog" disclosure.

Adds a CaptureDialogs() phase with six seeded states across two dialogs — four connect
states (WiFi scanning, USB idle, USB with a connect error, Manual USB with a validation
error) and two export states (configure, failed result). All six are deterministic and
committed to the macOS baseline, taking the gated set from 18 screens to 24.

The reusable parts, each of which was a trap somebody hit by hand:

- An indeterminate ProgressBar never settles, so a dialog showing one fails SettledFrame
  after 40 rounds with a message that says nothing about progress bars. The connect dialog
  has one behind a "Scanning…" overlay on three of its five tabs, so it is the default
  state of the dialog most likely to be captured next. SettleIndeterminateProgress now
  fails by name and offers two remedies: seed the state that hides it (preferred), or
  opt into FreezeIndeterminateProgress, which stops the animation and logs that it did.
  The settle rule itself is not loosened for anything — CI caught a real six-pixel race
  with it in #201.
- RequireRenderedText reads a bound element back out of the visual tree by
  AutomationProperties.AutomationId and reports type, visibility, bounds, foreground and
  text, separately from the pixels: a TextBlock whose binding silently failed and one that
  rendered an empty string are indistinguishable in a screenshot.
- SelectTab matches on the tab header rather than an index, so selecting and asserting are
  the same act.

Determinism: 24/24 byte-identical across 10 runs, twice (20 runs total, macOS 26.5 25F71,
Apple silicon). The manifest change is purely additive — all eighteen existing hashes are
unchanged — and --check-captured passes over a fresh determinism set.

No scenario may start device discovery, and that is the standing rule rather than an
accident of this change: a real SerialDeviceFinder opens every DAQiFi VID/PID COM port on
the machine. Scenarios seed the bound collections directly with fixed literals. The
README's side-effects section previously guaranteed this by saying the harness never opens
the connection dialog, which is no longer true; it now names the actual guarantee.

Deliberately not covered, with the reasons recorded in the README: the autosizing alert
family (headless does not apply a window's MinWidth/MinHeight — DuplicateDeviceDialog
declares 480x280 and lays out at 460x248, so a capture would be a picture of a dialog no
user has), and FirmwareDialog (its view-model constructor starts a network fetch that
populates a bound ComboBox, so its rendered state races github.com).

closes #213

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Gate six dialog states in the Avalonia visual capture harness

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Captures six deterministic connection and export dialog states with real view-models.
• Verifies rendered bindings and safely handles tabs and indeterminate animations.
• Expands CI and macOS visual baselines from 18 to 24 screens.
Diagram

sequenceDiagram
    participant CI as macOS CI
    participant Run as run.sh
    participant Harness as Capture Harness
    participant App as Avalonia App
    participant VM as Dialog ViewModels
    participant Dialog as Real Dialogs
    participant Tree as Visual Tree
    participant Base as SHA Baseline
    CI->>Run: Start determinism
    Run->>Harness: Execute capture runs
    Harness->>App: Bootstrap headless
    Harness->>VM: Seed fixed states
    Harness->>Dialog: Show fixed size
    VM-->>Dialog: Bind real state
    Harness->>Tree: Select and inspect
    Tree-->>Harness: Return rendered values
    Harness->>Dialog: Capture settled PNG
    Harness-->>Run: Return 24 PNGs
    Run->>Base: Verify byte hashes
    Run-->>CI: Report gate result
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Capture through IDialogService flows
  • ➕ Exercises the complete production dialog invocation path
  • ➕ Verifies owners, lifecycle behavior, and service wiring end-to-end
  • ➖ Connection flows can start real hardware discovery
  • ➖ Modal orchestration and asynchronous state make captures less deterministic
  • ➖ Reaching specific error states would require broader service substitution
2. Adopt compiled XAML bindings
  • ➕ Moves many binding-name failures to build time
  • ➕ Improves type safety throughout the application
  • ➖ Requires a broad view and view-model migration beyond dialog capture
  • ➖ Does not verify layout, visibility converters, brushes, or rendered pixels
  • ➖ Would not provide visual regression baselines
3. Create isolated dialog snapshot tests
  • ➕ Could separate scenarios into smaller test cases
  • ➕ Allows focused execution of an individual dialog state
  • ➖ Duplicates app bootstrap, settling, baseline, and diagnostics infrastructure
  • ➖ Risks rendering dialogs under a different environment than the established visual gate

Recommendation: Keep the centralized capture-harness approach. It reuses the proven determinism and baseline pipeline while rendering real views with real view-models, and deterministic direct seeding avoids hardware and network side effects. Compiled bindings remain a valuable separate initiative, but they cannot replace runtime visual coverage.

Files changed (5) +610 / -31

Enhancement (1) +468 / -8
Program.csAdd deterministic capture and inspection for real dialogs +468/-8

Add deterministic capture and inspection for real dialogs

• Adds a final dialog-capture phase with four connection states and two export states backed by real, deterministically seeded view-models. Introduces fixed sizing, safe tab selection, explicit indeterminate-animation handling, visual-tree text assertions, scenario diagnostics, and normal dialog teardown.

tools/parity-audit/AvaloniaCapture/Program.cs

Tests (1) +6 / -0
macos-arm64.sha256Record macOS hashes for six dialog captures +6/-0

Record macOS hashes for six dialog captures

• Adds committed SHA-256 hashes for the four connection and two export dialog screenshots. Existing desktop and mobile hashes remain unchanged.

tools/parity-audit/baselines/macos-arm64.sha256

Documentation (1) +123 / -18
README.mdDocument dialog coverage, constraints, and extension workflow +123/-18

Document dialog coverage, constraints, and extension workflow

• Documents the six gated dialog states and how to add future scenarios. It also explains deterministic seeding, animation handling, rendered-element assertions, hardware side-effect avoidance, and why autosizing and firmware dialogs remain excluded.

tools/parity-audit/README.md

Other (2) +13 / -5
build.ymlRaise the CI visual gate to 24 screens +5/-3

Raise the CI visual gate to 24 screens

• Updates workflow documentation and the independent expected-screen assertion from 18 to 24. The new count ensures all six dialog captures are required by the macOS determinism gate.

.github/workflows/build.yml

run.shExpose capture evidence in filtered harness output +8/-2

Expose capture evidence in filtered harness output

• Includes informational harness messages in both successful and failed capture output. This preserves evidence about output paths, frozen animations, and visual-tree binding inspections without changing exit-code handling.

tools/parity-audit/run.sh

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@tylerkron

Copy link
Copy Markdown
Contributor Author

Ready for review — not merging.

Qodo: clean in one round on a072111 (Bugs (0) / Rule violations (0) / Requirement gaps (0), commit marker matches head, no Review mode: Skipped), 0 review threads. Re-checked both surfaces after a >3 min settle against the same SHA; unchanged. Zero verified against a control (PR #196 returns 3 threads with the same filter).

CI: all six jobs green. The load-bearing one is Desktop head on macOS, where both visual-gate steps pass — Visual capture harness must be deterministic (with the expected=24 count assertion) and Committed baseline must be reproduced on this runner. That makes the six new dialog hashes cross-machine reproducible on macos-latest, not just locally stable.

Verification method: the dialogs were rendered and looked at, not reasoned about — two are attached to the body. Locally --determinism gave 24/24 byte-identical over 10 runs, twice, and the 18 pre-existing screens reproduce byte-for-byte against the old manifest (hence a purely additive baseline diff).

Two dialog families are deliberately excluded with measurements rather than hand-waving: the autosizing alert family (headless lays DuplicateDeviceDialog out at 460x248 against its declared MinWidth/MinHeight of 480x280) and FirmwareDialog (its view-model constructor starts a network fetch). Both are written up in the README.

No board was attached and none was needed — no scenario opens a port.

@tylerkron
tylerkron added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 8d37ec7 Sep 3, 2026
7 checks passed
@tylerkron
tylerkron deleted the builder/issue-213-dialog-capture branch September 3, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant