Skip to content

Prove a click on a pixel runs the handler, with a real mouse - #608

Open
kellylford wants to merge 2 commits into
mainfrom
claude/mouse-click-testing-24a3d2
Open

Prove a click on a pixel runs the handler, with a real mouse#608
kellylford wants to merge 2 commits into
mainfrom
claude/mouse-click-testing-24a3d2

Conversation

@kellylford

Copy link
Copy Markdown
Owner

The mouse work shipped with its logic under test and its wiring asserted by reading MainWindow's source as text. Neither can answer the question the folder bug (#601) actually turned on: does a click on that pixel end up running that handler? Every piece there was correct and clicking a folder still did nothing.

What this adds

MouseClickInputTests sends real SendInput clicks at real screen coordinates against a real MainWindow, and asserts on what the ViewModel did:

Gesture Must
Click a folder row open that folder (one load)
Click a subfolder row open the child, not its parent
Click the expander chevron collapse the branch, open nothing
Click empty space below the rows open nothing
Double-click a folder row open it once
Click a message row open that message
Press on one row, release over another open neither
Ctrl+click a second message extend the selection, open nothing

Gated behind QUICKMAIL_RUN_INPUT_TESTS=1 like the synthesized-keystroke tests, and the CI guard that asserts those actually executed now covers these too — so they cannot quietly stop running.

Safety on a real desktop

Every click first confirms Windows puts the window under test at that screen point and that WPF's input hit test finds the intended element there. A mismatch fails without pressing, so a coordinate error cannot become a stray click on someone's desktop. Press and release are paired in finally blocks: an early version failed mid-drag, left the machine's button held down, and wedged the rest of the run behind it.

Four things the harness had to learn

Written up in the file headers and in CLAUDE.md, where the next person will be standing when they matter:

  • The window runs on its own thread with a real Dispatcher.Run() loop. Pumping it from the test thread with DispatcherFrame/PushFrame instead is a nested message loop over a live WebView2 — the deadlock the Modal Dialog Rules describe — and it hung the suite with no output.
  • SendInput only queues. Waiting for the dispatcher to go idle races the input it is meant to be waiting for.
  • InputHitTest, never VisualTreeHelper.HitTest, to ask what a click would land on: the latter is geometric and returns visuals inside a collapsed subtree, still carrying their last-arranged bounds, so a hidden pane answers for the visible one underneath it.
  • The fixture needs an account. With none, start-up opens the Account Manager modally, which disables the window under test — it then receives no input at all while remaining the window WindowFromPoint names and hit-testing correctly in WPF, so every click looks exactly like a click the app ignored.

Two assertions that were wrong about the app

Both now say so where they sit: a WPF ListBox has no native drag-select (so a drag selects one row, and what the test guards is that the release activates nothing), and the app prefetches the details of the whole visible list when a folder opens (so store-load counts cannot tell you a message was opened — the message tests compare MessageDetail identity instead).

Verification

8/8 pass on two consecutive runs; 8/8 skip with a visible reason when the env var is absent. The rest of the suite is unchanged by this — the 7 failures currently on this branch (AccountDialogHintTests, Pop3DialogTests) fail identically with this class excluded and are being looked at separately.

🤖 Generated with Claude Code

The mouse work shipped with its logic under test and its wiring asserted by
reading MainWindow's source as text. Neither can answer the question the folder
bug (#601) actually turned on: does a click on that pixel end up running that
handler? Every piece there was correct and clicking a folder still did nothing.

MouseClickInputTests sends real SendInput clicks at real screen coordinates
against a real MainWindow, and asserts on what the ViewModel did: a folder row
opens that folder, a child row opens the child and not its parent, the chevron
collapses without opening, empty space opens nothing, a double-click opens once,
a message row opens, a press released over another row opens neither, and a
Ctrl+click extends the selection without opening.

Gated behind QUICKMAIL_RUN_INPUT_TESTS=1 like the synthesized-keystroke tests,
and the CI guard that asserts those actually executed now covers these too, so
they cannot quietly stop running.

Every click first confirms Windows puts the window under test at that screen
point AND that WPF's input hit test finds the intended element there; a mismatch
fails without pressing, so a coordinate error cannot become a stray click on
someone's desktop. Press and release are paired in a finally: an early version
failed mid-drag, left the machine's button held down, and wedged the rest of the
run behind it.

Four things the harness had to learn, all written up where the next person will
be standing when they matter:

- The window runs on its own thread with a real Dispatcher.Run() loop. Pumping
  it from the test thread with DispatcherFrame/PushFrame instead is a nested
  message loop over a live WebView2 - the deadlock the Modal Dialog Rules
  describe - and it hung the suite with no output.
- SendInput only queues. Waiting for the dispatcher to go idle races the input
  it is meant to be waiting for.
- InputHitTest, never VisualTreeHelper.HitTest, to ask what a click would land
  on: the latter is geometric and returns visuals inside a collapsed subtree,
  still carrying their last-arranged bounds, so a hidden pane answers for the
  visible one underneath it.
- The fixture needs an account. With none, start-up opens the Account Manager
  modally, which disables the window under test - it then receives no input at
  all while remaining the window WindowFromPoint names and hit-testing correctly
  in WPF, so every click looks exactly like a click the app ignored.

Two assertions written first were wrong about the app rather than the app being
wrong, and say so where they sit: a WPF ListBox has no native drag-select, and
the app prefetches the details of the whole visible list when a folder opens, so
store-load counts cannot tell you a message was opened. The message tests
compare MessageDetail identity instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard that is supposed to make a real click safe to run on someone's desktop
did not do it. It asked whether our window was at the point the test AIMED at,
and whether a click there would hit the intended element - but that coordinate
came from the window's own layout, so it agreed with itself no matter where the
pointer actually went. Everything the file claimed about refusing to click when
the coordinates are wrong was untrue. It now asks about where the pointer really
is: that it landed where it was sent, that Windows puts our window there, and
that a click there resolves to the intended row.

The rest of the review, in order of how much it mattered:

- A throw from the fixture constructor skipped the whole cleanup, stranding a
  topmost window with a live message loop for the rest of the process - which
  then fails every later mouse test on "another window is in front" and takes the
  other windowed classes with it. Seeding asserts, so this was reachable.
- Dispose caught only TimeoutException, so anything MainWindow.OnClosed threw
  skipped the dispatcher shutdown and the join AND replaced the real test failure
  with a secondary one from the cleanup path.
- The double-click test waited for a full folder load between its two presses,
  which on a slow enough machine puts the second one past GetDoubleClickTime and
  quietly turns it into two single clicks. The presses now go back to back, and
  the test asserts the window was actually given ClickCount 2 - otherwise it
  passes without ever reaching the line it exists to guard.
- The empty-space test could not tell empty space from a row: its guard was
  satisfied by anything inside the tree, a row included. It now asks the app's own
  row lookup and requires it to find nothing.
- ClickingAFolderRow read the load count before the load it counts could have
  finished, so a second activation queued behind the first - the defect the count
  is for - would not have been counted yet.
- Math.Round in the absolute-coordinate normalization lands a pixel short, since
  Windows maps the value back with a floor. Ceiling, and clamped.
- The DPI awareness context is restored in a finally; CloseToTray is set
  explicitly rather than relied on; the startup wait is bounded so a hang inside
  Build reports instead of hanging the run with no output; IsExpanded is read on
  the window's thread like everything else; and a failure message can no longer
  throw on its way out and replace the diagnosis with a timeout.

Then CI found two more, both races this machine is too fast to lose:

- The guard compared visual INSTANCES. Containers get recycled, so the ViewModel
  finishing a load between resolving a row and moving onto it leaves the same row
  on screen as a different object, and the guard rejects a pointer that is exactly
  where it should be. It now compares the DATA the hit resolves to.
- The seed lost to the ViewModel's own startup load, which lands later there:
  seeding asserted five messages and the list was empty by the time a test looked.
  Seeding now repeats until it holds.

And one that was mine all along, reported earlier as somebody else's flakiness:
six AccountDialogHintTests and one Pop3DialogTests failed whenever these ran in
the same process. Clicking makes this process foreground; closing the window
afterwards leaves the foreground wherever it falls; and those tests focus a
control on a window shown with ShowActivated = false, which needs the process to
hold it. The foreground window is now restored alongside the pointer. Full suite
with the mouse tests on: 3190 passed, 0 failed, twice.

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

Copy link
Copy Markdown
Owner Author

Independent review applied, plus two more that CI found and one that turned out to be mine.

The central guard was not doing its job. It asked whether our window was at the point the test aimed at, and whether a click there would hit the intended element — but that coordinate came from the window's own layout, so it agreed with itself no matter where the pointer actually went. Everything this PR claimed about refusing to click when coordinates are wrong was untrue. It now asks about where the pointer really is.

Also from the review: a constructor throw skipped all cleanup and stranded a topmost window for the rest of the process; Dispose caught only TimeoutException, so a throw from OnClosed skipped the shutdown and replaced the real test failure with a cleanup one; the double-click test waited out a whole folder load between its presses, which past GetDoubleClickTime silently makes it two single clicks (now back-to-back, and it asserts the window was given ClickCount 2); the empty-space test could not tell empty space from a row; Math.Round in the coordinate normalization lands a pixel short; and a handful of smaller ones — DPI context restored in a finally, bounded startup wait, CloseToTray set explicitly, cross-thread read routed properly.

CI found two races this machine is too fast to lose. The guard compared visual instances, which go stale when containers are recycled — the ViewModel finishing a load between resolving a row and moving onto it left the same row on screen as a different object. And the seed lost to the runner's slower startup load: seeding asserted five messages and the list was empty by the time a test looked. The guard now compares the data the hit resolves to, and seeding repeats until it holds.

One was mine and I had called it someone else's. Six AccountDialogHintTests and one Pop3DialogTests failed whenever these ran in the same process. Clicking makes this process foreground; closing the window afterwards leaves the foreground wherever it falls; those tests focus a control on a window shown with ShowActivated = false, which needs the process to hold it. The foreground window is now restored alongside the pointer.

Full suite with the mouse tests on: 3190 passed, 0 failed, twice.

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