merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open doors fully passable) + the full collision/streaming/dense-town-FPS arc meet the paperdoll/inventory work. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
43
tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs
Normal file
43
tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DatWidgetFactoryZOrderTests
|
||||
{
|
||||
private static (uint, int, int) NoChrome(uint id) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_HigherZLevel_DrawsBehind()
|
||||
{
|
||||
// The gmInventoryUI backdrop (ZLevel 100, ReadOrder 4) must draw BEHIND the panels
|
||||
// (ZLevel 0, ReadOrder 1): lower ZOrder = drawn first = behind. Before the fix, ZOrder
|
||||
// came from ReadOrder only, so the backdrop (4) painted over the panels (1).
|
||||
var backdrop = new ElementInfo { Type = 3, ReadOrder = 4, ZLevel = 100 };
|
||||
var panel = new ElementInfo { Type = 3, ReadOrder = 1, ZLevel = 0 };
|
||||
|
||||
var b = DatWidgetFactory.Create(backdrop, NoChrome, null);
|
||||
var p = DatWidgetFactory.Create(panel, NoChrome, null);
|
||||
|
||||
Assert.True(b!.ZOrder < p!.ZOrder, $"backdrop ZOrder {b.ZOrder} should be < panel ZOrder {p.ZOrder}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_AllZeroZLevel_EqualsReadOrder()
|
||||
{
|
||||
// Regression guard: a vitals-style window (every element ZLevel 0) keeps ZOrder == ReadOrder.
|
||||
var e = new ElementInfo { Type = 3, ReadOrder = 5, ZLevel = 0 };
|
||||
var w = DatWidgetFactory.Create(e, NoChrome, null);
|
||||
Assert.Equal(5, w!.ZOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_SameZLevel_OrderedByReadOrder()
|
||||
{
|
||||
// Within one ZLevel, ReadOrder is the tiebreaker (higher ReadOrder draws on top).
|
||||
var lo = new ElementInfo { Type = 3, ReadOrder = 2, ZLevel = 2 };
|
||||
var hi = new ElementInfo { Type = 3, ReadOrder = 7, ZLevel = 2 };
|
||||
var l = DatWidgetFactory.Create(lo, NoChrome, null);
|
||||
var h = DatWidgetFactory.Create(hi, NoChrome, null);
|
||||
Assert.True(l!.ZOrder < h!.ZOrder);
|
||||
}
|
||||
}
|
||||
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DragDropSpineTests
|
||||
{
|
||||
// A spy handler used across the spine tests.
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public bool AcceptResult = true;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastLift = (list, cell, p); }
|
||||
|
||||
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return AcceptResult; }
|
||||
|
||||
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastDrop = (list, cell, p); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_holdsAllFields()
|
||||
{
|
||||
var src = new UiItemSlot();
|
||||
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(3, p.SourceSlot);
|
||||
Assert.Same(src, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiItemList_registerDragHandler_roundtrips()
|
||||
{
|
||||
var list = new UiItemList(_ => (0u, 0, 0));
|
||||
Assert.Null(list.DragHandler);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
Assert.Same(h, list.DragHandler);
|
||||
}
|
||||
|
||||
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
|
||||
[Fact]
|
||||
public void GetDragPayload_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragPayload());
|
||||
|
||||
[Fact]
|
||||
public void GetDragPayload_boundCell_snapshotsFields()
|
||||
{
|
||||
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(4, p.SourceSlot);
|
||||
Assert.Same(cell, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragGhost());
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_boundCell_returnsIconTuple()
|
||||
{
|
||||
var cell = new UiItemSlot { Width = 32, Height = 32 };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var g = cell.GetDragGhost();
|
||||
Assert.NotNull(g);
|
||||
Assert.Equal(0x99u, g!.Value.tex);
|
||||
Assert.Equal(32, g.Value.w);
|
||||
Assert.Equal(32, g.Value.h);
|
||||
}
|
||||
|
||||
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
|
||||
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
return (list, list.Cell, h);
|
||||
}
|
||||
|
||||
private static ItemDragPayload SomePayload()
|
||||
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = true;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = false;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragOver_resetsOverlayToNeutral()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_accepted_dispatchesToHandler()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
Assert.Same(list, h.LastDrop!.Value.list);
|
||||
Assert.Same(cell, h.LastDrop.Value.cell);
|
||||
Assert.Same(p, h.LastDrop.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_dispatchesToHandler_regardlessOfData0()
|
||||
{
|
||||
// Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
|
||||
// hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragBegin_callsHandlerOnDragLift()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
|
||||
Assert.NotNull(h.LastLift);
|
||||
Assert.Same(list, h.LastLift!.Value.list);
|
||||
Assert.Same(cell, h.LastLift.Value.cell);
|
||||
Assert.Same(p, h.LastLift.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
|
||||
cell.Clear(); // simulate the lift emptying the source
|
||||
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
list.Cell.SetItem(0x5001u, 0x99u);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
root.AddChild(list);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
|
||||
root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
|
||||
Assert.NotNull(h.LastLift); // lift happened
|
||||
Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
|
||||
Assert.Null(root.DragSource); // cleaned up
|
||||
}
|
||||
|
||||
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
|
||||
// A bound, hit-testable slot inside a list, sized for the hit-test.
|
||||
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
root.AddChild(list);
|
||||
return (root, list, list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_arms_whenPayloadNonNull()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // >3px → promote to drag
|
||||
Assert.Same(cell, root.DragSource);
|
||||
Assert.IsType<ItemDragPayload>(root.DragPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
|
||||
{
|
||||
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10);
|
||||
Assert.Null(root.DragSource); // never armed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_withoutDrag_firesUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
|
||||
Assert.True(used);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedDrag_doesNotFireUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // promote to drag
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
|
||||
Assert.False(used);
|
||||
}
|
||||
|
||||
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
|
||||
[Fact]
|
||||
public void DragEnter_orphanCell_noList_defaultsToReject()
|
||||
{
|
||||
var cell = new UiItemSlot(); // no parent list → FindList() null
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_listWithoutHandler_defaultsToReject()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
|
||||
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
// ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
|
||||
// Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
|
||||
// frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
|
||||
// (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
|
||||
// drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
|
||||
// RootWithBoundSlot tests put the slot directly under the root (no draggable
|
||||
// ancestor), so they could not catch this.
|
||||
private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
frame.AddChild(list);
|
||||
root.AddChild(frame);
|
||||
return (root, frame, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
|
||||
{
|
||||
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
|
||||
// Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Same(list.Cell, root.DragSource); // item drag armed
|
||||
Assert.Equal(10f, frame.Left); // window did NOT move
|
||||
Assert.Equal(300f, frame.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
|
||||
{
|
||||
var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Null(root.DragSource); // no item drag
|
||||
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
|
||||
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
|
||||
}
|
||||
}
|
||||
528
tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Normal file
528
tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class InventoryControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
// UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
|
||||
// (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
// Element ids (spec §1).
|
||||
private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint ContainerList = 0x100001CAu;
|
||||
private const uint TopContainer = 0x100001C9u;
|
||||
private const uint BurdenMeter = 0x100001D9u;
|
||||
private const uint BurdenText = 0x100001D8u;
|
||||
private const uint BurdenCaption = 0x100001D7u;
|
||||
private const uint ContentsCaption = 0x100001C5u;
|
||||
private const uint ContentsScrollbar = 0x100001C7u;
|
||||
|
||||
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
||||
UiItemList top, UiMeter meter, UiElement burdenText,
|
||||
UiElement burdenCap, UiElement contentsCap) BuildLayout()
|
||||
{
|
||||
var grid = new UiItemList { Width = 192, Height = 96 };
|
||||
var containers = new UiItemList { Width = 36, Height = 252 };
|
||||
var top = new UiItemList { Width = 36, Height = 36 };
|
||||
var meter = new UiMeter { Width = 11, Height = 58 };
|
||||
var burdenText = new TestElement { Width = 36, Height = 15 };
|
||||
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
||||
var contentsCap = new TestElement { Width = 192, Height = 15 };
|
||||
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
||||
var root = new TestElement { Width = 300, Height = 362 };
|
||||
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
||||
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
||||
root.AddChild(contentsCap); root.AddChild(scrollbar);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
||||
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
||||
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
||||
burdenText, burdenCap, contentsCap);
|
||||
}
|
||||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
||||
|
||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity });
|
||||
t.MoveItem(bag, Player, slot);
|
||||
}
|
||||
|
||||
// Place an object in a container at a slot via the faithful production path:
|
||||
// AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex
|
||||
// (the same machinery server-driven moves use), so GetContents returns it slot-ordered.
|
||||
private static void SeedContained(ClientObjectTable t, uint guid, uint container, int slot,
|
||||
int burden = 0, ItemType type = ItemType.None)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, Burden = burden, Type = type });
|
||||
t.MoveItem(guid, container, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_fills_contents_grid_with_loose_items_only()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Seed via the indexed path: AddOrUpdate registers the object, MoveItem places it in
|
||||
// the container at a slot (the faithful "server moved item into container" flow that
|
||||
// calls Reindex). GetContents — retail's per-container list — then returns them ordered.
|
||||
SeedContained(objects, 0xA, Player, slot: 0, burden: 10); // loose
|
||||
SeedContained(objects, 0xB, Player, slot: 1, burden: 20); // loose
|
||||
SeedContained(objects, 0xC, Player, slot: 2, type: ItemType.Container); // side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 2 loose + 100 empty (main-pack capacity)
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
|
||||
Assert.Equal(0u, grid.GetItem(2)!.ItemId); // padded empty
|
||||
Assert.Equal(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column)
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipped_items_are_excluded_from_the_grid_and_selector()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // loose pack item
|
||||
// A self-wielded item: MoveItem with an equip location indexes it under the player
|
||||
// (the live wield path), but it must NOT show in the pack grid or the selector.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xD });
|
||||
objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon);
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 1 loose + 101 empty (main-pack capacity)
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here)
|
||||
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_is_six_columns_thirtytwo_px()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Equal(6, grid.Columns);
|
||||
Assert.Equal(32f, grid.CellWidth);
|
||||
Assert.Equal(32f, grid.CellHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectAdded_for_player_item_rebuilds_grid()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // empty grid still shows the full 102-slot pack
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId); // slot 0 empty before the add
|
||||
|
||||
objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
|
||||
null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // still 102; the item fills slot 0
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_meter_fill_and_percent_from_load()
|
||||
{
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
|
||||
Bind(layout, objects, strength: 100);
|
||||
|
||||
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
|
||||
Assert.True(meter.Vertical);
|
||||
// The % text caption child reads "50%".
|
||||
Assert.Contains("50%", CaptionText(burdenText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Captions_render_known_strings()
|
||||
{
|
||||
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Contains("Burden", CaptionText(burdenCap));
|
||||
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
|
||||
{
|
||||
// B-Wire: the burden bar must read the server's wire EncumbranceVal (PropertyInt 5),
|
||||
// NOT the client-side carried-Burden sum (retires AP-48 — the sum is only the fallback).
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Str 100 → capacity 15000. A carried item sums to 3000 (would read 20%), but the
|
||||
// server says EncumbranceVal = 7500 → load 0.5 → fill 0.1667 → "50%". Wire wins.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 3000 });
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 7500; // EncumbranceVal (PropertyInt 5)
|
||||
objects.UpsertProperties(Player, bundle);
|
||||
|
||||
Bind(layout, objects, strength: 100);
|
||||
|
||||
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); // 7500/15000/3 (wire), not 3000-based 0.0667
|
||||
Assert.Contains("50%", CaptionText(burdenText)); // not "20%"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_player_int_update_refreshes_burden()
|
||||
{
|
||||
// B-Wire C1d: a live PrivateUpdatePropertyInt (0x02CD) for the player's EncumbranceVal
|
||||
// fires ObjectUpdated on the PLAYER object; Concerns now includes o.ObjectId == player,
|
||||
// so the burden bar repaints. Without the C1d branch this update would be ignored.
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 3000; // initial EncumbranceVal → load 0.2 → "20%"
|
||||
objects.UpsertProperties(Player, bundle);
|
||||
Bind(layout, objects, strength: 100);
|
||||
Assert.Contains("20%", CaptionText(burdenText));
|
||||
|
||||
objects.UpdateIntProperty(Player, 5u, 9000); // live 0x02CD: → load 0.6 → "60%"
|
||||
|
||||
Assert.Contains("60%", CaptionText(burdenText));
|
||||
Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
var bar = (UiScrollbar)layout.FindElement(ContentsScrollbar)!;
|
||||
Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contents_grid_pads_empty_slots_to_main_pack_capacity()
|
||||
{
|
||||
// The grid shows the full main-pack capacity (default 102) — occupied + empty — so the
|
||||
// pack reads like retail's fixed 102-slot grid you scroll, not just the loose items.
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 });
|
||||
SeedContained(objects, 0xA, Player, slot: 0, burden: 5); // one loose item
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 1 item + 101 empty frames = 102
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0u, grid.GetItem(50)!.ItemId); // a padded empty slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side_bag_column_pads_empty_slots_up_to_capacity()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 });
|
||||
SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag
|
||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame
|
||||
Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_sprites_are_applied_to_the_three_lists()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
|
||||
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
|
||||
|
||||
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
|
||||
Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell
|
||||
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickSideBag_sendsUse_andSwapsGridToBagContents()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1)
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag
|
||||
var uses = new List<uint>();
|
||||
Bind(layout, objects, uses: uses);
|
||||
|
||||
// The side-bag column's first cell holds the bag guid; click it.
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.Contains(0xCu, uses); // Use(bag) sent
|
||||
Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents
|
||||
Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1);
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open the bag
|
||||
top.GetItem(0)!.Clicked!(); // click the main-pack cell
|
||||
|
||||
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
|
||||
Assert.DoesNotContain(Player, uses); // no Use for the main pack
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
|
||||
Assert.Equal(102, grid.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC1, slot: 0);
|
||||
SeedBag(objects, 0xC2, slot: 1);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
|
||||
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
|
||||
|
||||
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
|
||||
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBag_marksTriangleAndSquare_onTheBagCell()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0);
|
||||
Bind(layout, objects, uses: new List<uint>());
|
||||
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag
|
||||
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer()
|
||||
{
|
||||
var (layout, grid, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
grid.GetItem(0)!.Clicked!(); // select the loose item
|
||||
|
||||
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
|
||||
Assert.Empty(uses); // selection sends no wire
|
||||
Assert.Empty(closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_mainPackCell_isOpenContainer()
|
||||
{
|
||||
var (layout, _, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainPackCell_requestsConstantBackpackIcon_notPlayerBodyIcon() // AP-51 retire
|
||||
{
|
||||
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Give the player a (wrong) body icon to prove the main-pack cell does NOT use it.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u });
|
||||
(ItemType type, uint icon)? mainPackCall = null;
|
||||
InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
|
||||
strength: () => 100, datFont: null);
|
||||
|
||||
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
|
||||
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).
|
||||
Assert.NotNull(mainPackCall);
|
||||
Assert.Equal(ItemType.Container, mainPackCall!.Value.type);
|
||||
Assert.Equal(0x0600127Eu, mainPackCall.Value.icon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SideBagCell_capacityBar_reflectsContents()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24)
|
||||
for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside
|
||||
Bind(layout, objects);
|
||||
|
||||
// Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float).
|
||||
Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LooseGridItem_hasNoCapacityBar()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container
|
||||
}
|
||||
|
||||
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
SeedContained(objects, 0xB, Player, slot: 1);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
// Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1).
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
|
||||
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onSideBagCell_movesIntoThatBag()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
|
||||
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_fullSideBag_rejects_butGridAccepts()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1...
|
||||
SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
||||
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_isNoOp_itemStaysUntilServerConfirms()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu));
|
||||
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_closedBag_unknownCount_advisoryAccepts() // AP-61
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a bag with capacity but NO indexed contents (closed)
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
// GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept.
|
||||
Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 3); // item starts in the main pack at slot 3
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var ctrl = Bind(layout, objects);
|
||||
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu));
|
||||
Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant)
|
||||
|
||||
objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed)
|
||||
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack
|
||||
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot
|
||||
}
|
||||
|
||||
// Reads the text of the UiText caption child attached by the controller.
|
||||
private static string CaptionText(UiElement host)
|
||||
{
|
||||
foreach (var c in host.Children)
|
||||
if (c is UiText t)
|
||||
{
|
||||
var lines = t.LinesProvider();
|
||||
if (lines.Count > 0) return lines[0].Text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window
|
||||
/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN
|
||||
/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the
|
||||
/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold
|
||||
/// (ReadOrder − ZLevel·10000) sank them to ≈ −10,000,000, BEHIND the frame's Alphablend
|
||||
/// backdrop (ZLevel 100 → ≈ −1,000,000), and the backdrop washed out the panels' captions,
|
||||
/// burden meter, and item cells. Skips when the live dat directory is absent (CI).
|
||||
/// </summary>
|
||||
public class InventoryFrameImportProbe
|
||||
{
|
||||
private const uint Frame = 0x21000023u;
|
||||
private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100)
|
||||
private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot
|
||||
private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot
|
||||
private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot
|
||||
private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it)
|
||||
private const uint ContentsGrid = 0x100001C6u; // 3D-items content
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paperdoll_equip_slots_resolve_to_item_lists()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
// A representative spread across the slot grid (head, shield, the weapon composite, cloak,
|
||||
// trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList).
|
||||
foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu })
|
||||
{
|
||||
var el = layout!.FindElement(id);
|
||||
Assert.True(el is UiItemList,
|
||||
$"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mounted_panels_sit_in_front_of_the_backdrop()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip (this is a smoke test)
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
var backdrop = layout!.FindElement(Backdrop);
|
||||
Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame
|
||||
|
||||
// The sub-window mount brings each panel's content into the by-id index.
|
||||
Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter
|
||||
Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid
|
||||
|
||||
// The fix: every mounted panel slot must draw IN FRONT of the backdrop
|
||||
// (higher ZOrder = painted later = on top), so the backdrop sits behind the content.
|
||||
foreach (var (id, name) in new[]
|
||||
{
|
||||
(BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"),
|
||||
})
|
||||
{
|
||||
var panel = layout.FindElement(id);
|
||||
Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree");
|
||||
Assert.True(panel!.ZOrder > backdrop!.ZOrder,
|
||||
$"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " +
|
||||
"(else the Alphablend backdrop overpaints/washes out the panel content)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Real-dat test for <see cref="ItemListCellTemplate.ResolveEmptySprite"/>: the inventory
|
||||
/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute
|
||||
/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar
|
||||
/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record.
|
||||
/// Skips when the live dat directory is absent (CI).
|
||||
/// </summary>
|
||||
public class ItemListCellTemplateTests
|
||||
{
|
||||
private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug)
|
||||
|
||||
private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu;
|
||||
private const uint MainPack = 0x100001C9u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o;
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip (smoke test)
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (var (name, layout, elem) in new[]
|
||||
{
|
||||
("contents", ContentsLayout, ContentsGrid),
|
||||
("side-bag", BackpackLayout, SideBag),
|
||||
("main-pack", BackpackLayout, MainPack),
|
||||
})
|
||||
{
|
||||
uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem);
|
||||
_out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}");
|
||||
Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)");
|
||||
Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix");
|
||||
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_lists_resolve_the_pinned_retail_sprites()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
// Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's
|
||||
// ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner
|
||||
// ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a
|
||||
// structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts).
|
||||
Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid));
|
||||
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag));
|
||||
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack));
|
||||
}
|
||||
}
|
||||
148
tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs
Normal file
148
tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class PaperdollControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x40000005u;
|
||||
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
||||
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
||||
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
||||
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
||||
|
||||
private sealed class RootElement : UiElement { }
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
||||
{
|
||||
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||
var lists = new Dictionary<uint, UiItemList>();
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = new RootElement { Width = 224, Height = 214 };
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var list = new UiItemList { Width = 32, Height = 32 };
|
||||
lists[id] = list; byId[id] = list; root.AddChild(list);
|
||||
}
|
||||
return (new ImportedLayout(root, byId), lists);
|
||||
}
|
||||
|
||||
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u)
|
||||
=> PaperdollController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
|
||||
emptySlotSprite: emptySlot);
|
||||
|
||||
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player });
|
||||
t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc);
|
||||
}
|
||||
|
||||
private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations });
|
||||
t.MoveItem(guid, Pack, newSlot: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_shows_equipped_item_in_its_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot
|
||||
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_matches_a_weapon_into_the_composite_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_accepts_only_valid_locations()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
|
||||
var ctrl = Bind(layout, objects);
|
||||
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
|
||||
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_wields_optimistically_and_sends_wire()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xD01u, EquipMask.HeadWear);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
|
||||
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
|
||||
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
|
||||
Assert.Single(wields);
|
||||
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload);
|
||||
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable(), emptySlot: 0x06004D20u);
|
||||
Assert.Equal(0x06004D20u, lists[HeadSlot].Cell.EmptySprite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects); // slots empty at bind
|
||||
SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll)
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
|
||||
objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player)
|
||||
Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
const uint npc = 0x60000001u;
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear,
|
||||
});
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll
|
||||
}
|
||||
}
|
||||
|
|
@ -89,8 +89,8 @@ public class ToolbarControllerTests
|
|||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
|
||||
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
|
||||
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0x5001u, used);
|
||||
}
|
||||
|
|
@ -421,4 +421,177 @@ public class ToolbarControllerTests
|
|||
|
||||
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
|
||||
}
|
||||
|
||||
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Bind registers the controller as each slot list's drag handler and
|
||||
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
|
||||
[Fact]
|
||||
public void Bind_registersDragHandler_andStampsSlots()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
for (int i = 0; i < Row1.Length; i++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
|
||||
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
|
||||
}
|
||||
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
|
||||
for (int j = 0; j < Row2.Length; j++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
|
||||
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
|
||||
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_acceptsRealItem()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
|
||||
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_rejectsZeroObjId()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
|
||||
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
|
||||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
|
||||
{
|
||||
var adds = new System.Collections.Generic.List<(uint,uint)>();
|
||||
var removes = new System.Collections.Generic.List<uint>();
|
||||
add = (i, g) => adds.Add((i, g));
|
||||
rem = i => removes.Add(i);
|
||||
return (adds, removes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
|
||||
Assert.Contains(3u, removes);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
|
||||
new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||||
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
|
||||
Assert.Contains((5u, 0x5001u), adds);
|
||||
Assert.Contains((3u, 0x5002u), adds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Contains((7u, 0x5001u), adds);
|
||||
Assert.DoesNotContain(adds, a => a.i == 3u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoSelf_reAddsToSource()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
|
||||
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
|
||||
Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent
|
||||
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
|
||||
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarSlots_useGreenCrossAcceptSprite()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
Normal file
22
tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class LayoutImporterMountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
|
||||
=> Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnChildren()
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
|
||||
}
|
||||
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiCollapsibleFrameTests
|
||||
{
|
||||
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
|
||||
{
|
||||
CollapsedHeight = 96f,
|
||||
ExpandedHeight = 128f,
|
||||
};
|
||||
row2a = new UiPanel(); row2b = new UiPanel();
|
||||
f.SecondRow = new UiElement[] { row2a, row2b };
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(96f, f.Height);
|
||||
Assert.False(a.Visible);
|
||||
Assert.False(b.Visible);
|
||||
Assert.False(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 120f; // nearer the expanded stop
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(128f, f.Height);
|
||||
Assert.True(a.Visible);
|
||||
Assert.True(b.Visible);
|
||||
Assert.True(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_notConfigured_isNoOp()
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
|
||||
f.Height = 50f;
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
|
||||
// bottom edge (y=200) → Bottom only
|
||||
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
|
||||
// top edge (y=100) → masked out → None
|
||||
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
|
||||
}
|
||||
}
|
||||
43
tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
Normal file
43
tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiItemListGridTests
|
||||
{
|
||||
[Fact]
|
||||
public void CellOffset_RowMajor()
|
||||
{
|
||||
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36));
|
||||
Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36));
|
||||
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridMode_PositionsCellsInColumns()
|
||||
{
|
||||
var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
|
||||
list.Flush(); // drop the ctor's default cell
|
||||
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
var c4 = list.GetItem(4)!;
|
||||
Assert.Equal(36f, c4.Left);
|
||||
Assert.Equal(36f, c4.Top);
|
||||
Assert.Equal(36f, c4.Width);
|
||||
Assert.Equal(36f, c4.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillMode_SizesSingleCellToList()
|
||||
{
|
||||
// CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
|
||||
var list = new UiItemList { Width = 36, Height = 36 };
|
||||
list.Flush();
|
||||
list.AddItem(new UiItemSlot());
|
||||
|
||||
var c = list.Cell;
|
||||
Assert.Equal(0f, c.Left);
|
||||
Assert.Equal(0f, c.Top);
|
||||
Assert.Equal(36f, c.Width);
|
||||
Assert.Equal(36f, c.Height);
|
||||
}
|
||||
}
|
||||
83
tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
Normal file
83
tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiItemListScrollTests
|
||||
{
|
||||
private static UiItemList Grid(int items)
|
||||
{
|
||||
var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 };
|
||||
list.Flush(); // drop the default single cell
|
||||
for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot());
|
||||
list.LayoutCells(); // drive the scroll model + position cells
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowCount_ceils_to_whole_rows()
|
||||
{
|
||||
Assert.Equal(0, UiItemList.RowCount(0, 6));
|
||||
Assert.Equal(1, UiItemList.RowCount(1, 6));
|
||||
Assert.Equal(7, UiItemList.RowCount(41, 6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_drives_scroll_model_from_content()
|
||||
{
|
||||
var list = Grid(30); // 5 rows × 32 = 160 content, 96 view
|
||||
Assert.Equal(160, list.Scroll.ContentHeight);
|
||||
Assert.Equal(96, list.Scroll.ViewHeight);
|
||||
Assert.Equal(64, list.Scroll.MaxScroll);
|
||||
Assert.True(list.Scroll.HasOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_top_first_three_rows_visible_rest_clipped()
|
||||
{
|
||||
var list = Grid(30);
|
||||
Assert.True(list.GetItem(0)!.Visible); // row 0, top 0
|
||||
Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96)
|
||||
Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrolled_one_row_shifts_window_and_clips_top_row()
|
||||
{
|
||||
var list = Grid(30);
|
||||
list.Scroll.SetScrollY(32);
|
||||
list.LayoutCells();
|
||||
Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above
|
||||
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top
|
||||
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scroll_survives_the_per_frame_anchor_pass()
|
||||
{
|
||||
// Regression (the escaping-grid bug): UiElement.DrawSelfAndChildren runs ApplyAnchor
|
||||
// on every child AFTER OnDraw/LayoutCells. Grid cells must be exempt (Anchors=None) so
|
||||
// the anchor pass can't reset the scroll offset LayoutCells applied — otherwise the
|
||||
// grid translates out of the view when scrolled.
|
||||
var list = Grid(30); // cells added (Anchors=None) + laid out
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++) // first anchor pass (would capture base)
|
||||
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
|
||||
list.Scroll.SetScrollY(32); // scroll one row
|
||||
list.LayoutCells();
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++) // second anchor pass (the draw traversal)
|
||||
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
|
||||
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 stays at the view top
|
||||
Assert.False(list.GetItem(0)!.Visible); // row 0 stays clipped above
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wheel_down_scrolls_toward_the_bottom()
|
||||
{
|
||||
var list = Grid(30); // max scroll 64, LineHeight 32
|
||||
// Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY.
|
||||
var e = new UiEvent(0u, null, UiEventType.Scroll, Data0: -1);
|
||||
bool handled = list.OnEvent(e);
|
||||
Assert.True(handled);
|
||||
Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down
|
||||
}
|
||||
}
|
||||
|
|
@ -22,4 +22,25 @@ public class UiItemListTests
|
|||
var list = new UiItemList();
|
||||
Assert.Same(list.GetItem(0), list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellEmptySprite_stamps_existing_and_future_cells()
|
||||
{
|
||||
var list = new UiItemList(); // ctor adds one default cell
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set
|
||||
|
||||
list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite
|
||||
Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped
|
||||
|
||||
list.AddItem(new UiItemSlot());
|
||||
Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellEmptySprite_zero_leaves_the_UiItemSlot_default()
|
||||
{
|
||||
var list = new UiItemList();
|
||||
list.CellEmptySprite = 0u;
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,4 +141,30 @@ public class UiItemSlotTests
|
|||
s.SetShortcutNum(0, peace: true);
|
||||
Assert.Null(s.ActiveDigitArray());
|
||||
}
|
||||
|
||||
// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 /
|
||||
// SetSelectedState 0x004e1240) ───────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected);
|
||||
|
||||
[Fact]
|
||||
public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer);
|
||||
|
||||
[Fact]
|
||||
public void SelectedSprite_default_isGreenYellowSquare()
|
||||
=> Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite);
|
||||
|
||||
[Fact]
|
||||
public void OpenContainerSprite_default_isTriangle()
|
||||
=> Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite);
|
||||
|
||||
// ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ──
|
||||
[Fact]
|
||||
public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill);
|
||||
|
||||
[Fact]
|
||||
public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite);
|
||||
|
||||
[Fact]
|
||||
public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
|
||||
}
|
||||
|
|
|
|||
35
tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
Normal file
35
tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiMeterVerticalTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vertical_fill_from_bottom_occupies_lower_fraction()
|
||||
{
|
||||
// 11x58 bar, 50% → fill rect is the bottom 29px.
|
||||
var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(0f, x);
|
||||
Assert.Equal(29f, y, 3); // h - h*p = 58 - 29
|
||||
Assert.Equal(11f, w);
|
||||
Assert.Equal(29f, h, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vertical_fill_from_top_starts_at_zero()
|
||||
{
|
||||
var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
|
||||
Assert.Equal(0f, y);
|
||||
Assert.Equal(14.5f, h, 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)]
|
||||
[InlineData(2f, 58f)]
|
||||
public void Vertical_fill_clamps_fraction(float pct, float expectedH)
|
||||
{
|
||||
var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(expectedH, h, 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ public class UiRootInputTests
|
|||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||
{
|
||||
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(10f, x); Assert.Equal(20f, y);
|
||||
Assert.Equal(130f, w); Assert.Equal(65f, h);
|
||||
}
|
||||
|
|
@ -168,11 +168,32 @@ public class UiRootInputTests
|
|||
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
|
||||
// origin shifts so the RIGHT edge (110) stays put → x = 70.
|
||||
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(40f, w);
|
||||
Assert.Equal(70f, x);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_Bottom_ClampsToMaxH()
|
||||
{
|
||||
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
|
||||
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
||||
Assert.Equal(128f, h);
|
||||
Assert.Equal(20f, y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_Top_ClampsToMaxH()
|
||||
{
|
||||
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
|
||||
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
|
||||
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
||||
Assert.Equal(128f, h);
|
||||
Assert.Equal(20f + 50f - 128f, y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_DetectsCornerAndInteriorNone()
|
||||
{
|
||||
|
|
@ -236,4 +257,77 @@ public class UiRootInputTests
|
|||
Assert.Equal(8f, x); Assert.Equal(24f, y);
|
||||
Assert.Equal(200f, w); Assert.Equal(14f, h);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
|
||||
Assert.True(win.Visible);
|
||||
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
|
||||
Assert.False(win.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ShowWindow("inventory"));
|
||||
Assert.True(win.Visible);
|
||||
Assert.True(root.HideWindow("inventory"));
|
||||
Assert.False(win.Visible);
|
||||
|
||||
Assert.False(root.ShowWindow("nope"));
|
||||
Assert.False(root.HideWindow("nope"));
|
||||
Assert.False(root.ToggleWindow("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowWindow_RaisesAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100 };
|
||||
var b = new UiPanel { Width = 100, Height = 100 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(a); root.AddChild(b); root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
root.ShowWindow("inventory");
|
||||
Assert.True(win.ZOrder > a.ZOrder);
|
||||
Assert.True(win.ZOrder > b.ZOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BringToFront_SetsStrictlyGreatestZOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
|
||||
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
|
||||
root.AddChild(a); root.AddChild(b);
|
||||
|
||||
root.BringToFront(a);
|
||||
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDown_OnWindow_RaisesItAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
|
||||
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
|
||||
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
|
||||
root.AddChild(peer); root.AddChild(clicked);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
|
||||
Assert.True(clicked.ZOrder > peer.ZOrder);
|
||||
root.OnMouseUp(UiMouseButton.Left, 50, 50);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue