test(ui): automation runner gains hover/mousemove — synthetic pointer verify without the OS cursor

Morning gate live-verify apparatus: 'hover element <datId>' and
'mousemove <x> <y>' drive UiRoot.OnMouseMove synthetically (no click, no
real cursor theft — a user is present at the machine during this round,
unlike the overnight rounds whose drive scripts moved the physical
cursor). Deliberately no probe-clock Advance: hover dwell and the
world-tooltip timing must ride the production frame tick's real
monotonic clock, which keeps running between script commands — an
Advance would stamp the idle timestamp with the probe's tiny private
counter.

(Committed from a re-attached worktree: the round's original worktree
was pruned from git's registry mid-session by an external cleanup; the
branch and all three finding commits were unaffected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 09:32:06 +02:00
parent 942a02af11
commit 302d90209d
2 changed files with 56 additions and 0 deletions

View file

@ -131,6 +131,32 @@ public sealed class RetailUiAutomationProbe
return true;
}
/// <summary>
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
/// element's center, for rollover/tooltip verification. Deliberately
/// does NOT <see cref="Advance"/> the probe's own micro-clock: hover
/// dwell (<see cref="UiRoot.MouseIdleMs"/> vs
/// <see cref="UiRoot.TooltipDelayMs"/>) and the world-tooltip timing
/// must ride the PRODUCTION frame tick's real monotonic clock
/// (<c>UiHost.Tick</c>'s <c>TickCount64</c>), which keeps running
/// between script commands — an <c>Advance</c> here would stamp the
/// idle timestamp with this probe's tiny private counter and make the
/// next production tick read an enormous (or negative) idle time.
/// </summary>
public bool HoverElement(uint datElementId)
{
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
_root.OnMouseMove((int)target.CenterX, (int)target.CenterY);
return true;
}
/// <summary>Raw synthetic mouse move (canvas coordinates). Same
/// no-<see cref="Advance"/> contract as <see cref="HoverElement"/> —
/// used to sweep the pointer across the 3D world for the world-hover
/// tooltip's mouse-idle dwell verification.</summary>
public void MoveMouse(int x, int y) => _root.OnMouseMove(x, y);
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
{
var target = FindByItemId(itemGuid, sourceKind);

View file

@ -195,6 +195,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
{
"dump" => DoDump(),
"click" => DoClick(command),
"hover" => DoHover(command),
"mousemove" => DoMouseMove(command),
"doubleclick" => DoDoubleClick(command),
"drag" => DoDrag(command),
"wait" => DoWait(command),
@ -232,6 +234,34 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
}
/// <summary>2026-08-17 morning gate: `hover element &lt;datId&gt;` — a
/// synthetic pointer hover with NO click, for rollover-state and
/// tooltip-dwell verification (the drive scripts must not move the real
/// OS cursor when a user is present). See
/// <see cref="RetailUiAutomationProbe.HoverElement"/>'s own doc for the
/// deliberate no-<c>Advance</c> clock contract.</summary>
private bool DoHover(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 3 || !string.Equals(p[1], "element", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: hover element <datId>");
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
return _probe.HoverElement(datId) || Stop(command, "hover element failed");
}
/// <summary>`mousemove &lt;x&gt; &lt;y&gt;` — raw synthetic pointer move in
/// canvas coordinates (world-hover dwell sweeps).</summary>
private bool DoMouseMove(ScriptCommand command)
{
var p = command.Parts;
if (p.Length != 3)
return Stop(command, "usage: mousemove <x> <y>");
if (!TryParseInt(p[1], out int x)) return Stop(command, $"bad x '{p[1]}'");
if (!TryParseInt(p[2], out int y)) return Stop(command, $"bad y '{p[2]}'");
_probe.MoveMouse(x, y);
return true;
}
private bool DoDoubleClick(ScriptCommand command)
{
var p = command.Parts;