acdream/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
Erik 06016014bc fix(ui): port retail window control feedback
Map synthetic move and resize affordances to the exact DAT cursors, make chat top chrome movable, and replace stale primary-panel height caps with a dynamic screen-edge constraint. This keeps the retained wrapper adaptation aligned with retail Dragbar/Resizebar behavior.
2026-07-17 09:40:08 +02:00

386 lines
16 KiB
C#

using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class CursorFeedbackControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
private const uint Source = 0x50000020u;
private const uint Target = 0x50000021u;
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
private const uint HealthKitUseability = 0x00220008u;
[Fact]
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
{
var c = new CursorFeedbackController();
var accept = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Accept,
HoverResizeEdges: ResizeEdges.Right));
var reject = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Reject,
HoverResizeEdges: ResizeEdges.Right));
Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind);
Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind);
}
[Theory]
[InlineData(ResizeEdges.Left, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
[InlineData(ResizeEdges.Top, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
[InlineData(ResizeEdges.Left | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
[InlineData(ResizeEdges.Right | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
public void ResizeEdges_chooseExpectedRetailCursor(
ResizeEdges edges,
CursorFeedbackKind expected,
uint expectedDid)
{
var c = new CursorFeedbackController();
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
Assert.Equal(expected, feedback.Kind);
Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), feedback.Cursor);
}
[Fact]
public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState()
{
var c = new CursorFeedbackController();
Assert.Equal(CursorFeedbackKind.WindowMove,
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16),
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Cursor);
Assert.Equal(CursorFeedbackKind.Text,
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
}
[Fact]
public void ActiveSyntheticResize_keepsControlCursorOverAuthoredCapturedElement()
{
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
Resizable = true,
};
var content = new UiPanel { Width = 150, Height = 100 };
var authored = new UiCursorMedia(0x06009999u, 3, 4);
content.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
window.AddChild(content);
root.AddChild(window);
var controller = new CursorFeedbackController();
root.OnMouseMove(199, 90); // inside the five-pixel right-edge resizebar region
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), controller.Update(root).Cursor);
root.OnMouseDown(UiMouseButton.Left, 199, 90);
root.OnMouseMove(100, 90); // leave the edge while resize capture remains active
CursorFeedback active = controller.Update(root);
Assert.Equal(CursorFeedbackKind.ResizeHorizontal, active.Kind);
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), active.Cursor);
}
[Fact]
public void AuthoredWidgetCursor_winsUntilSyntheticWindowMoveStarts()
{
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
};
var dragRegion = new UiPanel { Width = 150, Height = 100 };
var authored = new UiCursorMedia(0x06008888u, 7, 8);
dragRegion.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
window.AddChild(dragRegion);
root.AddChild(window);
var controller = new CursorFeedbackController();
root.OnMouseMove(100, 90);
Assert.Equal(authored, controller.Update(root).Cursor);
root.OnMouseDown(UiMouseButton.Left, 100, 90);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
root.OnMouseMove(300, 200);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
}
[Fact]
public void UiLock_suppressesSyntheticWindowControlCursors()
{
var root = new UiRoot { Width = 400, Height = 300, UiLocked = true };
root.AddChild(new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
Resizable = true,
});
root.OnMouseMove(199, 90);
CursorFeedback feedback = new CursorFeedbackController().Update(root);
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
Assert.False(feedback.Cursor.IsValid);
}
[Fact]
public void TargetMode_reportsValidInvalidAndPendingTargets()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
Assert.Equal(CursorFeedbackKind.TargetValid,
c.Resolve(new CursorFeedbackSnapshot(
HoverUi: true,
HoverTargetGuid: Player,
HoverTargetCompatible: true)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(
HoverUi: true,
HoverTargetGuid: 0x5000BADu,
HoverTargetCompatible: false)).Kind);
// Retail UpdateCursorState: no found object → PENDING, including over
// UI chrome (the HoverUi→Invalid arm was a non-retail invention).
Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot()).Kind);
}
[Fact]
public void UpdateFromRoot_slotItemDrivesGlobalTargetCursor_withoutInventingWidgetState()
{
var objects = SeedTargetObjects();
objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var root = new UiRoot { Width = 800, Height = 600 };
var slot = new UiItemSlot
{
Left = 10,
Top = 10,
Width = 32,
Height = 32,
};
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
slot.SetStateCursors(new Dictionary<string, UiCursorMedia>
{
["Drag_rollover_accept"] = acceptCursor,
});
slot.SetItem(Target, iconTexture: 1u);
root.AddChild(slot);
root.OnMouseMove(20, 20);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
var feedback = c.Update(root);
Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind);
Assert.Equal(RetailGlobalCursorKind.TargetValid, feedback.GlobalKind);
Assert.False(feedback.Cursor.IsValid);
}
[Fact]
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var root = new UiRoot { Width = 800, Height = 600 }; // nothing under the mouse
root.OnMouseMove(400, 300);
var c = new CursorFeedbackController(interaction, worldTargetProvider: () => Player);
Assert.True(interaction.ActivateItem(Source));
// World hover (retail SmartBox found object) drives valid/invalid…
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
// …but UI occludes the world: hovering a plain panel → PENDING even
// though the world provider would return a valid target.
var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 };
root.AddChild(panel);
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
}
[Fact]
public void GenericUseAndExamineModes_driveRetailGlobalCursors()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.UseSelectedOrEnterMode(0));
Assert.Equal(RetailGlobalCursorKind.Use,
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
Assert.Equal(RetailGlobalCursorKind.UseFound,
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
interaction.CancelTargetMode();
Assert.True(interaction.ExamineSelectedOrEnterMode(0));
Assert.Equal(RetailGlobalCursorKind.Examine,
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
Assert.Equal(RetailGlobalCursorKind.ExamineFound,
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
}
public static TheoryData<CursorFeedbackSnapshot, RetailGlobalCursorKind> GlobalCursorCases => new()
{
{ new(), RetailGlobalCursorKind.Default },
{ new(HoverTargetGuid: Target), RetailGlobalCursorKind.DefaultFound },
{ new(CombatMode: CombatMode.Melee), RetailGlobalCursorKind.MeleeOrMissile },
{ new(HoverTargetGuid: Target, CombatMode: CombatMode.Melee), RetailGlobalCursorKind.MeleeOrMissileFound },
{ new(CombatMode: CombatMode.Missile), RetailGlobalCursorKind.MeleeOrMissile },
{ new(CombatMode: CombatMode.Magic), RetailGlobalCursorKind.Magic },
{ new(HoverTargetGuid: Target, CombatMode: CombatMode.Magic), RetailGlobalCursorKind.MagicFound },
{ new(TargetMode: RetailCursorTargetMode.Examine), RetailGlobalCursorKind.Examine },
{ new(HoverTargetGuid: Target, TargetMode: RetailCursorTargetMode.Examine), RetailGlobalCursorKind.ExamineFound },
{ new(TargetMode: RetailCursorTargetMode.Use), RetailGlobalCursorKind.Use },
{ new(HoverTargetGuid: Target, TargetMode: RetailCursorTargetMode.Use), RetailGlobalCursorKind.UseFound },
{ new(BusyCount: 1), RetailGlobalCursorKind.Busy },
{ new(HoverTargetGuid: Target, BusyCount: 1), RetailGlobalCursorKind.BusyFound },
{ new(TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetPending },
{ new(HoverTargetGuid: Target, HoverTargetCompatible: true, TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetValid },
{ new(HoverTargetGuid: Target, HoverTargetCompatible: false, TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetInvalid },
};
[Theory]
[MemberData(nameof(GlobalCursorCases))]
public void GlobalCursorDecisionTree_matchesUpdateCursorState(
CursorFeedbackSnapshot snapshot,
RetailGlobalCursorKind expected)
{
var c = new CursorFeedbackController();
Assert.Equal(expected, c.Resolve(snapshot).GlobalKind);
}
[Fact]
public void BusyGlobalCursor_winsOverTargetAndCombat()
{
var c = new CursorFeedbackController();
var snapshot = new CursorFeedbackSnapshot(
HoverTargetGuid: Target,
HoverTargetCompatible: true,
BusyCount: 1,
TargetMode: RetailCursorTargetMode.UseTarget,
CombatMode: CombatMode.Magic);
Assert.Equal(RetailGlobalCursorKind.BusyFound, c.Resolve(snapshot).GlobalKind);
}
[Fact]
public void WidgetCursorPrecedence_isCapturedThenHoveredThenGlobalDefault()
{
var root = new UiRoot { Width = 200, Height = 100 };
var captured = new UiPanel { Left = 0, Top = 0, Width = 50, Height = 50 };
var hovered = new UiPanel { Left = 60, Top = 0, Width = 50, Height = 50 };
var capturedCursor = new UiCursorMedia(0x06001001u, 1, 2);
var hoveredCursor = new UiCursorMedia(0x06001002u, 3, 4);
captured.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = capturedCursor });
hovered.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = hoveredCursor });
root.AddChild(captured);
root.AddChild(hovered);
var c = new CursorFeedbackController();
root.OnMouseMove(10, 10);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(70, 10);
Assert.Equal(capturedCursor, c.Update(root).Cursor);
root.OnMouseUp(UiMouseButton.Left, 70, 10);
Assert.Equal(hoveredCursor, c.Update(root).Cursor);
root.OnMouseMove(150, 75);
var fallback = c.Update(root);
Assert.False(fallback.Cursor.IsValid);
Assert.Equal(RetailGlobalCursorKind.Default, fallback.GlobalKind);
}
private static ClientObjectTable SeedTargetObjects()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
objects.MoveItem(Pack, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Source,
Name = "Health Kit",
Type = ItemType.Misc,
Useability = HealthKitUseability,
TargetType = (uint)ItemType.Creature, // retail kind gate: kits target creatures/players
});
objects.MoveItem(Source, Pack, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Misc,
});
objects.MoveItem(Target, Pack, 1);
return objects;
}
}