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) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests strength: () => strength, datFont: null); // 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(2, grid.GetNumUIItems()); // 2 loose Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); Assert.Equal(0xBu, grid.GetItem(1)!.ItemId); Assert.Equal(1, containers.GetNumUIItems()); // 1 side bag Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); } [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(1, grid.GetNumUIItems()); // only the loose item Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); Assert.Equal(0, containers.GetNumUIItems()); // equipped item is not a side bag either } [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(0, grid.GetNumUIItems()); 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(1, grid.GetNumUIItems()); 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 } // 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 ""; } }