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:
Erik 2026-06-25 12:57:46 +02:00
commit 01594b4cfd
95 changed files with 17201 additions and 167 deletions

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

View 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)
}
}

View 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 "";
}
}

View file

@ -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)");
}
}
}

View file

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

View 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
}
}

View file

@ -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
}
}

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

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

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

View 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
}
}

View file

@ -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
}
}

View file

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

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

View file

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