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 { [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? uses = null, List? 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(); 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(); var closes = new List(); 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(); var closes = new List(); 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()); 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(); var closes = new List(); 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 ""; } }