feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The vendor window is retail's own: LayoutDesc 0x21000012, root 0x100000B7, found by enumerating all 101 layouts for the one containing both known tab controls and clinched by the root's Type 0x10000017 — the literal UIElement::RegisterElementClass id for gmVendorUI (pc:202075). Discovery evidence and the D0 read live in the research doc's new §B.4. D0 corrected two assumptions: retail's category "tabs" are a UiMenu DROPDOWN fed by a hardcoded 18-row ordered category table (ported bit-for-bit against our ItemType enum; list always scoped to exactly one category, first-present wins, selection preserved across refresh per retail's clamp), and the layout authors THREE tabs — Items (browse, this slice), Buying and Selling (staged-transaction review, Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's mode-2 OpenTab opens. The non-default tabs render and switch pages but stay inert, fenced in comments. VendorUiController mounts Items: category dropdown, icon-cell item row with the retained scrollbar, per-unit retail pricing via VendorPricing.SellPrice (the vendor-stock path VendorProfile:: VendorSellPrice feeds), name/cost on selection. The panel is a pure projection of VendorState — opens on populate, closes on clear; the close button's VendorState.Close() is its only permitted mutation. Nothing on the wire. AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add buttons, InqAcceptability). Twelve controller tests on a real-dat fixture. Clean-room complete solution: 11,323 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
609a2dfda0
commit
c721830e71
10 changed files with 17024 additions and 2 deletions
|
|
@ -183,6 +183,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadMiniGameInfos()
|
||||
=> LoadInfos("mini_game_2100001E.json");
|
||||
|
||||
public static ImportedLayout LoadVendor()
|
||||
=> LayoutImporter.Build(LoadVendorInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadVendorInfos()
|
||||
=> LoadInfos("vendor_21000012_100000B7.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"examine_row_2100006B_10000166.json"),
|
||||
(AppraisalUiController.LayoutId, SpellExamineComponentTemplateFactory.TemplateId,
|
||||
"examine_component_2100006B_1000032E.json"),
|
||||
(VendorUiController.LayoutId, VendorUiController.RootId,
|
||||
"vendor_21000012_100000B7.json"),
|
||||
})
|
||||
{
|
||||
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
||||
|
|
|
|||
327
tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Normal file
327
tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Slice 5.4 — mirrors <see cref="ExternalContainerControllerTests"/>'s
|
||||
/// harness shape (hand-built <see cref="ImportedLayout"/> over
|
||||
/// <see cref="RetailWindowFrame.Mount"/>) for the behavioral suite, plus one
|
||||
/// real-dat-fixture smoke test (mirroring
|
||||
/// <c>AppraisalUiControllerTests</c>'s use of <see cref="FixtureLoader"/>)
|
||||
/// that catches drift between the hardcoded ids in
|
||||
/// <see cref="VendorUiController"/> and the actual LayoutDesc 0x21000012.
|
||||
/// </summary>
|
||||
public sealed class VendorUiControllerTests
|
||||
{
|
||||
private const uint VendorGuid = 0x70000010u;
|
||||
private const uint ArmorItemGuid = 0x60000101u;
|
||||
private const uint FoodItemGuid = 0x60000102u;
|
||||
private const uint StackedItemGuid = 0x60000103u;
|
||||
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
[Fact]
|
||||
public void Bind_FromRealDatFixture_ResolvesAllRequiredControls()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadVendor();
|
||||
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
||||
RetailWindowHandle window = RetailWindowFrame.Mount(
|
||||
screen,
|
||||
layout.Root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = "vendor-fixture-smoke",
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
});
|
||||
|
||||
VendorUiController? controller = VendorUiController.Bind(
|
||||
layout,
|
||||
new VendorState(),
|
||||
window,
|
||||
static (_, _, _, _, _) => 0u);
|
||||
|
||||
Assert.NotNull(controller);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly VendorState State = new();
|
||||
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
|
||||
public readonly UiItemList ItemList = new();
|
||||
public readonly UiScrollbar ItemScrollbar = new();
|
||||
public readonly UiMenu TypeMenu = new();
|
||||
public readonly UiText ItemNameText = new();
|
||||
public readonly UiText ItemCostText = new();
|
||||
public readonly UiText ItemsTab = new();
|
||||
public readonly UiText BuyingTab = new();
|
||||
public readonly UiText SellingTab = new();
|
||||
public readonly UiElement ItemsPage = new TestElement();
|
||||
public readonly UiElement BuyingPage = new TestElement();
|
||||
public readonly UiElement SellingPage = new TestElement();
|
||||
public readonly UiButton CloseButton;
|
||||
public readonly RetailWindowHandle Window;
|
||||
public readonly VendorUiController Controller;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var root = new TestElement { Width = 800f, Height = 110f };
|
||||
CloseButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.CloseId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
|
||||
root.AddChild(CloseButton);
|
||||
root.AddChild(ItemsTab);
|
||||
root.AddChild(BuyingTab);
|
||||
root.AddChild(SellingTab);
|
||||
root.AddChild(ItemsPage);
|
||||
root.AddChild(BuyingPage);
|
||||
root.AddChild(SellingPage);
|
||||
ItemsPage.AddChild(ItemList);
|
||||
ItemsPage.AddChild(ItemScrollbar);
|
||||
ItemsPage.AddChild(TypeMenu);
|
||||
ItemsPage.AddChild(ItemNameText);
|
||||
ItemsPage.AddChild(ItemCostText);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[VendorUiController.CloseId] = CloseButton,
|
||||
[VendorUiController.ItemsTabId] = ItemsTab,
|
||||
[VendorUiController.BuyingTabId] = BuyingTab,
|
||||
[VendorUiController.SellingTabId] = SellingTab,
|
||||
[VendorUiController.ItemsPageId] = ItemsPage,
|
||||
[VendorUiController.BuyingPageId] = BuyingPage,
|
||||
[VendorUiController.SellingPageId] = SellingPage,
|
||||
[VendorUiController.ItemListId] = ItemList,
|
||||
[VendorUiController.ItemScrollbarId] = ItemScrollbar,
|
||||
[VendorUiController.TypeFilterMenuId] = TypeMenu,
|
||||
[VendorUiController.ItemNameTextId] = ItemNameText,
|
||||
[VendorUiController.ItemCostTextId] = ItemCostText,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
Screen,
|
||||
root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Vendor,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
});
|
||||
|
||||
Controller = VendorUiController.Bind(
|
||||
layout,
|
||||
State,
|
||||
Window,
|
||||
static (_, iconId, _, _, _) => iconId != 0u ? iconId + 1000u : 0u)!;
|
||||
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
|
||||
}
|
||||
}
|
||||
|
||||
private static VendorShopProfile Profile(
|
||||
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "") =>
|
||||
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, 0u, altName);
|
||||
|
||||
private static string GetText(UiText text)
|
||||
=> string.Concat(text.LinesProvider().Select(line => line.Text));
|
||||
|
||||
[Fact]
|
||||
public void Bind_WiresTheScrollbarToTheItemListsScrollModel()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
Assert.True(h.ItemScrollbar.Horizontal);
|
||||
Assert.Same(h.ItemList.Scroll, h.ItemScrollbar.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opened_ShowsWindowAndPopulatesFirstPresentCategoryInTableOrder()
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = new[]
|
||||
{
|
||||
// Food is table entry #5, Armor is #1 — retail's fixed table
|
||||
// order, not insertion order, decides the default selection.
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
};
|
||||
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.True(h.ItemsPage.Visible);
|
||||
Assert.False(h.BuyingPage.Visible);
|
||||
Assert.False(h.SellingPage.Visible);
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_HidesWindowAndClearsListAndText()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
|
||||
|
||||
h.State.Close();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(string.Empty, GetText(h.ItemNameText));
|
||||
Assert.Equal(string.Empty, GetText(h.ItemCostText));
|
||||
Assert.Empty(h.TypeMenu.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_HidesWindowAndClearsContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.State.Reset();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_ShowsRetailCorrectPerUnitPrice_ForStackedItem()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
||||
{
|
||||
// Value=1000 is the STACK-TOTAL wire value for a 100-unit stack;
|
||||
// per-unit = 1000/100 = 10. SellPrice = ceil(2.0*10*1 - 0.1) = 20
|
||||
// (VendorPricing.SellPrice, ShopSystem::SellPrice pc:702107).
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||
DescStackSize: 100),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal("20", GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_WithAlternateCurrency_AppendsCurrencyName()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(
|
||||
VendorGuid,
|
||||
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes"),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("50 Trade Notes", GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingDifferentCategory_FiltersItemListToThatCategoryOnly()
|
||||
{
|
||||
var h = new Harness();
|
||||
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
|
||||
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
|
||||
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
|
||||
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // default: Armor (table order)
|
||||
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
// The panel never mutates VendorState directly — the ONLY path from
|
||||
// the close button to a cleared session is VendorState.Close()
|
||||
// itself, so VendorId reads back 0 through the owner's own public
|
||||
// surface, not a private field poke.
|
||||
Assert.Equal(0u, h.State.VendorId);
|
||||
Assert.False(h.Window.IsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellingTab_SwitchesPageButPopulatesNoSellContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.SellingTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.SellingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.False(h.BuyingPage.Visible);
|
||||
// Slice 6 fence: no sell-list/price/button wiring exists at all —
|
||||
// the page is exactly the authored-empty container it started as.
|
||||
Assert.Empty(h.SellingPage.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyingTab_SwitchesPageButPopulatesNoBuyContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.BuyingTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.BuyingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.Empty(h.BuyingPage.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemsTab_ReselectedAfterVisitingOtherTabs_ShowsBrowseListAgain()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.SellingTab.OnClick!.Invoke();
|
||||
|
||||
h.ItemsTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.ItemsPage.Visible);
|
||||
Assert.False(h.SellingPage.Visible);
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
}
|
||||
}
|
||||
15938
tests/AcDream.App.Tests/UI/Layout/fixtures/vendor_21000012_100000B7.json
Normal file
15938
tests/AcDream.App.Tests/UI/Layout/fixtures/vendor_21000012_100000B7.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue