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(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 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 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 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 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 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 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 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 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 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); } } }