acdream/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
Erik ac2ca8f965 feat(ui): port retail spellbook interactions
Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar.

Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state.

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

203 lines
7.7 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;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellcastingUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var used = new List<uint>();
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, used.Add);
Assert.True(controller is not null, DescribeBinding(layout));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
IconId = 0x06001234u,
});
controller.Tick();
UiElement host = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(SpellcastingUiController.EndowmentId));
Assert.True(host.Visible);
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
Assert.Equal(2u, slot.EntryId);
Assert.Equal(2670u, slot.CatalogIconTexture);
Assert.Equal(2u, slot.CatalogOverlayTexture);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
cast.OnEvent(new UiEvent(0, cast, UiEventType.Click));
Assert.Equal([2u], used);
}
[Fact]
public void FavoriteDrop_IgnoresForeignInventoryPayload()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, _ => { });
Assert.True(controller is not null, DescribeBinding(layout));
controller.Tick();
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
var foreign = new ItemDragPayload(9u, ItemDragSource.Inventory, 0, new UiItemSlot(), null);
bool handled = slot.OnEvent(new UiEvent(
0, slot, UiEventType.DropReleased, Payload: foreign));
Assert.True(handled);
Assert.Equal([42u], spellbook.GetFavorites(0));
}
[Fact]
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var added = new List<(int Tab, int Position, uint Spell)>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { },
(tab, position, spell) => added.Add((tab, position, spell)))!;
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
bool handled = list.OnEvent(new UiEvent(
0,
list,
UiEventType.DropReleased,
Data1: 5,
Payload: new SpellbookShortcutDragPayload(42u)));
Assert.True(handled);
Assert.Equal([(0, 0, 42u)], added);
Assert.Equal([42u], spellbook.GetFavorites(0));
Assert.True(spellbook.Knows(42u));
}
[Fact]
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { })!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiItemSlot before = list.GetItem(0)!;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Rock" });
controller.Tick();
Assert.Same(before, list.GetItem(0));
}
[Fact]
public void ObjectTableClear_RemovesEquippedEndowmentAndCannotUseStaleGuid()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
});
var used = new List<uint>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, used.Add)!;
UiElement host = layout.FindElement(SpellcastingUiController.EndowmentId)!;
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
objects.Clear();
controller.Tick();
Assert.False(host.Visible);
Assert.Equal(0u, slot.EntryId);
slot.OnEvent(new UiEvent(0, slot, UiEventType.DoubleClick));
Assert.Empty(used);
}
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Action<uint> useItem,
Action<int, int, uint>? addFavorite = null)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
useItem,
new SelectionState(),
addFavorite ?? ((_, _, _) => { }),
(_, _) => { });
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
private static string DescribeBinding(ImportedLayout layout)
{
uint[] ids =
[
SpellcastingUiController.CastButtonId, SpellcastingUiController.EndowmentId,
0x100000A3u, 0x100000AAu, 0x100005C2u, 0x100005C3u,
];
return string.Join(", ", ids.Select(id =>
$"{id:X8}={layout.FindElement(id)?.GetType().Name ?? "missing"}"));
}
}