acdream/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
Erik c918ccea06 fix(ui): retain compound spellbook button children
Render Type-1 state-propagating child media as the button face so spellbook school and level filters show their authored green Highlight indicator and keep labels at the retail left inset.

Lift captions, fonts, and colors from authored text children for framed buttons such as Delete, preventing centered filter labels from being covered by the overlapping delete frame.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 16:40:42 +02:00

264 lines
11 KiB
C#

using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
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 = FixtureLoader.LoadSpellbook();
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(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));
}
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)
=> SpellbookWindowController.Bind(
layout,
spellbook,
new ClientObjectTable(),
() => 1u,
new Dictionary<uint, SpellComponentDescriptor>(),
spellId => spellId + 1000u,
iconId => iconId,
spellId => spellId == 103u ? 8 : 1,
_ => { },
addFavorite ?? (_ => { }),
sendFilter ?? (_ => { }),
removeSpell ?? (_ => { }),
showConfirmation ?? ((_, _) => { }),
(_, _) => { },
close ?? (() => { }),
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();
}