acdream/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Erik 1ccf07b705 fix(ui): D.2b-B — address phase-boundary review (burden % saturation + equipped filter)
Opus phase-boundary review findings:
- I1 (faithfulness): LoadToPercent now computes from the CLAMPED fill
  (floor(LoadToFill(load)*300)), so the burden % SATURATES at 300% like retail
  (decomp 176544-176576 clamps arg2 to [0,1] BEFORE the *300). The old
  floor(load*100) over-read to 400% at 4x capacity. Golden test corrected.
- I2 (partition): exclude equipped items (CurrentlyEquippedLocation != None) from
  the contents grid + selector — a mid-session self-wield routes them through
  MoveItem(item, WielderGuid=player) into GetContents(player); retail's gm3DItemsUI
  shows pack contents only. New conformance test.
- N1: drop dead 'using System.Collections.Generic' (left after the 383e8b7 cleanup).
- N3: AP-48 risk wording (drift can be low OR high vs server EncumbranceVal).

Build green; BurdenMath 17, InventoryController/UiMeter 10 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:28:29 +02:00

166 lines
7.3 KiB
C#

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 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 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);
var byId = new Dictionary<uint, UiElement>
{
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
[ContentsCaption] = contentsCap,
};
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));
}
// 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 "";
}
}