Preserve sub-millisecond frame deltas with a command-local compensated clock so uncapped diagnostic routes do not outrun materialization and settling. Keep sleep boundaries inclusive, wait timeouts strict, and handle long-duration and invalid-delta cases deterministically. Record the aligned seven-destination connected route with a full turn at every stop. Release build succeeds and all 6,308 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
444 lines
14 KiB
C#
444 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using System.IO;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Testing;
|
|
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public sealed class RetailUiAutomationProbeTests
|
|
{
|
|
private sealed class SpyHandler : IItemListDragHandler
|
|
{
|
|
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
|
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
|
|
|
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
|
=> LastLift = (sourceList, sourceCell, payload);
|
|
|
|
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
|
=> ItemDragAcceptance.Accept;
|
|
|
|
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
|
=> LastDrop = (targetList, targetCell, payload);
|
|
}
|
|
|
|
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
|
|
RootWithTwoItemLists()
|
|
{
|
|
var root = new UiRoot { Width = 240, Height = 120 };
|
|
var objects = new ClientObjectTable();
|
|
|
|
var source = new UiItemList(_ => (1u, 1, 1))
|
|
{
|
|
DatElementId = 0x10000010u,
|
|
Left = 10,
|
|
Top = 10,
|
|
Width = 32,
|
|
Height = 32,
|
|
};
|
|
source.Cell.SlotIndex = 0;
|
|
source.Cell.SourceKind = ItemDragSource.Inventory;
|
|
source.Cell.SetItem(0x5001u, 0x99u);
|
|
|
|
var target = new UiItemList(_ => (1u, 1, 1))
|
|
{
|
|
DatElementId = 0x10000020u,
|
|
Left = 70,
|
|
Top = 10,
|
|
Width = 32,
|
|
Height = 32,
|
|
};
|
|
target.Cell.SlotIndex = 1;
|
|
|
|
var handler = new SpyHandler();
|
|
target.RegisterDragHandler(handler);
|
|
|
|
root.AddChild(source);
|
|
root.AddChild(target);
|
|
return (root, source, target, handler, objects);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_listsDatElementsAndItemSlots()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
|
|
var rows = probe.Snapshot();
|
|
|
|
Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList));
|
|
var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u);
|
|
Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind);
|
|
Assert.Equal(0, itemRow.SlotIndex);
|
|
Assert.True(itemRow.Width > 0f);
|
|
Assert.True(itemRow.Height > 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClickItem_routesThroughUiRootDoubleClick()
|
|
{
|
|
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
|
bool doubleClicked = false;
|
|
source.Cell.DoubleClicked = () => doubleClicked = true;
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
|
|
Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory));
|
|
|
|
Assert.True(doubleClicked);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragItemToElement_deliversDropReleaseToTargetCell()
|
|
{
|
|
var (root, _, target, handler, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
|
|
Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory));
|
|
|
|
Assert.NotNull(handler.LastDrop);
|
|
Assert.Same(target, handler.LastDrop!.Value.list);
|
|
Assert.Same(target.Cell, handler.LastDrop.Value.cell);
|
|
Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragItemOutside_raisesRootOutsideUiEvent()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
object? payload = null;
|
|
(int x, int y) release = default;
|
|
root.DragReleasedOutsideUi += (p, x, y) =>
|
|
{
|
|
payload = p;
|
|
release = (x, y);
|
|
};
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
|
|
Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory));
|
|
|
|
var itemPayload = Assert.IsType<ItemDragPayload>(payload);
|
|
Assert.Equal(0x5001u, itemPayload.ObjId);
|
|
Assert.Equal((220, 100), release);
|
|
}
|
|
|
|
[Fact]
|
|
public void AssertItem_checksObjectTableState()
|
|
{
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x5001u,
|
|
ContainerId = 0x7001u,
|
|
ContainerSlot = 3,
|
|
CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
|
});
|
|
var probe = new RetailUiAutomationProbe(new UiRoot(), objects);
|
|
|
|
var ok = probe.AssertItem(
|
|
0x5001u,
|
|
equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
|
containerId: 0x7001u,
|
|
slot: 3);
|
|
var bad = probe.AssertItem(0x5001u, slot: 4);
|
|
|
|
Assert.True(ok.Success);
|
|
Assert.False(bad.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe()
|
|
{
|
|
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
|
bool doubleClicked = false;
|
|
source.Cell.DoubleClicked = () => doubleClicked = true;
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var logs = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, new[]
|
|
{
|
|
"wait item 0x5001 inventory 100",
|
|
"doubleclick item 0x5001 inventory",
|
|
});
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add);
|
|
|
|
runner.Tick(0.016);
|
|
|
|
Assert.True(doubleClicked);
|
|
Assert.True(runner.Completed);
|
|
Assert.Contains(logs, line => line.Contains("UI probe script complete"));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_command_routesThroughInjectedClientSubmitPath()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllText(path, "command /ls");
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0.016);
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_sleep_preserves_fractional_uncapped_frame_time()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 10", "command /ls"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0d); // Arm the sleep at script time zero.
|
|
for (int i = 0; i < 1_000; i++)
|
|
runner.Tick(0.000001); // 1 ms total, not the old artificial 1,000 ms.
|
|
|
|
Assert.Empty(submitted);
|
|
Assert.False(runner.Completed);
|
|
|
|
runner.Tick(0.009); // Exact 10 ms deadline, including the 1 ms above.
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_sleep_releases_on_exact_fractional_frame_deadline()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 1000", "command /ls"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0d); // Arm the sleep at script time zero.
|
|
for (int i = 0; i < 60; i++)
|
|
runner.Tick(1d / 60d);
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_sleep_rebases_fractional_clock_after_long_prior_command()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 1000000", "sleep 1000", "command /ls"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0d);
|
|
runner.Tick(1000d); // Complete the 1,000,000 ms precursor and arm the next sleep.
|
|
for (int i = 0; i < 60; i++)
|
|
runner.Tick(1d / 60d);
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_sleep_ignores_invalid_or_overflowing_frame_deltas()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 1", "command /ls"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0d);
|
|
runner.Tick(double.NaN);
|
|
runner.Tick(double.PositiveInfinity);
|
|
runner.Tick(double.NegativeInfinity);
|
|
runner.Tick(double.MaxValue); // Finite input whose millisecond conversion overflows.
|
|
runner.Tick(-1d);
|
|
|
|
Assert.Empty(submitted);
|
|
Assert.False(runner.Completed);
|
|
|
|
runner.Tick(0.001d);
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(30)]
|
|
[InlineData(120)]
|
|
public void ScriptRunner_wait_timeout_does_not_fire_on_exact_fractional_deadline(int framesPerSecond)
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var logs = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 1000000", "wait item 0xDEAD inventory 1000"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
log: logs.Add);
|
|
|
|
runner.Tick(0d);
|
|
runner.Tick(1000d); // Complete the precursor and arm the wait at a rebased zero.
|
|
for (int i = 0; i < framesPerSecond; i++)
|
|
runner.Tick(1d / framesPerSecond);
|
|
|
|
Assert.False(runner.Completed);
|
|
Assert.DoesNotContain(logs, line => line.Contains("timed out", StringComparison.Ordinal));
|
|
|
|
runner.Tick(1d / framesPerSecond);
|
|
|
|
Assert.True(runner.Completed);
|
|
Assert.Contains(logs, line => line.Contains("timed out", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_one_hour_sleep_releases_on_exact_60Hz_deadline()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var submitted = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllLines(path, ["sleep 3600000", "command /ls"]);
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
submitCommand: submitted.Add);
|
|
|
|
runner.Tick(0d);
|
|
for (int i = 0; i < 60 * 60 * 60; i++)
|
|
runner.Tick(1d / 60d);
|
|
|
|
Assert.Equal(["/ls"], submitted);
|
|
Assert.True(runner.Completed);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptRunner_one_hour_wait_does_not_timeout_on_exact_30Hz_deadline()
|
|
{
|
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
|
var probe = new RetailUiAutomationProbe(root, objects);
|
|
var logs = new List<string>();
|
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
|
File.WriteAllText(path, "wait item 0xDEAD inventory 3600000");
|
|
|
|
try
|
|
{
|
|
var runner = new RetailUiAutomationScriptRunner(
|
|
probe,
|
|
path,
|
|
dumpOnStart: false,
|
|
log: logs.Add);
|
|
|
|
runner.Tick(0d);
|
|
for (int i = 0; i < 30 * 60 * 60; i++)
|
|
runner.Tick(1d / 30d);
|
|
|
|
Assert.False(runner.Completed);
|
|
Assert.DoesNotContain(logs, line => line.Contains("timed out", StringComparison.Ordinal));
|
|
|
|
runner.Tick(1d / 30d);
|
|
|
|
Assert.True(runner.Completed);
|
|
Assert.Contains(logs, line => line.Contains("timed out", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|