feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
|
|
@ -25,4 +25,20 @@ public class RuntimeOptionsRetailUiTests
|
|||
Assert.False(opts.RetailUi);
|
||||
Assert.Null(opts.AcDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReadsUiProbeOptions()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["ACDREAM_UI_PROBE_DUMP"] = "1",
|
||||
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
|
||||
};
|
||||
|
||||
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
||||
|
||||
Assert.True(opts.UiProbeDump);
|
||||
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
|
||||
Assert.True(opts.UiProbeEnabled);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.True(opts.RetailCloseDegrades);
|
||||
Assert.False(opts.DumpSceneryZ);
|
||||
Assert.Null(opts.LegacyStreamRadius);
|
||||
Assert.False(opts.UiProbeDump);
|
||||
Assert.Null(opts.UiProbeScript);
|
||||
Assert.False(opts.UiProbeEnabled);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
}
|
||||
|
||||
|
|
|
|||
155
tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
Normal file
155
tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class CursorFeedbackControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x50000010u;
|
||||
private const uint Source = 0x50000020u;
|
||||
private const uint Target = 0x50000021u;
|
||||
private const uint HealthKitUseability = 0x000A0008u;
|
||||
|
||||
[Fact]
|
||||
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
|
||||
{
|
||||
var c = new CursorFeedbackController();
|
||||
|
||||
var accept = c.Resolve(new CursorFeedbackSnapshot(
|
||||
DragPayload: new object(),
|
||||
DragAccept: UiItemSlot.DragAcceptState.Accept,
|
||||
HoverResizeEdges: ResizeEdges.Right));
|
||||
var reject = c.Resolve(new CursorFeedbackSnapshot(
|
||||
DragPayload: new object(),
|
||||
DragAccept: UiItemSlot.DragAcceptState.Reject,
|
||||
HoverResizeEdges: ResizeEdges.Right));
|
||||
|
||||
Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind);
|
||||
Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
|
||||
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
|
||||
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
|
||||
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
|
||||
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
|
||||
{
|
||||
var c = new CursorFeedbackController();
|
||||
|
||||
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
|
||||
|
||||
Assert.Equal(expected, feedback.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState()
|
||||
{
|
||||
var c = new CursorFeedbackController();
|
||||
|
||||
Assert.Equal(CursorFeedbackKind.WindowMove,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.Text,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetMode_reportsValidInvalidAndPendingTargets()
|
||||
{
|
||||
var objects = SeedTargetObjects();
|
||||
var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1_000);
|
||||
var c = new CursorFeedbackController(interaction);
|
||||
|
||||
Assert.True(interaction.ActivateItem(Source));
|
||||
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.TargetInvalid,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.TargetInvalid,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.TargetPending,
|
||||
c.Resolve(new CursorFeedbackSnapshot()).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem()
|
||||
{
|
||||
var objects = SeedTargetObjects();
|
||||
var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1_000);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var slot = new UiItemSlot
|
||||
{
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
UseTargetGuidProvider = () => Player,
|
||||
};
|
||||
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
|
||||
slot.SetStateCursors(new Dictionary<string, UiCursorMedia>
|
||||
{
|
||||
["Drag_rollover_accept"] = acceptCursor,
|
||||
});
|
||||
slot.SetItem(Target, iconTexture: 1u);
|
||||
root.AddChild(slot);
|
||||
root.OnMouseMove(20, 20);
|
||||
var c = new CursorFeedbackController(interaction);
|
||||
|
||||
Assert.True(interaction.ActivateItem(Source));
|
||||
|
||||
var feedback = c.Update(root);
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind);
|
||||
Assert.Equal(acceptCursor, feedback.Cursor);
|
||||
}
|
||||
|
||||
private static ClientObjectTable SeedTargetObjects()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Pack,
|
||||
Name = "Backpack",
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
});
|
||||
objects.MoveItem(Pack, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Source,
|
||||
Name = "Health Kit",
|
||||
Type = ItemType.Misc,
|
||||
Useability = HealthKitUseability,
|
||||
});
|
||||
objects.MoveItem(Source, Pack, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Target,
|
||||
Name = "Target",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
objects.MoveItem(Target, Pack, 1);
|
||||
return objects;
|
||||
}
|
||||
}
|
||||
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class ItemInteractionControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x50000010u;
|
||||
private const uint HealthKitUseability = 0x000A0008u;
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly List<uint> Uses = new();
|
||||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||
public readonly List<uint> Drops = new();
|
||||
public readonly List<string> Toasts = new();
|
||||
public long Now = 1_000;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Pack,
|
||||
Name = "Backpack",
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
});
|
||||
Objects.MoveItem(Pack, Player, 0);
|
||||
|
||||
Controller = new ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: Uses.Add,
|
||||
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||
sendDrop: Drops.Add,
|
||||
nowMs: () => Now,
|
||||
toast: Toasts.Add);
|
||||
}
|
||||
|
||||
public ItemInteractionController Controller { get; }
|
||||
|
||||
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
|
||||
{
|
||||
var item = new ClientObject
|
||||
{
|
||||
ObjectId = id,
|
||||
Name = $"Item {id:X}",
|
||||
Type = ItemType.Misc,
|
||||
IconId = 0x06001234u,
|
||||
};
|
||||
configure?.Invoke(item);
|
||||
Objects.AddOrUpdate(item);
|
||||
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetedItem_entersTargetModeAndMarksPendingSource()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A01u));
|
||||
|
||||
Assert.True(h.Controller.IsTargetModeActive);
|
||||
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
|
||||
Assert.Empty(h.UseWithTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
|
||||
|
||||
h.Controller.ActivateItem(0x50000A01u);
|
||||
Assert.True(h.Controller.AcquireSelfTarget());
|
||||
|
||||
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
|
||||
Assert.False(h.Controller.IsTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u);
|
||||
h.AddContained(0x50000A02u);
|
||||
|
||||
h.Controller.ActivateItem(0x50000A01u);
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A02u));
|
||||
|
||||
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
|
||||
Assert.False(h.Controller.IsTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectUseItem_sendsUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A03u));
|
||||
|
||||
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerItem_sendsUseToOpen()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A04u, item =>
|
||||
{
|
||||
item.Type = ItemType.Container;
|
||||
item.ItemsCapacity = 12;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A04u));
|
||||
|
||||
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A05u, item =>
|
||||
{
|
||||
item.Type = ItemType.Clothing;
|
||||
item.ValidLocations = EquipMask.HeadWear;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A05u));
|
||||
|
||||
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
|
||||
var equipped = h.Objects.Get(0x50000A05u)!;
|
||||
Assert.Equal(Player, equipped.ContainerId);
|
||||
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
|
||||
{
|
||||
var h = new Harness();
|
||||
const EquipMask coatMask =
|
||||
EquipMask.ChestWear
|
||||
| EquipMask.UpperArmWear
|
||||
| EquipMask.LowerArmWear;
|
||||
h.AddContained(0x50000A15u, item =>
|
||||
{
|
||||
item.Type = ItemType.Clothing;
|
||||
item.ValidLocations = coatMask;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A15u));
|
||||
|
||||
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
|
||||
var equipped = h.Objects.Get(0x50000A15u)!;
|
||||
Assert.Equal(Player, equipped.ContainerId);
|
||||
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x50000AF1u,
|
||||
Type = ItemType.Clothing,
|
||||
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
||||
Priority = 0x00000001u,
|
||||
});
|
||||
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
|
||||
const EquipMask coatMask =
|
||||
EquipMask.ChestWear
|
||||
| EquipMask.UpperArmWear
|
||||
| EquipMask.LowerArmWear;
|
||||
h.AddContained(0x50000A16u, item =>
|
||||
{
|
||||
item.Type = ItemType.Clothing;
|
||||
item.ValidLocations = coatMask;
|
||||
item.Priority = 0x00000002u;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A16u));
|
||||
|
||||
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
|
||||
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x50000AF2u,
|
||||
Type = ItemType.Clothing,
|
||||
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
||||
Priority = 0x00000004u,
|
||||
});
|
||||
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
|
||||
const EquipMask coatMask =
|
||||
EquipMask.ChestWear
|
||||
| EquipMask.UpperArmWear
|
||||
| EquipMask.LowerArmWear;
|
||||
h.AddContained(0x50000A17u, item =>
|
||||
{
|
||||
item.Type = ItemType.Clothing;
|
||||
item.ValidLocations = coatMask;
|
||||
item.Priority = 0x00000004u;
|
||||
});
|
||||
|
||||
Assert.False(h.Controller.ActivateItem(0x50000A17u));
|
||||
|
||||
Assert.Empty(h.Wields);
|
||||
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquippableItemWithNoFreeSlot_sendsNothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x50000AF0u,
|
||||
Type = ItemType.Armor,
|
||||
CurrentlyEquippedLocation = EquipMask.Shield,
|
||||
});
|
||||
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
|
||||
h.AddContained(0x50000A06u, item =>
|
||||
{
|
||||
item.Type = ItemType.Armor;
|
||||
item.ValidLocations = EquipMask.Shield;
|
||||
});
|
||||
|
||||
Assert.False(h.Controller.ActivateItem(0x50000A06u));
|
||||
|
||||
Assert.Empty(h.Wields);
|
||||
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A07u);
|
||||
var payload = new ItemDragPayload(
|
||||
0x50000A07u,
|
||||
ItemDragSource.Inventory,
|
||||
SourceSlot: 0,
|
||||
SourceCell: new UiItemSlot());
|
||||
|
||||
Assert.True(h.Controller.DropToWorld(payload));
|
||||
|
||||
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
|
||||
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A08u);
|
||||
var payload = new ItemDragPayload(
|
||||
0x50000A08u,
|
||||
ItemDragSource.ShortcutBar,
|
||||
SourceSlot: 0,
|
||||
SourceCell: new UiItemSlot());
|
||||
|
||||
Assert.False(h.Controller.DropToWorld(payload));
|
||||
|
||||
Assert.Empty(h.Drops);
|
||||
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivateItem_appliesRetailUseThrottle()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
|
||||
|
||||
h.Controller.ActivateItem(0x50000A09u);
|
||||
h.Now += 199;
|
||||
h.Controller.ActivateItem(0x50000A09u);
|
||||
h.Now += 1;
|
||||
h.Controller.ActivateItem(0x50000A09u);
|
||||
|
||||
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
|
||||
}
|
||||
}
|
||||
109
tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs
Normal file
109
tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class CharacterLayoutImportProbe
|
||||
{
|
||||
private const uint CharacterLayout = 0x2100002Eu;
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selected_attribute_shows_raise_buttons_on_visible_footer_state()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
CharacterStatController.Bind(layout!, SampleData.SampleCharacter, spriteResolve: _ => (1u, 30, 26));
|
||||
|
||||
var rows = new List<UiClickablePanel>();
|
||||
CollectRows(layout!.Root, rows);
|
||||
Assert.True(rows.Count >= 9, $"expected at least 9 attribute rows, found {rows.Count}");
|
||||
|
||||
rows[4].OnClick!();
|
||||
|
||||
var buttons = new List<UiButton>();
|
||||
CollectButtons(layout.Root, buttons);
|
||||
|
||||
Assert.Contains(buttons, b =>
|
||||
b.ElementId == CharacterStatController.RaiseOneId
|
||||
&& b.ActiveState == "Normal"
|
||||
&& IsEffectivelyVisible(b));
|
||||
Assert.Contains(buttons, b =>
|
||||
b.ElementId == CharacterStatController.RaiseTenId
|
||||
&& b.ActiveState == "Normal"
|
||||
&& IsEffectivelyVisible(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Close_button_resolves_and_invokes_controller_close_callback()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
var close = layout!.FindElement(WindowChromeController.CharacterCloseButtonId);
|
||||
Assert.NotNull(close);
|
||||
Assert.True(close is UiButton or UiDatElement,
|
||||
$"character close button resolved to {close?.GetType().Name ?? "null"}");
|
||||
|
||||
int closes = 0;
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
|
||||
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, closes);
|
||||
}
|
||||
|
||||
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
|
||||
{
|
||||
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)
|
||||
result.Add(row);
|
||||
foreach (var child in node.Children)
|
||||
CollectRows(child, result);
|
||||
}
|
||||
|
||||
private static void CollectButtons(UiElement node, List<UiButton> result)
|
||||
{
|
||||
if (node is UiButton button
|
||||
&& (button.ElementId == CharacterStatController.RaiseOneId
|
||||
|| button.ElementId == CharacterStatController.RaiseTenId))
|
||||
{
|
||||
result.Add(button);
|
||||
}
|
||||
|
||||
foreach (var child in node.Children)
|
||||
CollectButtons(child, result);
|
||||
}
|
||||
|
||||
private static bool IsEffectivelyVisible(UiElement element)
|
||||
{
|
||||
for (UiElement? e = element; e is not null; e = e.Parent)
|
||||
{
|
||||
if (!e.Visible) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CharacterSheetProvider"/> — the extracted character-sheet
|
||||
/// assembly + raise flow (formerly private methods inside GameWindow). Covers the
|
||||
/// XP-curve math against a known synthetic ExperienceTable and the single-owner
|
||||
/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire
|
||||
/// CharacterChanged).
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProviderTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
|
||||
/// <summary>Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250.</summary>
|
||||
private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new()
|
||||
{
|
||||
Levels = new ulong[] { 0, 100, 250, 450 },
|
||||
Attributes = new uint[] { 0, 10, 30, 60, 100 },
|
||||
Vitals = new uint[] { 0, 4, 12, 24 },
|
||||
TrainedSkills = new uint[] { 0, 5, 15, 30 },
|
||||
SpecializedSkills = new uint[] { 0, 8, 24, 48 },
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public ClientObjectTable Table { get; } = new();
|
||||
public LocalPlayerState Player { get; } = new();
|
||||
public CharacterSheetProvider Provider { get; }
|
||||
public (uint statId, ulong cost)? SentAttribute;
|
||||
public (uint skillId, uint credits)? SentTrain;
|
||||
public bool CanSend = true;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Provider = new CharacterSheetProvider(
|
||||
Table, Player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
activeToonName: () => "default",
|
||||
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 },
|
||||
canSendRaise: () => CanSend,
|
||||
sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost),
|
||||
sendRaiseVital: (_, _) => { },
|
||||
sendRaiseSkill: (_, _) => { },
|
||||
sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits))
|
||||
{
|
||||
ExperienceTable = MakeXpTable(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Put the player's ClientObject in the table with live sheet properties.</summary>
|
||||
public ClientObject AddPlayerObject(long unassignedXp = 1000L)
|
||||
{
|
||||
var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
player.Properties.Ints[0x19u] = 1; // level
|
||||
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
|
||||
player.Properties.Int64s[2u] = unassignedXp;
|
||||
Table.AddOrUpdate(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_NoLiveData_UsesFallbackSheet()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal(-1, sheet.Level); // fallback marker
|
||||
Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 777L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal("Testy", sheet.Name); // live object name beats "Player"
|
||||
Assert.Equal(1, sheet.Level);
|
||||
Assert.Equal(150L, sheet.TotalXp);
|
||||
Assert.Equal(777L, sheet.UnassignedXp);
|
||||
// Level band 100..250, at 150: 100 XP to next, 1/3 through the band.
|
||||
Assert.Equal(100L, sheet.XpToNextLevel);
|
||||
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
|
||||
Assert.Equal(11, sheet.Strength); // ranks + start
|
||||
// Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10.
|
||||
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
|
||||
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject();
|
||||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained
|
||||
h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
var skill = Assert.Single(sheet.Skills);
|
||||
Assert.Equal(6u, skill.Id);
|
||||
Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name
|
||||
Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass);
|
||||
// TrainedSkills curve: x1 = 15 − 5; x10 clamps at index 3: 30 − 5.
|
||||
Assert.Equal(10L, skill.RaiseCost);
|
||||
Assert.Equal(25L, skill.Raise10Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 1000L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
int tableUpdates = 0;
|
||||
h.Table.ObjectUpdated += _ => tableUpdates++;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||||
|
||||
Assert.Equal((1u, 20ul), h.SentAttribute);
|
||||
var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength);
|
||||
Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply
|
||||
Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited
|
||||
Assert.True(tableUpdates >= 1); // via the eventful API
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 1000L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
h.CanSend = false;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||||
|
||||
Assert.Null(h.SentAttribute);
|
||||
Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks);
|
||||
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty()
|
||||
{
|
||||
var h = new Harness();
|
||||
var player = h.AddPlayerObject();
|
||||
player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain
|
||||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
|
||||
|
||||
Assert.Equal((6u, 4u), h.SentTrain);
|
||||
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
||||
Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
|
||||
{
|
||||
var h = new Harness(); // note: nothing added to the table
|
||||
var props = new PropertyBundle();
|
||||
props.Int64s[2u] = 500L;
|
||||
h.Player.OnProperties(props);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
int changed = 0;
|
||||
h.Player.CharacterChanged += () => changed++;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
|
||||
|
||||
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
|
||||
Assert.True(changed >= 1); // and CharacterChanged fired
|
||||
}
|
||||
}
|
||||
|
|
@ -50,10 +50,114 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
|
||||
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
|
||||
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_WindowChromeButton_InvokesCloseCallback()
|
||||
{
|
||||
var close = MakeButton(WindowChromeController.CharacterCloseButtonId);
|
||||
var layout = Fake((WindowChromeController.CharacterCloseButtonId, close));
|
||||
int closes = 0;
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
|
||||
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle()
|
||||
{
|
||||
var sheet = new CharacterSheet
|
||||
{
|
||||
Gender = "Female",
|
||||
Heritage = "Aluvian",
|
||||
Title = "the Adventurer",
|
||||
};
|
||||
|
||||
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Male")]
|
||||
[InlineData(2, "Female")]
|
||||
[InlineData(0, null)]
|
||||
public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected)
|
||||
=> Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Aluvian")]
|
||||
[InlineData(2, "Gharu'ndim")]
|
||||
[InlineData(5, "Umbraen")]
|
||||
[InlineData(13, "Olthoi")]
|
||||
[InlineData(99, null)]
|
||||
public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected)
|
||||
=> Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value));
|
||||
|
||||
[Fact]
|
||||
public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated()
|
||||
{
|
||||
var root = new UiPanel { Width = 300, Height = 600 };
|
||||
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||||
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||||
|
||||
var visibleName = new UiText { ElementId = CharacterStatController.NameId };
|
||||
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
|
||||
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||||
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||||
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||||
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||||
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||||
var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||||
var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||||
var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||||
var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||||
var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||||
var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||||
var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||||
visibleMeter.AddChild(visibleXpNext);
|
||||
hiddenMeter.AddChild(hiddenXpNext);
|
||||
|
||||
attrPage.AddChild(visibleName);
|
||||
attrPage.AddChild(visibleHeritage);
|
||||
attrPage.AddChild(visibleLevel);
|
||||
attrPage.AddChild(visibleTotalXpLabel);
|
||||
attrPage.AddChild(visibleTotalXp);
|
||||
attrPage.AddChild(visibleMeter);
|
||||
hiddenPage.AddChild(hiddenName);
|
||||
hiddenPage.AddChild(hiddenHeritage);
|
||||
hiddenPage.AddChild(hiddenLevel);
|
||||
hiddenPage.AddChild(hiddenTotalXpLabel);
|
||||
hiddenPage.AddChild(hiddenTotalXp);
|
||||
hiddenPage.AddChild(hiddenMeter);
|
||||
root.AddChild(attrPage);
|
||||
root.AddChild(hiddenPage);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[CharacterStatController.NameId] = hiddenName,
|
||||
[CharacterStatController.HeritageId] = hiddenHeritage,
|
||||
[CharacterStatController.LevelId] = hiddenLevel,
|
||||
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
|
||||
[CharacterStatController.TotalXpId] = hiddenTotalXp,
|
||||
[CharacterStatController.XpMeterId] = hiddenMeter,
|
||||
[CharacterStatController.XpNextValueId] = hiddenXpNext,
|
||||
});
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
|
||||
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
|
||||
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
|
||||
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
|
||||
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
|
||||
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
|
||||
Assert.Empty(hiddenName.LinesProvider());
|
||||
Assert.Empty(hiddenXpNext.LinesProvider());
|
||||
}
|
||||
|
||||
// ── XP meter fill ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
@ -514,6 +618,31 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal("Normal", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
var sheet = new CharacterSheet
|
||||
{
|
||||
UnassignedXp = 500L,
|
||||
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||||
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||||
};
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_MaxedRow_ShowsGhostedState()
|
||||
{
|
||||
|
|
@ -535,6 +664,95 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind);
|
||||
Assert.Equal(5u, request.StatId);
|
||||
Assert.Equal(110L, request.Cost);
|
||||
Assert.Equal(1, request.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
|
||||
btn1.OnClick!();
|
||||
|
||||
Assert.Single(requests);
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind);
|
||||
Assert.Equal(1u, request.StatId);
|
||||
Assert.Equal(90L, request.Cost);
|
||||
Assert.Equal(1, request.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest()
|
||||
{
|
||||
var btn10 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseTenId, btn10),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
var sheet = new CharacterSheet
|
||||
{
|
||||
UnassignedXp = 500L,
|
||||
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||||
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||||
};
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn10.OnClick!();
|
||||
|
||||
Assert.Empty(requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_Deselect_HidesButtons()
|
||||
{
|
||||
|
|
@ -635,10 +853,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
ClickTab(layout, left: 92f);
|
||||
|
||||
var headerPanels = list.Children
|
||||
.Where(c => c is UiPanel and not UiClickablePanel)
|
||||
.Cast<UiPanel>()
|
||||
.ToList();
|
||||
var headerPanels = SkillHeaders(list);
|
||||
var headers = headerPanels
|
||||
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
|
||||
.ToList();
|
||||
|
|
@ -654,7 +869,7 @@ public class CharacterStatControllerTests
|
|||
Assert.All(headerPanels, h =>
|
||||
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = SkillRows(list);
|
||||
Assert.Equal(12, rows.Count);
|
||||
Assert.All(rows, row =>
|
||||
{
|
||||
|
|
@ -692,7 +907,7 @@ public class CharacterStatControllerTests
|
|||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
Assert.Equal(12, list.Children.OfType<UiClickablePanel>().Count());
|
||||
Assert.Equal(12, SkillRows(list).Count);
|
||||
|
||||
ClickTab(layout, left: 0f);
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
|
|
@ -704,8 +919,8 @@ public class CharacterStatControllerTests
|
|||
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
|
||||
{
|
||||
var root = new UiPanel { Width = 300, Height = 600 };
|
||||
var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
|
||||
var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
|
||||
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||||
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||||
var name = new UiText();
|
||||
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||||
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||||
|
|
@ -761,7 +976,7 @@ public class CharacterStatControllerTests
|
|||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = SkillRows(list);
|
||||
rows[1].OnClick!();
|
||||
|
||||
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
|
||||
|
|
@ -777,6 +992,31 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest()
|
||||
{
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var btn10 = MakeButton();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.ListBoxId, list),
|
||||
(CharacterStatController.RaiseTenId, btn10));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: requests.Add);
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list)[1].OnClick!();
|
||||
btn10.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind);
|
||||
Assert.Equal(34u, request.StatId);
|
||||
Assert.Equal(111_000_000L, request.Cost);
|
||||
Assert.Equal(10, request.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
|
||||
{
|
||||
|
|
@ -800,7 +1040,7 @@ public class CharacterStatControllerTests
|
|||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[5].OnClick!();
|
||||
SkillRows(list)[5].OnClick!();
|
||||
|
||||
Assert.Equal("Healing", title.LinesProvider()[0].Text);
|
||||
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
|
||||
|
|
@ -811,6 +1051,133 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest()
|
||||
{
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var btn1 = MakeButton();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.ListBoxId, list),
|
||||
(CharacterStatController.RaiseOneId, btn1));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: requests.Add);
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list)[5].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||||
Assert.Equal(21u, request.StatId);
|
||||
Assert.Equal(6L, request.Cost);
|
||||
Assert.Equal(1, request.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained()
|
||||
{
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
CharacterSheet sheet = new()
|
||||
{
|
||||
SkillCredits = 10,
|
||||
UnassignedXp = 1_000,
|
||||
Skills = new[]
|
||||
{
|
||||
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||||
CharacterSkillAdvancementClass.Untrained,
|
||||
BaseLevel: 5,
|
||||
CurrentLevel: 5,
|
||||
UsableUntrained: true,
|
||||
TrainedCost: 4,
|
||||
SpecializedCost: 0,
|
||||
RaiseCost: 0,
|
||||
Raise10Cost: 0),
|
||||
},
|
||||
};
|
||||
var layout = Fake(
|
||||
(CharacterStatController.ListBoxId, list),
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: request =>
|
||||
{
|
||||
requests.Add(request);
|
||||
sheet = new CharacterSheet
|
||||
{
|
||||
SkillCredits = 6,
|
||||
UnassignedXp = 1_000,
|
||||
Skills = new[]
|
||||
{
|
||||
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||||
CharacterSkillAdvancementClass.Trained,
|
||||
BaseLevel: 5,
|
||||
CurrentLevel: 5,
|
||||
UsableUntrained: true,
|
||||
TrainedCost: 4,
|
||||
SpecializedCost: 0,
|
||||
RaiseCost: 10,
|
||||
Raise10Cost: 100),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list).Single().OnClick!();
|
||||
Assert.False(btn10.Visible);
|
||||
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||||
Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
|
||||
Assert.True(btn1.Visible);
|
||||
Assert.True(btn10.Visible);
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
Assert.Equal("Normal", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
|
||||
{
|
||||
var root = new UiPanel { Width = 300, Height = 600 };
|
||||
var page = new UiPanel { Width = 300, Height = 600 };
|
||||
var name = new UiText();
|
||||
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80);
|
||||
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80);
|
||||
scrollbarShell.Left = 281;
|
||||
|
||||
page.AddChild(name);
|
||||
page.AddChild(list);
|
||||
page.AddChild(scrollbarShell);
|
||||
root.AddChild(page);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[CharacterStatController.NameId] = name,
|
||||
[CharacterStatController.ListBoxId] = list,
|
||||
[CharacterStatController.ListScrollbarId] = scrollbarShell,
|
||||
});
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
|
||||
var bar = page.Children.OfType<UiScrollbar>().Single();
|
||||
Assert.True(bar.Visible);
|
||||
Assert.NotNull(bar.Model);
|
||||
Assert.Contains(list.Children, c => c is UiScrollablePanel);
|
||||
Assert.False(scrollbarShell.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_Index4Focus_Returns110()
|
||||
{
|
||||
|
|
@ -818,6 +1185,13 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_Amount10Index4Focus_Returns1100()
|
||||
{
|
||||
var sheet = SampleData.SampleCharacter();
|
||||
Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_Index0Strength_Returns0()
|
||||
{
|
||||
|
|
@ -868,10 +1242,22 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(9, costs.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaise10Costs_HasNineEntries()
|
||||
{
|
||||
var costs = SampleData.SampleCharacter().AttributeRaise10Costs;
|
||||
Assert.NotNull(costs);
|
||||
Assert.Equal(9, costs.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
|
||||
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaise10Costs_FocusAt1100()
|
||||
=> Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]);
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
|
||||
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
|
||||
|
|
@ -1098,12 +1484,32 @@ public class CharacterStatControllerTests
|
|||
|
||||
private static string FirstRowName(UiElement list)
|
||||
{
|
||||
var row = list.Children.OfType<UiClickablePanel>().FirstOrDefault();
|
||||
var row = Descendants(list).OfType<UiClickablePanel>().FirstOrDefault();
|
||||
if (row is null) return "<no row>";
|
||||
var texts = row.Children.OfType<UiText>().ToList();
|
||||
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
|
||||
}
|
||||
|
||||
private static List<UiClickablePanel> SkillRows(UiElement list)
|
||||
=> Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
|
||||
private static List<UiPanel> SkillHeaders(UiElement list)
|
||||
=> Descendants(list)
|
||||
.Where(c => c is UiPanel and not UiClickablePanel
|
||||
&& c.Children.OfType<UiText>().Any())
|
||||
.Cast<UiPanel>()
|
||||
.ToList();
|
||||
|
||||
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||||
{
|
||||
foreach (var child in root.Children)
|
||||
{
|
||||
yield return child;
|
||||
foreach (var nested in Descendants(child))
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
|
||||
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
|
|
@ -1123,9 +1529,9 @@ public class CharacterStatControllerTests
|
|||
}
|
||||
|
||||
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
||||
private static UiButton MakeButton()
|
||||
private static UiButton MakeButton(uint id = 0u)
|
||||
{
|
||||
var info = new ElementInfo { Id = 0, Type = 1 };
|
||||
var info = new ElementInfo { Id = id, Type = 1 };
|
||||
return new UiButton(info, static _ => (0u, 0, 0));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,19 @@ public class ChatWindowControllerTests
|
|||
|
||||
// ── Test 3: Input is placed as a child of the input bar ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Transcript_ForcesScrollableTextMode()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
Assert.False(ctrl!.Transcript.Centered);
|
||||
Assert.False(ctrl.Transcript.RightAligned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Input_IsChildOfInputBar()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -63,6 +63,18 @@ public class DatWidgetFactoryTests
|
|||
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_PropagatesStateCursors()
|
||||
{
|
||||
var info = new ElementInfo { Type = 3 };
|
||||
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
|
||||
|
||||
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
||||
|
||||
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
|
||||
e.CursorForState("Drag_rollover_accept", allowFallback: false));
|
||||
}
|
||||
|
||||
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -148,6 +148,22 @@ public class ElementReaderTests
|
|||
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DerivedStateCursorOverridesBase()
|
||||
{
|
||||
var base_ = new ElementInfo();
|
||||
base_.StateCursors[""] = new UiCursorMedia(0x06003000u, 1, 2);
|
||||
base_.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06003001u, 3, 4);
|
||||
|
||||
var derived = new ElementInfo();
|
||||
derived.StateCursors[""] = new UiCursorMedia(0x06004000u, 5, 6);
|
||||
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
|
||||
Assert.Equal(new UiCursorMedia(0x06004000u, 5, 6), merged.StateCursors[""]);
|
||||
Assert.Equal(new UiCursorMedia(0x06003001u, 3, 4), merged.StateCursors["Drag_rollover_accept"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_ChildrenComeFromDerived()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public class InventoryControllerTests
|
|||
private const uint BurdenText = 0x100001D8u;
|
||||
private const uint BurdenCaption = 0x100001D7u;
|
||||
private const uint ContentsCaption = 0x100001C5u;
|
||||
private const uint TitleText = 0x100001D3u;
|
||||
private const uint ContentsScrollbar = 0x100001C7u;
|
||||
|
||||
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
||||
|
|
@ -35,16 +36,17 @@ public class InventoryControllerTests
|
|||
var burdenText = new TestElement { Width = 36, Height = 15 };
|
||||
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
||||
var contentsCap = new TestElement { Width = 192, Height = 15 };
|
||||
var titleText = new TestElement { Width = 276, Height = 25 };
|
||||
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
||||
var root = new TestElement { Width = 300, Height = 362 };
|
||||
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
||||
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
||||
root.AddChild(contentsCap); root.AddChild(scrollbar);
|
||||
root.AddChild(contentsCap); root.AddChild(titleText); root.AddChild(scrollbar);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
||||
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
||||
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
|
||||
[ContentsCaption] = contentsCap, [TitleText] = titleText, [ContentsScrollbar] = scrollbar,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
||||
burdenText, burdenCap, contentsCap);
|
||||
|
|
@ -52,13 +54,23 @@ public class InventoryControllerTests
|
|||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null)
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
string? ownerName = null,
|
||||
Action? onClose = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
ownerName: ownerName is null ? null : () => ownerName,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||
onClose: onClose);
|
||||
|
||||
private static UiButton MakeButton(uint id)
|
||||
{
|
||||
var info = new ElementInfo { Id = id, Type = 1 };
|
||||
return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 };
|
||||
}
|
||||
|
||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||
|
|
@ -100,6 +112,23 @@ public class InventoryControllerTests
|
|||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_uses_manifest_container_hint_before_create_object_details()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.ReplaceContents(Player, new[]
|
||||
{
|
||||
new ContainerContentEntry(0xC, 1u),
|
||||
new ContainerContentEntry(0xA, 0u),
|
||||
});
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipped_items_are_excluded_from_the_grid_and_selector()
|
||||
{
|
||||
|
|
@ -125,6 +154,7 @@ public class InventoryControllerTests
|
|||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Equal(6, grid.Columns);
|
||||
Assert.Equal(UiItemListFlow.RowMajor, grid.Flow);
|
||||
Assert.Equal(32f, grid.CellWidth);
|
||||
Assert.Equal(32f, grid.CellHeight);
|
||||
}
|
||||
|
|
@ -164,11 +194,27 @@ public class InventoryControllerTests
|
|||
public void Captions_render_known_strings()
|
||||
{
|
||||
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
var title = layout.FindElement(TitleText)!;
|
||||
Bind(layout, new ClientObjectTable(), ownerName: "Horan");
|
||||
Assert.Contains("Inventory of Horan", CaptionText(title));
|
||||
Assert.Contains("Burden", CaptionText(burdenCap));
|
||||
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_chrome_button_invokes_close_callback()
|
||||
{
|
||||
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
||||
var close = MakeButton(WindowChromeController.InventoryCloseButtonId);
|
||||
layout.Root.AddChild(close);
|
||||
int closes = 0;
|
||||
|
||||
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
|
||||
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
|
||||
{
|
||||
|
|
@ -354,6 +400,42 @@ public class InventoryControllerTests
|
|||
Assert.Empty(closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetMode_suppressesSelectedSquare_onPendingSource()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Type = ItemType.Creature,
|
||||
ItemsCapacity = 102,
|
||||
});
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
objects.Get(0xA)!.Useability = 0x000A0008u;
|
||||
var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1_000);
|
||||
|
||||
InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
datFont: null,
|
||||
itemInteraction: interaction);
|
||||
grid.GetItem(0)!.Clicked!();
|
||||
Assert.True(grid.GetItem(0)!.Selected);
|
||||
|
||||
Assert.True(interaction.ActivateItem(0xAu));
|
||||
|
||||
Assert.True(interaction.IsTargetModeActive);
|
||||
Assert.False(grid.GetItem(0)!.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_mainPackCell_isOpenContainer()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
|
@ -86,4 +87,34 @@ public class InventoryFrameImportProbe
|
|||
"(else the Alphablend backdrop overpaints/washes out the panel content)");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Close_button_resolves_and_invokes_controller_close_callback()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat - skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
var close = layout!.FindElement(WindowChromeController.InventoryCloseButtonId);
|
||||
Assert.NotNull(close);
|
||||
Assert.True(close is UiButton or UiDatElement,
|
||||
$"inventory close button resolved to {close?.GetType().Name ?? "null"}");
|
||||
|
||||
int closes = 0;
|
||||
InventoryController.Bind(
|
||||
layout,
|
||||
new ClientObjectTable(),
|
||||
playerGuid: static () => 0u,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: static () => 100,
|
||||
datFont: null,
|
||||
onClose: () => closes++);
|
||||
|
||||
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, closes);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,22 @@ public class LayoutImporterTests
|
|||
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
||||
/// the Type-3 must also be present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFromInfos_StampsDatElementId_OnRootAndItemList()
|
||||
{
|
||||
const uint RootId = 0x100005F9u;
|
||||
const uint ListId = 0x100001C6u;
|
||||
|
||||
var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 };
|
||||
var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 };
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null);
|
||||
|
||||
Assert.Equal(RootId, tree.Root.DatElementId);
|
||||
var found = Assert.IsType<UiItemList>(tree.FindElement(ListId));
|
||||
Assert.Equal(ListId, found.DatElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ public class PaperdollControllerTests
|
|||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x40000005u;
|
||||
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
||||
private const uint ChestSlot = 0x100001E2u; // ChestWear 0x2
|
||||
private const uint ChestArmorSlot = 0x100005ACu; // ChestArmor 0x200
|
||||
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
||||
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
||||
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
||||
|
|
@ -19,7 +21,7 @@ public class PaperdollControllerTests
|
|||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
||||
{
|
||||
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||
var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||
var lists = new Dictionary<uint, UiItemList>();
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = new RootElement { Width = 224, Height = 214 };
|
||||
|
|
@ -112,6 +114,47 @@ public class PaperdollControllerTests
|
|||
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_onAutoWearSlot_sendsFullValidLocations()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
const EquipMask coatMask =
|
||||
EquipMask.ChestWear
|
||||
| EquipMask.UpperArmWear
|
||||
| EquipMask.LowerArmWear;
|
||||
SeedPackItem(objects, 0xE02u, coatMask);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xE02u, ItemDragSource.Inventory, 0, lists[ChestSlot].Cell);
|
||||
|
||||
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
|
||||
|
||||
Assert.Equal((uint)coatMask, wields[0].mask);
|
||||
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_onArmorAutoWearSlot_sendsFullValidLocations()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
const EquipMask hauberkMask =
|
||||
EquipMask.ChestArmor
|
||||
| EquipMask.AbdomenArmor
|
||||
| EquipMask.UpperArmArmor
|
||||
| EquipMask.LowerArmArmor;
|
||||
SeedPackItem(objects, 0xE03u, hauberkMask);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xE03u, ItemDragSource.Inventory, 0, lists[ChestArmorSlot].Cell);
|
||||
|
||||
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
|
||||
|
||||
Assert.Equal((uint)hauberkMask, wields[0].mask);
|
||||
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public class ToolbarControllerTests
|
|||
|
||||
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
|
||||
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
private const uint InventoryButtonId = 0x100001B1u;
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
|
||||
Dictionary<uint, UiElement> indicators) FakeToolbar()
|
||||
|
|
@ -34,6 +36,8 @@ public class ToolbarControllerTests
|
|||
var e = new UiPanel { Visible = true };
|
||||
dict[id] = e; indicators[id] = e; root.AddChild(e);
|
||||
}
|
||||
AddButton(CharacterButtonId);
|
||||
AddButton(InventoryButtonId);
|
||||
return (new ImportedLayout(root, dict), slots, indicators);
|
||||
|
||||
void AddSlot(uint id)
|
||||
|
|
@ -41,6 +45,23 @@ public class ToolbarControllerTests
|
|||
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||
dict[id] = list; slots[id] = list; root.AddChild(list);
|
||||
}
|
||||
|
||||
void AddButton(uint id)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = id,
|
||||
Type = 1,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
DefaultStateName = "Normal",
|
||||
};
|
||||
info.StateMedia["Normal"] = (0x1u, 1);
|
||||
info.StateMedia["Highlight"] = (0x2u, 1);
|
||||
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||
dict[id] = button;
|
||||
root.AddChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -95,6 +116,93 @@ public class ToolbarControllerTests
|
|||
Assert.Equal(0x5001u, used);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowToggleButtons_clickCallbacks_fire()
|
||||
{
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
int inventoryClicks = 0;
|
||||
int characterClicks = 0;
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
ctrl.BindWindowToggles(
|
||||
toggleInventory: () => inventoryClicks++,
|
||||
toggleCharacter: () => characterClicks++);
|
||||
|
||||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, inventoryClicks);
|
||||
Assert.Equal(1, characterClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
|
||||
{
|
||||
const uint player = 0x50000001u;
|
||||
const uint pack = 0x50000010u;
|
||||
const uint kit = 0x50000A01u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
|
||||
repo.MoveItem(pack, player, 0);
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u });
|
||||
repo.MoveItem(kit, pack, 0);
|
||||
var useWithTarget = new List<(uint Source, uint Target)>();
|
||||
var interaction = new ItemInteractionController(
|
||||
repo,
|
||||
playerGuid: () => player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1_000);
|
||||
int inventoryClicks = 0;
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u,
|
||||
useItem: _ => { },
|
||||
itemInteraction: interaction);
|
||||
ctrl.BindWindowToggles(
|
||||
toggleInventory: () => inventoryClicks++,
|
||||
toggleCharacter: () => { });
|
||||
interaction.ActivateItem(kit);
|
||||
|
||||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0, inventoryClicks);
|
||||
Assert.Equal(new[] { (kit, player) }, useWithTarget);
|
||||
Assert.False(interaction.IsTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowButtonState_highlightsWhenOpen()
|
||||
{
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
|
||||
|
||||
ctrl.SetInventoryOpen(true);
|
||||
ctrl.SetCharacterOpen(true);
|
||||
|
||||
Assert.Equal("Highlight", inventoryButton.ActiveState);
|
||||
Assert.Equal("Highlight", characterButton.ActiveState);
|
||||
|
||||
ctrl.SetInventoryOpen(false);
|
||||
ctrl.SetCharacterOpen(false);
|
||||
|
||||
Assert.Equal("Normal", inventoryButton.ActiveState);
|
||||
Assert.Equal("Normal", characterButton.ActiveState);
|
||||
}
|
||||
|
||||
// ── C1: combat-mode indicator tests ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
61
tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs
Normal file
61
tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using SysEnv = System.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailCursorCatalogTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)]
|
||||
[InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)]
|
||||
[InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)]
|
||||
public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState(
|
||||
CursorFeedbackKind kind,
|
||||
uint expectedEnum,
|
||||
int expectedHotspotX,
|
||||
int expectedHotspotY)
|
||||
{
|
||||
Assert.Equal(6u, RetailCursorCatalog.CursorEnumTable);
|
||||
|
||||
Assert.True(RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec));
|
||||
|
||||
Assert.Equal(expectedEnum, spec.EnumId);
|
||||
Assert.Equal(expectedHotspotX, spec.HotspotX);
|
||||
Assert.Equal(expectedHotspotY, spec.HotspotY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetUseCursorSpecs_resolveGoldenDatSurfaces()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var resolver = new RetailCursorResolver(dats, new object());
|
||||
|
||||
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending));
|
||||
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid));
|
||||
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid));
|
||||
|
||||
Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending);
|
||||
Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid);
|
||||
Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid);
|
||||
}
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
|
||||
string defaultDir = Path.Combine(
|
||||
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
return Directory.Exists(defaultDir) ? defaultDir : null;
|
||||
}
|
||||
}
|
||||
179
tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
Normal file
179
tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Testing;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailUiAutomationProbeTests
|
||||
{
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
=> LastLift = (sourceList, sourceCell, payload);
|
||||
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> true;
|
||||
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> LastDrop = (targetList, targetCell, payload);
|
||||
}
|
||||
|
||||
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
|
||||
RootWithTwoItemLists()
|
||||
{
|
||||
var root = new UiRoot { Width = 240, Height = 120 };
|
||||
var objects = new ClientObjectTable();
|
||||
|
||||
var source = new UiItemList(_ => (1u, 1, 1))
|
||||
{
|
||||
DatElementId = 0x10000010u,
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
};
|
||||
source.Cell.SlotIndex = 0;
|
||||
source.Cell.SourceKind = ItemDragSource.Inventory;
|
||||
source.Cell.SetItem(0x5001u, 0x99u);
|
||||
|
||||
var target = new UiItemList(_ => (1u, 1, 1))
|
||||
{
|
||||
DatElementId = 0x10000020u,
|
||||
Left = 70,
|
||||
Top = 10,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
};
|
||||
target.Cell.SlotIndex = 1;
|
||||
|
||||
var handler = new SpyHandler();
|
||||
target.RegisterDragHandler(handler);
|
||||
|
||||
root.AddChild(source);
|
||||
root.AddChild(target);
|
||||
return (root, source, target, handler, objects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_listsDatElementsAndItemSlots()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
|
||||
var rows = probe.Snapshot();
|
||||
|
||||
Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList));
|
||||
var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u);
|
||||
Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind);
|
||||
Assert.Equal(0, itemRow.SlotIndex);
|
||||
Assert.True(itemRow.Width > 0f);
|
||||
Assert.True(itemRow.Height > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClickItem_routesThroughUiRootDoubleClick()
|
||||
{
|
||||
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
||||
bool doubleClicked = false;
|
||||
source.Cell.DoubleClicked = () => doubleClicked = true;
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
|
||||
Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory));
|
||||
|
||||
Assert.True(doubleClicked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragItemToElement_deliversDropReleaseToTargetCell()
|
||||
{
|
||||
var (root, _, target, handler, objects) = RootWithTwoItemLists();
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
|
||||
Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory));
|
||||
|
||||
Assert.NotNull(handler.LastDrop);
|
||||
Assert.Same(target, handler.LastDrop!.Value.list);
|
||||
Assert.Same(target.Cell, handler.LastDrop.Value.cell);
|
||||
Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragItemOutside_raisesRootOutsideUiEvent()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
object? payload = null;
|
||||
(int x, int y) release = default;
|
||||
root.DragReleasedOutsideUi += (p, x, y) =>
|
||||
{
|
||||
payload = p;
|
||||
release = (x, y);
|
||||
};
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
|
||||
Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory));
|
||||
|
||||
var itemPayload = Assert.IsType<ItemDragPayload>(payload);
|
||||
Assert.Equal(0x5001u, itemPayload.ObjId);
|
||||
Assert.Equal((220, 100), release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssertItem_checksObjectTableState()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x5001u,
|
||||
ContainerId = 0x7001u,
|
||||
ContainerSlot = 3,
|
||||
CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
||||
});
|
||||
var probe = new RetailUiAutomationProbe(new UiRoot(), objects);
|
||||
|
||||
var ok = probe.AssertItem(
|
||||
0x5001u,
|
||||
equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
||||
containerId: 0x7001u,
|
||||
slot: 3);
|
||||
var bad = probe.AssertItem(0x5001u, slot: 4);
|
||||
|
||||
Assert.True(ok.Success);
|
||||
Assert.False(bad.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe()
|
||||
{
|
||||
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
||||
bool doubleClicked = false;
|
||||
source.Cell.DoubleClicked = () => doubleClicked = true;
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
var logs = new List<string>();
|
||||
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
||||
File.WriteAllLines(path, new[]
|
||||
{
|
||||
"wait item 0x5001 inventory 100",
|
||||
"doubleclick item 0x5001 inventory",
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add);
|
||||
|
||||
runner.Tick(0.016);
|
||||
|
||||
Assert.True(doubleClicked);
|
||||
Assert.True(runner.Completed);
|
||||
Assert.Contains(logs, line => line.Contains("UI probe script complete"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.UI.Testing;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailUiInteractionFlowTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Hauberk = 0x50001001u;
|
||||
private const uint HealthKit = 0x50001002u;
|
||||
private const uint SlotsButtonId = 0x100005BEu;
|
||||
private const uint ChestArmorSlotId = 0x100005ACu;
|
||||
private const uint HealthKitUseability = 0x000A0008u;
|
||||
|
||||
private const EquipMask HauberkMask =
|
||||
EquipMask.ChestArmor
|
||||
| EquipMask.AbdomenArmor
|
||||
| EquipMask.UpperArmArmor
|
||||
| EquipMask.LowerArmArmor;
|
||||
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly UiRoot Root = new() { Width = 800, Height = 600 };
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly ImportedLayout Layout;
|
||||
public readonly List<uint> Uses = new();
|
||||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||
public readonly List<uint> Drops = new();
|
||||
public long Now = 10_000;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var layoutRoot = new TestElement { Width = 360, Height = 180 };
|
||||
var grid = ItemList(
|
||||
InventoryController.ContentsGridId,
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 192,
|
||||
height: 96);
|
||||
var sideBags = ItemList(
|
||||
InventoryController.ContainerListId,
|
||||
left: 210,
|
||||
top: 10,
|
||||
width: 36,
|
||||
height: 96);
|
||||
var mainPack = ItemList(
|
||||
InventoryController.TopContainerId,
|
||||
left: 210,
|
||||
top: 112,
|
||||
width: 36,
|
||||
height: 36);
|
||||
var chestArmor = ItemList(
|
||||
ChestArmorSlotId,
|
||||
left: 280,
|
||||
top: 10,
|
||||
width: 32,
|
||||
height: 32);
|
||||
var slotsButton = new UiButton(
|
||||
new ElementInfo { Id = SlotsButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0))
|
||||
{
|
||||
DatElementId = SlotsButtonId,
|
||||
Left = 270,
|
||||
Top = 56,
|
||||
Width = 56,
|
||||
Height = 20,
|
||||
};
|
||||
var meter = new UiMeter
|
||||
{
|
||||
DatElementId = InventoryController.BurdenMeterId,
|
||||
Left = 248,
|
||||
Top = 10,
|
||||
Width = 11,
|
||||
Height = 58,
|
||||
};
|
||||
var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14);
|
||||
var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14);
|
||||
var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14);
|
||||
var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14);
|
||||
var scrollbar = new UiScrollbar
|
||||
{
|
||||
DatElementId = InventoryController.ContentsScrollbarId,
|
||||
Left = 192,
|
||||
Top = 10,
|
||||
Width = 16,
|
||||
Height = 96,
|
||||
};
|
||||
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[InventoryController.ContentsGridId] = grid,
|
||||
[InventoryController.ContainerListId] = sideBags,
|
||||
[InventoryController.TopContainerId] = mainPack,
|
||||
[InventoryController.BurdenMeterId] = meter,
|
||||
[InventoryController.TitleTextId] = titleText,
|
||||
[InventoryController.BurdenTextId] = burdenText,
|
||||
[InventoryController.BurdenCaptionId] = burdenCaption,
|
||||
[InventoryController.ContentsCaptionId] = contentsCaption,
|
||||
[InventoryController.ContentsScrollbarId] = scrollbar,
|
||||
[ChestArmorSlotId] = chestArmor,
|
||||
[SlotsButtonId] = slotsButton,
|
||||
};
|
||||
|
||||
layoutRoot.AddChild(grid);
|
||||
layoutRoot.AddChild(sideBags);
|
||||
layoutRoot.AddChild(mainPack);
|
||||
layoutRoot.AddChild(chestArmor);
|
||||
layoutRoot.AddChild(slotsButton);
|
||||
layoutRoot.AddChild(meter);
|
||||
layoutRoot.AddChild(titleText);
|
||||
layoutRoot.AddChild(burdenText);
|
||||
layoutRoot.AddChild(burdenCaption);
|
||||
layoutRoot.AddChild(contentsCaption);
|
||||
layoutRoot.AddChild(scrollbar);
|
||||
Layout = new ImportedLayout(layoutRoot, byId);
|
||||
Root.AddChild(layoutRoot);
|
||||
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
Type = ItemType.Creature,
|
||||
ItemsCapacity = 102,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
}
|
||||
|
||||
public ItemInteractionController BindInventoryInteraction()
|
||||
{
|
||||
var interaction = new ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: Uses.Add,
|
||||
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||
sendDrop: Drops.Add,
|
||||
nowMs: () => Now);
|
||||
|
||||
InventoryController.Bind(
|
||||
Layout,
|
||||
Objects,
|
||||
playerGuid: () => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||
strength: () => 100,
|
||||
datFont: null,
|
||||
itemInteraction: interaction);
|
||||
|
||||
Root.DragReleasedOutsideUi += (payload, _, _) =>
|
||||
{
|
||||
if (payload is ItemDragPayload itemPayload)
|
||||
interaction.DropToWorld(itemPayload);
|
||||
};
|
||||
|
||||
return interaction;
|
||||
}
|
||||
|
||||
public void BindPaperdoll()
|
||||
=> PaperdollController.Bind(
|
||||
Layout,
|
||||
Objects,
|
||||
playerGuid: () => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||
emptySlotSprite: 0x06004D20u);
|
||||
|
||||
public RetailUiAutomationProbe Probe()
|
||||
=> new(Root, Objects);
|
||||
|
||||
public void SeedHauberk(int slot = 0)
|
||||
{
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Hauberk,
|
||||
Name = "Hauberk",
|
||||
Type = ItemType.Armor,
|
||||
ValidLocations = HauberkMask,
|
||||
IconId = 0x06001234u,
|
||||
});
|
||||
Objects.MoveItem(Hauberk, Player, slot);
|
||||
}
|
||||
|
||||
public void SeedHealthKit(int slot = 0)
|
||||
{
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = HealthKit,
|
||||
Name = "Health Kit",
|
||||
Type = ItemType.Misc,
|
||||
IconId = 0x06001235u,
|
||||
Useability = HealthKitUseability,
|
||||
});
|
||||
Objects.MoveItem(HealthKit, Player, slot);
|
||||
}
|
||||
|
||||
private static UiItemList ItemList(uint id, float left, float top, float width, float height)
|
||||
=> new(static _ => (1u, 32, 32))
|
||||
{
|
||||
DatElementId = id,
|
||||
Left = left,
|
||||
Top = top,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
|
||||
private static UiElement TextHost(uint id, float left, float top, float width, float height)
|
||||
=> new TestElement
|
||||
{
|
||||
DatElementId = id,
|
||||
Left = left,
|
||||
Top = top,
|
||||
Width = width,
|
||||
Height = height,
|
||||
ClickThrough = true,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SeedHauberk();
|
||||
h.BindInventoryInteraction();
|
||||
var probe = h.Probe();
|
||||
|
||||
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
|
||||
|
||||
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
|
||||
var state = probe.AssertItem(
|
||||
Hauberk,
|
||||
equippedLocation: HauberkMask,
|
||||
containerId: Player);
|
||||
Assert.True(state.Success, state.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SeedHealthKit();
|
||||
var interaction = h.BindInventoryInteraction();
|
||||
var probe = h.Probe();
|
||||
|
||||
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
|
||||
Assert.True(interaction.IsTargetModeActive);
|
||||
Assert.True(probe.ClickElement(InventoryController.TopContainerId));
|
||||
|
||||
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
|
||||
Assert.False(interaction.IsTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SeedHauberk();
|
||||
h.BindInventoryInteraction();
|
||||
var probe = h.Probe();
|
||||
|
||||
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
|
||||
|
||||
Assert.Equal(new[] { Hauberk }, h.Drops);
|
||||
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
|
||||
Assert.True(state.Success, state.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SeedHauberk();
|
||||
h.BindInventoryInteraction();
|
||||
h.BindPaperdoll();
|
||||
var probe = h.Probe();
|
||||
|
||||
Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view.
|
||||
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
|
||||
|
||||
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
|
||||
var state = probe.AssertItem(
|
||||
Hauberk,
|
||||
equippedLocation: HauberkMask,
|
||||
containerId: Player);
|
||||
Assert.True(state.Success, state.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ public class UiItemListGridTests
|
|||
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellOffset_ColumnMajor_UsesLogicalRows()
|
||||
{
|
||||
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||
Assert.Equal((0f, 36f), UiItemList.CellOffset(1, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||
Assert.Equal((36f, 0f), UiItemList.CellOffset(3, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridMode_PositionsCellsInColumns()
|
||||
{
|
||||
|
|
@ -26,6 +34,25 @@ public class UiItemListGridTests
|
|||
Assert.Equal(36f, c4.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridMode_ColumnMajor_PositionsCellsDownRowsFirst()
|
||||
{
|
||||
var list = new UiItemList
|
||||
{
|
||||
Columns = 3,
|
||||
Flow = UiItemListFlow.ColumnMajor,
|
||||
CellWidth = 36,
|
||||
CellHeight = 36,
|
||||
};
|
||||
list.Flush();
|
||||
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
var c3 = list.GetItem(3)!;
|
||||
Assert.Equal(36f, c3.Left);
|
||||
Assert.Equal(0f, c3.Top);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void FillMode_SizesSingleCellToList()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ public class UiItemSlotTests
|
|||
Assert.Equal(0u, s.IconTexture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClick_invokesDoubleClicked()
|
||||
{
|
||||
var s = new UiItemSlot();
|
||||
bool fired = false;
|
||||
s.DoubleClicked = () => fired = true;
|
||||
|
||||
s.OnEvent(new UiEvent(0u, s, UiEventType.DoubleClick));
|
||||
|
||||
Assert.True(fired);
|
||||
}
|
||||
|
||||
// ── Shortcut number tests ────────────────────────────────────────────────
|
||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ public class UiRootInputTests
|
|||
{
|
||||
private readonly bool _handlesClick;
|
||||
public bool Clicked;
|
||||
public int Clicks;
|
||||
public int DoubleClicks;
|
||||
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
|
||||
public override bool HandlesClick => _handlesClick;
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
|
||||
if (e.Type == UiEventType.Click) { Clicked = true; Clicks++; return true; }
|
||||
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +48,51 @@ public class UiRootInputTests
|
|||
Assert.True(btn.Clicked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClick_EmitsRealDoubleClickEvent()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
||||
root.AddChild(btn);
|
||||
|
||||
root.Tick(0, nowMs: 1_000);
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
||||
root.Tick(0, nowMs: 1_300);
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
||||
|
||||
Assert.Equal(2, btn.Clicks);
|
||||
Assert.Equal(1, btn.DoubleClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
cell.SetItem(0x50000A01u, 0x99u);
|
||||
root.AddChild(cell);
|
||||
object? payload = null;
|
||||
int releaseX = 0;
|
||||
int releaseY = 0;
|
||||
root.DragReleasedOutsideUi += (p, x, y) =>
|
||||
{
|
||||
payload = p;
|
||||
releaseX = x;
|
||||
releaseY = y;
|
||||
};
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 5, 5);
|
||||
root.OnMouseMove(20, 20);
|
||||
root.OnMouseUp(UiMouseButton.Left, 200, 200);
|
||||
|
||||
var drag = Assert.IsType<ItemDragPayload>(payload);
|
||||
Assert.Equal(0x50000A01u, drag.ObjId);
|
||||
Assert.Equal(200, releaseX);
|
||||
Assert.Equal(200, releaseY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
|
||||
{
|
||||
|
|
@ -327,14 +375,18 @@ public class UiRootInputTests
|
|||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.False(root.IsWindowVisible("inventory"));
|
||||
Assert.True(root.ShowWindow("inventory"));
|
||||
Assert.True(win.Visible);
|
||||
Assert.True(root.IsWindowVisible("inventory"));
|
||||
Assert.True(root.HideWindow("inventory"));
|
||||
Assert.False(win.Visible);
|
||||
Assert.False(root.IsWindowVisible("inventory"));
|
||||
|
||||
Assert.False(root.ShowWindow("nope"));
|
||||
Assert.False(root.HideWindow("nope"));
|
||||
Assert.False(root.ToggleWindow("nope"));
|
||||
Assert.False(root.IsWindowVisible("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
39
tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs
Normal file
39
tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiScrollablePanelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LayoutScrollableChildren_ClipsRowsOutsideViewport()
|
||||
{
|
||||
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
|
||||
for (int i = 0; i < 4; i++)
|
||||
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
|
||||
|
||||
panel.LayoutScrollableChildren();
|
||||
Assert.True(panel.Children[0].Visible);
|
||||
Assert.True(panel.Children[1].Visible);
|
||||
Assert.False(panel.Children[2].Visible);
|
||||
|
||||
panel.Scroll.SetScrollY(20);
|
||||
panel.LayoutScrollableChildren();
|
||||
Assert.False(panel.Children[0].Visible);
|
||||
Assert.True(panel.Children[1].Visible);
|
||||
Assert.True(panel.Children[2].Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollEvent_MovesByLineHeight()
|
||||
{
|
||||
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
|
||||
for (int i = 0; i < 4; i++)
|
||||
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
|
||||
panel.LayoutScrollableChildren();
|
||||
|
||||
var e = new UiEvent(0u, panel, UiEventType.Scroll, Data0: -1);
|
||||
Assert.True(panel.OnEvent(in e));
|
||||
|
||||
Assert.Equal(20, panel.Scroll.ScrollY);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue