fix(ui): OP5 re-check residuals R1/R2 (coordinator pass) — OP5 CLOSED

R1: UiRoot now delivers WM_CAPTURECHANGED (0x215 — retail's own Win32
event-id space) to the element losing pointer capture on BOTH release
and re-target; UiScrollbar terminates a mid-drag gesture there,
completing it (one DragCompleted flush persisting the user's last-seen
value) and unlatching IsDragging — a panel-close keybind mid-drag or a
second-button re-target can no longer latch the drag flag forever and
silently suppress every later settings flush. Normal MouseUp paths
no-op (the latch is already clear when capture releases).

R2: the scalar latch arms BEFORE the track-click jump applies, so the
jump's own ScalarChanged tick defers its flush to the MouseUp's single
DragCompleted — one flush per press gesture, never the
inline-then-completed double; the DragCompleted doc now states the real
contract (fires once per value-capable gesture incl. capture loss)
instead of the refuted never-on-jump claim.

Tests: capture-loss mid-drag (ends + completes once + stray-MouseUp
no-double), no-drag capture-change no-op, bare-track-click
single-completion with the latch observed armed during the jump tick.
Also reconciles the research doc's U4 row to its closure (the six
caption pairs, the BN zero-fold post-mortem) per the OP6 rework's flag.

Full Release suite: 13,128 passed / 4 skipped / 0 failed (one
documented #250-class allocation flake on first run, green in
isolation and on full-suite rerun).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 08:13:24 +02:00
parent 472525b99e
commit 67b0815c79
5 changed files with 142 additions and 10 deletions

View file

@ -178,6 +178,89 @@ public class UiScrollbarTests
Assert.Equal(1, completedCount);
}
// ── OP5 re-check R1/R2 (2026-08-11, coordinator pass) ───────────────────
[Fact]
public void CaptureLossMidDrag_EndsTheGesture_CompletesOnce_AndUnlatchesIsDragging()
{
// R1: UiRoot can drop capture WITHOUT a MouseUp (panel hidden by a
// keybind mid-drag; a second button re-targeting capture). The
// WM_CAPTURECHANGED delivery must end the drag, fire ONE completion
// (persisting the user's last-seen value), and unlatch IsDragging —
// otherwise every later Reset/Defaults flush is silently suppressed.
int completedCount = 0;
var bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
ScalarChanged = _ => { },
DragCompleted = () => completedCount++,
};
bar.SetScalarPosition(0f);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 5)));
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 30)));
Assert.True(bar.IsDragging);
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
Assert.False(bar.IsDragging);
Assert.Equal(1, completedCount);
// A later stray MouseUp (capture already gone) must not double-fire.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 30));
Assert.Equal(1, completedCount);
}
[Fact]
public void CaptureChange_WithNoActiveDrag_IsANoOp()
{
int completedCount = 0;
var bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
DragCompleted = () => completedCount++,
};
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
Assert.False(bar.IsDragging);
Assert.Equal(0, completedCount);
}
[Fact]
public void HorizontalScalar_BareTrackClickJump_DefersItsTickAndCompletesExactlyOnce()
{
// R2: a bare track click (outside the thumb) jumps the scalar. The
// latch now arms BEFORE the jump applies, so the jump's own
// ScalarChanged tick observes IsDragging=true (a consumer defers its
// flush) and the MouseUp's single DragCompleted carries the gesture's
// one flush — never the inline-then-completed double.
int completedCount = 0;
bool draggingDuringTick = false;
UiScrollbar bar = null!;
bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
ScalarChanged = _ => draggingDuringTick = bar.IsDragging,
DragCompleted = () => completedCount++,
};
bar.SetScalarPosition(0f); // thumb spans [0, 16]
// Click far outside the thumb — the jump branch.
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 70)));
Assert.True(draggingDuringTick); // the jump tick saw the latch armed
Assert.Equal(0, completedCount);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 70)));
Assert.Equal(1, completedCount); // one gesture, one completion
}
[Fact]
public void HorizontalModel_DragCompleted_FiresOnceAtMouseUp()
{