acdream/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
Erik a1175e6aac feat(ui): port retail component book rows
Instantiate gmSpellComponentUI category and component entries from LayoutDesc 0x21000033 instead of synthesizing catalog rows. Preserve localized category titles, authored textures and geometry, live icon/count/desired bindings, selected-object synchronization, and retail's inclusive 0..5000 desired-level contract.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-15 17:38:24 +02:00

418 lines
18 KiB
C#

using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using System.Numerics;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellbookWindowControllerTests
{
private static readonly SpellbookRowStyle RowStyle = new(
Width: 280f,
Height: 32f,
IconLeft: 0f,
IconTop: 0f,
IconWidth: 32f,
IconHeight: 32f,
LabelLeft: 42f,
LabelWidth: 230f,
FontDid: 0x40000001u,
LabelColor: Vector4.One,
BackgroundSprite: 0x06001396u,
SelectedSprite: 0x06001397u);
[Fact]
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadSpellbookInfos(),
_ => (0u, 0, 0),
datFont: null,
stringResolve: value => value.StringId switch
{
49867858u => "Spellbook",
79360818u => "Components",
_ => null,
});
var closed = new List<uint>();
using SpellbookWindowController? controller = Bind(
layout, new Spellbook(), close: () => closed.Add(1u));
Assert.NotNull(controller);
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
UiText componentTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.ComponentTabId));
UiElement spellPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.SpellPageId));
UiElement componentPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.ComponentPageId));
Assert.Equal("Spellbook", Assert.Single(spellTab.LinesProvider()).Text);
Assert.Equal("Components", Assert.Single(componentTab.LinesProvider()).Text);
Assert.False(spellTab.OneLine);
Assert.True(spellTab.Centered);
Assert.Equal(VJustify.Center, spellTab.VerticalJustify);
Assert.True(spellTab.DrawTextAfterChildren);
Assert.True(componentTab.DrawTextAfterChildren);
Assert.Equal(RetailUiStateIds.Open, spellTab.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, componentTab.ActiveRetailStateId);
Assert.True(spellPage.Visible);
Assert.False(componentPage.Visible);
Assert.Equal(3, spellTab.Children.Count);
Assert.All(spellTab.Children, child =>
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
componentTab.OnEvent(new UiEvent(0, componentTab, UiEventType.Click));
Assert.Equal(SpellbookWindowPage.Components, controller!.CurrentPage);
Assert.Equal(RetailUiStateIds.Closed, spellTab.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, componentTab.ActiveRetailStateId);
Assert.False(spellPage.Visible);
Assert.True(componentPage.Visible);
Assert.All(componentTab.Children, child =>
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
Assert.Empty(closed);
}
[Fact]
public void RetailFixture_BindsFilterIndicatorChildren_AndDeleteCaptionChild()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadSpellbookInfos(),
_ => (0u, 0, 0),
datFont: null,
stringResolve: value => value.StringId == 164868556u ? "Delete" : "Filter");
UiButton creature = Assert.IsType<UiButton>(layout.FindElement(0x10000298u));
Assert.Equal((0f, 1f, 13f, 13f),
(creature.FaceLeft, creature.FaceTop, creature.FaceWidth, creature.FaceHeight));
Assert.Equal(UiButton.LabelAlignment.Left, creature.LabelAlign);
Assert.Equal(17f, creature.LabelOffsetX);
Assert.Equal("Filter", creature.Label);
Assert.True(creature.ToggleBehavior);
creature.Selected = true;
Assert.Equal("Highlight", creature.ActiveState);
Assert.Equal(UiButtonStateMachine.Highlight, creature.ActiveRetailStateId);
UiButton levelSix = Assert.IsType<UiButton>(layout.FindElement(0x100002A1u));
Assert.Equal(UiButton.LabelAlignment.Left, levelSix.LabelAlign);
Assert.Equal(17f, levelSix.LabelOffsetX);
UiButton delete = Assert.IsType<UiButton>(
layout.FindElement(SpellbookWindowController.DeleteButtonId));
Assert.Equal("Delete", delete.Label);
Assert.Equal(UiButton.LabelAlignment.Center, delete.LabelAlign);
Assert.Equal((187f, 61f, 100f, 32f),
(delete.Left, delete.Top, delete.FaceWidth, delete.FaceHeight));
}
[Fact]
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u);
book.OnSpellLearned(102u);
book.OnSpellLearned(103u);
var added = new List<uint>();
using SpellbookWindowController controller = Bind(
layout, book, addFavorite: added.Add)!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(SpellbookWindowController.SpellScrollbarId));
Assert.Equal(1, list.Columns);
Assert.Equal(280f, list.CellWidth);
Assert.Equal(32f, list.CellHeight);
Assert.Same(list.Scroll, scrollbar.Model);
Assert.Equal(3, list.GetNumUIItems());
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
UiCatalogSlot third = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
Assert.Equal([102u, 101u, 103u], [first.EntryId, second.EntryId, third.EntryId]);
Assert.Equal("Creature One", first.Label);
Assert.True(first.ShowLabel);
Assert.Equal(RowStyle.BackgroundSprite, first.BackgroundSprite);
Assert.Equal(RowStyle.SelectedSprite, first.SelectedSprite);
Assert.True(first.SelectionBehindContent);
Assert.Equal(RowStyle.LabelLeft, first.LabelLeft);
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
Assert.True(second.Selected);
Assert.False(first.Selected);
first.OnEvent(new UiEvent(
0, first, UiEventType.DragBegin,
Payload: new SpellbookShortcutDragPayload(first.EntryId)));
Assert.True(first.Selected);
Assert.IsType<SpellbookShortcutDragPayload>(first.GetDragPayload());
first.OnEvent(new UiEvent(0, first, UiEventType.DoubleClick));
Assert.Equal([102u], added);
}
[Fact]
public void SchoolAndLevelFilters_RebuildFromAuthoritativeBitfield()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u); // War I
book.OnSpellLearned(102u); // Creature I
book.OnSpellLearned(103u); // War VIII
var sent = new List<uint>();
using SpellbookWindowController controller = Bind(
layout, book, sendFilter: sent.Add)!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiButton war = Assert.IsType<UiButton>(layout.FindElement(0x1000029Bu));
UiButton levelOne = Assert.IsType<UiButton>(layout.FindElement(0x1000029Cu));
Assert.True(war.Selected);
Assert.Equal(3, list.GetNumUIItems());
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
controller.Tick();
Assert.Equal([0x3FF7u], sent);
Assert.Single(VisibleSpellIds(list));
Assert.Equal(102u, VisibleSpellIds(list)[0]);
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
levelOne.OnEvent(new UiEvent(0, levelOne, UiEventType.Click));
controller.Tick();
Assert.Equal([103u], VisibleSpellIds(list));
Assert.False(levelOne.Selected);
}
[Fact]
public void Delete_UsesSharedConfirmation_AndWaitsForServerRemovalNotice()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u);
var removed = new List<uint>();
string? prompt = null;
Action<bool>? complete = null;
using SpellbookWindowController controller = Bind(
layout,
book,
removeSpell: removed.Add,
showConfirmation: (message, callback) =>
{
prompt = message;
complete = callback;
})!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiButton delete = Assert.IsType<UiButton>(
layout.FindElement(SpellbookWindowController.DeleteButtonId));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
Assert.Null(prompt);
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
Assert.Equal(
"Are you sure you want to remove War One from your spellbook? "
+ "You will no longer be able to cast this spell unless you learn it again!",
prompt);
Assert.Empty(removed);
complete!(false);
Assert.Empty(removed);
Assert.True(book.Knows(101u));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
complete!(true);
Assert.Equal([101u], removed);
Assert.True(book.Knows(101u));
Assert.Single(VisibleSpellIds(list));
book.OnSpellForgotten(101u);
controller.Tick();
Assert.False(book.Knows(101u));
Assert.Empty(VisibleSpellIds(list));
}
[Fact]
public void Components_UseAuthoredCategoryAndItemTemplates_WithRetailCountsAndSelection()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 10u,
WeenieClassId = 100u,
Name = "Copper Scarab",
Type = ItemType.SpellComponents,
IconId = 0x06000100u,
StackSize = 2,
ContainerId = 1u,
IsComponentPack = true,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 11u,
WeenieClassId = 101u,
Name = "Diamond Scarab",
Type = ItemType.SpellComponents,
IconId = 0x06000101u,
StackSize = 1,
ContainerId = 1u,
IsComponentPack = true,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 12u,
WeenieClassId = 200u,
Name = "Amaranth",
Type = ItemType.SpellComponents,
IconId = 0x06000200u,
StackSize = 3,
ContainerId = 1u,
IsComponentPack = true,
});
var components = new Dictionary<uint, SpellComponentDescriptor>
{
[100u] = new(100u, "Copper Scarab", 0u, 0x06000100u),
[101u] = new(101u, "Diamond Scarab", 0u, 0x06000101u),
[200u] = new(200u, "Amaranth", 1u, 0x06000200u),
};
Spellbook book = CreateSpellbook();
book.SetDesiredComponent(100u, 7u);
var selected = new List<uint>();
var desired = new List<(uint Component, uint Amount)>();
var selection = new SelectionState();
using SpellbookWindowController controller = Bind(
layout,
book,
objects: objects,
components: components,
selection: selection,
selectObject: objectId =>
{
selected.Add(objectId);
selection.Select(objectId, SelectionChangeSource.Inventory);
},
setDesiredComponent: (component, amount) => desired.Add((component, amount)),
resolveComponentIcon: icon => icon + 0x1000u)!;
controller.ShowPage(SpellbookWindowPage.Components);
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.ComponentListId)?.Children.Single());
Assert.Equal(5, list.GetNumUIItems());
Assert.Same(
list.Scroll,
Assert.IsType<UiScrollbar>(
layout.FindElement(SpellbookWindowController.ComponentScrollbarId)).Model);
UiTemplateListSlot scarabs = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
UiText scarabTitle = Assert.IsType<UiText>(scarabs.Content.Root);
Assert.Equal("SCARABS", Assert.Single(scarabTitle.LinesProvider()).Text);
Assert.Equal(0x06001392u, scarabTitle.BackgroundSprite);
UiTemplateListSlot copper = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
UiDatElement copperRoot = Assert.IsType<UiDatElement>(copper.Content.Root);
Assert.Equal((0x06005E21u, 1), copperRoot.ActiveMedia());
Assert.Equal("Copper Scarab", Assert.Single(
Assert.IsType<UiText>(copper.Content.FindElement(
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
Assert.Equal("2", Assert.Single(
Assert.IsType<UiText>(copper.Content.FindElement(
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
ComponentBookTemplateFactory.DesiredCountId));
Assert.Equal((224f, 15f, 60f, 15f),
(desiredField.Left, desiredField.Top, desiredField.Width, desiredField.Height));
Assert.True(desiredField.RightAligned);
Assert.Equal("7", desiredField.Text);
UiElement iconHost = Assert.IsAssignableFrom<UiElement>(copper.Content.FindElement(
ComponentBookTemplateFactory.IconId));
Assert.Equal(
0x06001100u,
Assert.IsType<UiTextureElement>(Assert.Single(iconHost.Children)).Texture);
copper.OnEvent(new UiEvent(0u, copper, UiEventType.Click));
Assert.Equal([10u], selected);
Assert.True(copper.Selected);
Assert.Equal(UiButtonStateMachine.Highlight, copperRoot.ActiveRetailStateId);
Assert.Equal((0x06005E22u, 1), copperRoot.ActiveMedia());
desiredField.SetText("21");
desiredField.OnSubmit!(desiredField.Text);
Assert.Equal([(100u, 21u)], desired);
desiredField.SetText("5001");
desiredField.OnSubmit!(desiredField.Text);
Assert.Equal("21", desiredField.Text);
Assert.Equal([(100u, 21u)], desired);
UiTemplateListSlot herbs = Assert.IsType<UiTemplateListSlot>(list.GetItem(3));
Assert.Equal("HERBS", Assert.Single(
Assert.IsType<UiText>(herbs.Content.Root).LinesProvider()).Text);
UiTemplateListSlot amaranth = Assert.IsType<UiTemplateListSlot>(list.GetItem(4));
selection.Select(12u, SelectionChangeSource.Radar);
Assert.True(amaranth.Selected);
Assert.False(copper.Selected);
selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
Assert.False(amaranth.Selected);
}
private static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint>? addFavorite = null,
Action<uint>? sendFilter = null,
Action<uint>? removeSpell = null,
Action<string, Action<bool>>? showConfirmation = null,
Action? close = null,
ClientObjectTable? objects = null,
IReadOnlyDictionary<uint, SpellComponentDescriptor>? components = null,
SelectionState? selection = null,
Action<uint>? selectObject = null,
Action<uint, uint>? setDesiredComponent = null,
Func<uint, uint>? resolveComponentIcon = null)
=> SpellbookWindowController.Bind(
layout,
spellbook,
objects ?? new ClientObjectTable(),
() => 1u,
components ?? new Dictionary<uint, SpellComponentDescriptor>(),
selection ?? new SelectionState(),
spellId => spellId + 1000u,
resolveComponentIcon ?? (iconId => iconId),
spellId => spellId == 103u ? 8 : 1,
selectObject ?? (_ => { }),
addFavorite ?? (_ => { }),
sendFilter ?? (_ => { }),
removeSpell ?? (_ => { }),
showConfirmation ?? ((_, _) => { }),
setDesiredComponent ?? ((_, _) => { }),
close ?? (() => { }),
new ComponentBookTemplateFactory(
FixtureLoader.LoadComponentCategoryTemplateInfos(),
FixtureLoader.LoadComponentRowTemplateInfos(),
_ => (0u, 0, 0),
defaultFont: null),
RowStyle,
rowFont: null);
private static Spellbook CreateSpellbook()
{
const string csv = "Spell ID,Name,School,IconId [Hex],SortKey\n"
+ "101,War One,War Magic,0x06000001,2\n"
+ "102,Creature One,Creature Enchantment,0x06000002,1\n"
+ "103,War Eight,War Magic,0x06000003,3\n";
return new Spellbook(SpellTable.LoadFromReader(new StringReader(csv)));
}
private static uint[] VisibleSpellIds(UiItemList list)
=> Enumerable.Range(0, list.GetNumUIItems())
.Select(index => Assert.IsType<UiCatalogSlot>(list.GetItem(index)).EntryId)
.ToArray();
}