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 AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; public sealed class SpellcastingUiControllerTests { private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); [Fact] public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton() { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); RetailCombatLayout.FitFavoriteSlots(layout); 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" }); uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u]; uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u]; uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u]; var digits = new UiShortcutDigitGraphics(regular, ghosted, empty); using SpellcastingUiController controller = Bind( layout, spellbook, objects, _ => { }, shortcutDigits: digits, emptySlotSprite: 0x06001A97u)!; UiElement group = layout.FindElement(0x100000AAu)!; UiItemList list = Descendants(group).OfType().First(); UiScrollbar scrollbar = Descendants(group).OfType().Single( candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId); Assert.True(list.SingleRow); Assert.True(list.HorizontalScroll); Assert.True(list.FillVisibleEmptySlots); Assert.Same(list.Scroll, scrollbar.Model); Assert.True(scrollbar.Horizontal); Assert.True(scrollbar.HideWhenDisabled); Assert.Equal(23f, scrollbar.DecrementButtonExtent); Assert.Equal(23f, scrollbar.IncrementButtonExtent); Assert.Equal(0x06004CDEu, scrollbar.UpSprite); Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite); Assert.Equal(0x06004CDCu, scrollbar.DownSprite); Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite); Assert.False(scrollbar.IsPresentationVisible); Assert.Equal(576f, list.Width); Assert.Equal(18, list.GetNumUIItems()); UiCatalogSlot favorite = Assert.IsType(list.GetItem(0)); Assert.Equal(42u, favorite.EntryId); Assert.Equal(0, favorite.ShortcutNum); Assert.Same(regular, favorite.ActiveDigitArray()); for (int i = 1; i < 9; i++) { UiCatalogSlot slot = Assert.IsType(list.GetItem(i)); Assert.True(slot.IsEmptySlot); Assert.Equal(0x06001A97u, slot.EmptySprite); Assert.Equal(i, slot.ShortcutNum); Assert.Same(empty, slot.ActiveDigitArray()); } for (int i = 9; i < 18; i++) { Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite); Assert.Equal(-1, list.GetItem(i)!.ShortcutNum); } var cast = Assert.IsType( layout.FindElement(SpellcastingUiController.CastButtonId)); Assert.Equal((662f, 75f), (cast.Left, cast.Width)); Assert.Equal(3, cast.FaceSegmentCount); Assert.Equal( [ new UiPixelRect(0, 0, 31, 31), new UiPixelRect(32, 0, 42, 31), new UiPixelRect(43, 0, 74, 31), ], cast.FaceSegmentRectsForTest()); } [Fact] public void FavoriteOverflow_ArrowButtonsStepOneCell_AndSelectionScrollsIntoView() { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); RetailCombatLayout.FitFavoriteSlots(layout); var spellbook = new Spellbook(); for (uint spellId = 1u; spellId <= 20u; spellId++) { spellbook.OnSpellLearned(spellId, 1f); spellbook.SetFavorite(0, (int)spellId - 1, spellId); } 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().Single(); UiScrollbar scrollbar = Descendants(group).OfType().Single( candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId); Assert.True(scrollbar.IsPresentationVisible); Assert.Equal(64, list.Scroll.MaxScroll); Assert.True(scrollbar.OnEvent(new UiEvent( 0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1))); Assert.Equal(32, list.Scroll.ScrollY); Assert.True(scrollbar.OnEvent(new UiEvent( 0u, scrollbar, UiEventType.MouseDown, Data1: 0))); Assert.Equal(0, list.Scroll.ScrollY); controller.AddFavorite(20u); Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY); Assert.True(scrollbar.OnEvent(new UiEvent( 0u, scrollbar, UiEventType.MouseDown, Data1: 0))); int manualOffset = list.Scroll.ScrollY; objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" }); controller.Tick(); Assert.Equal(manualOffset, list.Scroll.ScrollY); } [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(); 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( layout.FindElement(SpellcastingUiController.EndowmentId)); Assert.True(host.Visible); UiCatalogSlot slot = Assert.IsType(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( layout.FindElement(SpellcastingUiController.CastButtonId)); cast.OnEvent(new UiEvent(0, cast, UiEventType.Click)); Assert.Equal([2u], used); } /// /// #354 (user gate finding): "I should be able to rearrange spells on the /// spell bar ... drag one out and drop it in another position." Every prior /// test in this file drives Dropped/OnEvent directly, bypassing /// 's real pointer pipeline -- which is exactly why the /// regression this test guards against was invisible: BeginFavoriteDrag's /// press-time removal fires SpellbookChanged, and a per-frame Tick() (as /// production's RetailUiRuntime.Tick drives unconditionally) used to call /// Rebuild(), which flushes and recreates every slot. UiRoot's subtree-removal /// safety net then canceled the in-flight drag because its source cell had /// just been destroyed out from under it. This drives the WHOLE gesture /// through // /// a mid-drag / /// and asserts both the retail-faithful final /// order (insert-shift, not swap) and the exact remove-then-add wire pair. /// [Fact] public void DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire() { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); RetailCombatLayout.FitFavoriteSlots(layout); var spellbook = new Spellbook(); spellbook.OnSpellLearned(1u, 1f); spellbook.OnSpellLearned(2u, 1f); spellbook.OnSpellLearned(3u, 1f); spellbook.SetFavorite(0, 0, 1u); spellbook.SetFavorite(0, 1, 2u); spellbook.SetFavorite(0, 2, 3u); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); var selection = new SelectionState(); var casting = new RuntimeSpellCastState(spellbook, selection, new NoopSpellCastOperations()); var adds = new List<(int Tab, int Position, uint SpellId)>(); var removes = new List<(int Tab, uint SpellId)>(); using SpellcastingUiController? controller = SpellcastingUiController.Bind( layout, spellbook, casting, objects, () => 1u, spellId => spellId, item => item.ObjectId, _ => { }, selection, (tab, position, spellId) => { adds.Add((tab, position, spellId)); spellbook.SetFavorite(tab, position, spellId); }, (tab, spellId) => { removes.Add((tab, spellId)); spellbook.RemoveFavorite(tab, spellId); }); Assert.True(controller is not null, DescribeBinding(layout)); var screen = new UiRoot { Width = 1280f, Height = 800f }; screen.AddChild(layout.Root); controller.Tick(); UiElement group = layout.FindElement(0x100000AAu)!; UiItemList list = Descendants(group).OfType().First(); UiCatalogSlot slot0 = Assert.IsType(list.GetItem(0)); UiCatalogSlot slot2 = Assert.IsType(list.GetItem(2)); Assert.Equal(1u, slot0.EntryId); Assert.Equal(3u, slot2.EntryId); System.Numerics.Vector2 p0 = slot0.ScreenPosition; System.Numerics.Vector2 p2 = slot2.ScreenPosition; int x0 = (int)(p0.X + slot0.Width / 2f); int y0 = (int)(p0.Y + slot0.Height / 2f); int x2 = (int)(p2.X + slot2.Width / 2f); int y2 = (int)(p2.Y + slot2.Height / 2f); // Press on spell 1's cell and drag past the 3px promotion threshold -- // BeginFavoriteDrag fires here and removes spell 1 from the model + wire. screen.OnMouseDown(UiMouseButton.Left, x0, y0); screen.OnMouseMove(x0 + 10, y0); Assert.Same(slot0, screen.DragSource); Assert.Equal([(0, 1u)], removes); // A live per-frame Tick, exactly like production's RetailUiRuntime.Tick, // must NOT cancel the drag just because the lift already changed the // Spellbook -- this is the regression this test exists to catch. controller.Tick(); Assert.Same(slot0, screen.DragSource); // Move onto spell 3's cell and release there. screen.OnMouseMove(x2, y2); screen.OnMouseUp(UiMouseButton.Left, x2, y2); Assert.Null(screen.DragSource); // Insert-shift, not swap: dropping spell 1 onto spell 3's (pre-lift index 2) // cell lands it directly before spell 3, matching PlayerModule::AddSpellFavorite // @ 0x005D43E0's InsertPos semantics. Assert.Equal(0, adds[0].Tab); Assert.Equal(1, adds[0].Position); Assert.Equal(1u, adds[0].SpellId); Assert.Equal(new uint[] { 2u, 1u, 3u }, spellbook.GetFavorites(0)); // The list resyncs to the final state on the next tick. controller.Tick(); Assert.Equal(2u, Assert.IsType(list.GetItem(0)).EntryId); Assert.Equal(1u, Assert.IsType(list.GetItem(1)).EntryId); Assert.Equal(3u, Assert.IsType(list.GetItem(2)).EntryId); } [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(layout.FindElement(0x100000AAu)); UiItemList list = Descendants(group).OfType().First(); UiCatalogSlot slot = Assert.IsType(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)); } /// /// #354 discriminator sabotage check, symmetric to /// : a catalog payload /// minted by the favorite bar must be rejected by every PHYSICAL item slot too /// (toolbar / inventory / vendor / paperdoll all route drops through /// , which only accepts ). /// If were ever widened to match, a spell /// icon dragged onto the toolbar would silently misbehave instead of no-op'ing. /// [Fact] public void SpellFavoritePayload_IsRejectedByAPhysicalItemListDragHandler() { var recordedDrops = new List(); var handler = new RecordingDragHandler(recordedDrops); var list = new UiItemList(); list.RegisterDragHandler(handler); var cell = list.Cell; var payload = new SpellFavoriteDragPayload(0, 0, 42u); bool handled = cell.OnEvent(new UiEvent( 0, cell, UiEventType.DropReleased, Payload: payload)); Assert.True(handled); Assert.Empty(recordedDrops); } private sealed class RecordingDragHandler(List drops) : IItemListDragHandler { public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) => drops.Add(payload.ObjId); public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) => ItemDragAcceptance.None; public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) => drops.Add(payload.ObjId); } [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(layout.FindElement(0x100000AAu)); UiItemList list = Descendants(group).OfType().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().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(); using SpellcastingUiController controller = Bind( layout, spellbook, objects, used.Add)!; UiElement host = layout.FindElement(SpellcastingUiController.EndowmentId)!; UiCatalogSlot slot = Assert.IsType(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); } [Fact] public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally() { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); var spellbook = new Spellbook(); spellbook.OnSpellLearned(42u, 1f); spellbook.OnSpellLearned(43u, 1f); spellbook.SetFavorite(0, 0, 42u); spellbook.SetFavorite(0, 1, 43u); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); var selection = new SelectionState(); var examined = new List(); using SpellcastingUiController controller = Bind( layout, spellbook, objects, _ => { }, selection: selection, examineSpell: examined.Add)!; UiElement group = layout.FindElement(0x100000AAu)!; UiItemList list = Descendants(group).OfType().First(); UiCatalogSlot first = Assert.IsType(list.GetItem(0)); UiCatalogSlot second = Assert.IsType(list.GetItem(1)); Assert.True(first.Selected); second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown)); Assert.False(first.Selected); Assert.True(second.Selected); Assert.Null(selection.SelectedObjectId); second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick)); Assert.Equal(new uint[] { 43u }, examined); Assert.Null(selection.SelectedObjectId); } private static SpellcastingUiController? Bind( ImportedLayout layout, Spellbook spellbook, ClientObjectTable objects, Action useItem, Action? addFavorite = null, UiShortcutDigitGraphics? shortcutDigits = null, uint emptySlotSprite = 0u, SelectionState? selection = null, Action? examineSpell = null) { SelectionState selectionState = selection ?? new SelectionState(); var casting = new RuntimeSpellCastState( spellbook, selectionState, new NoopSpellCastOperations()); return SpellcastingUiController.Bind( layout, spellbook, casting, objects, () => 1u, spellId => spellId, item => item.ObjectId, useItem, selectionState, (tab, position, spellId) => { spellbook.SetFavorite(tab, position, spellId); addFavorite?.Invoke(tab, position, spellId); }, (tab, spellId) => spellbook.RemoveFavorite(tab, spellId), shortcutDigits, emptySlotSprite, examineSpell); } private sealed class NoopSpellCastOperations : IRuntimeSpellCastOperations { public uint LocalPlayerId => 1u; public bool CanSend => true; public bool HasRequiredComponents(uint spellId) => true; public bool IsTargetCompatible( uint targetId, SpellMetadata spell, bool showMessage) => true; public void StopCompletely() { } public void SendUntargeted(uint spellId) { } public void SendTargeted(uint targetId, uint spellId) { } public void DisplayMessage(string message) { } public void IncrementBusy() { } } private static void ApplyAnchors(UiElement parent) { foreach (UiElement child in parent.Children) { child.ApplyAnchor(parent.Width, parent.Height); ApplyAnchors(child); } } private static IEnumerable 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"}")); } }