fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -35,8 +35,7 @@ public sealed class AutoWieldGenerationTests
objects,
() => Player,
sendWield: null,
sendPutItemInContainer: (_, _, _) => { },
toast: null);
sendPutItemInContainer: (_, _, _) => { });
Assert.True(controller.TryWield(requested));
Assert.True(controller.IsBusy);

View file

@ -182,6 +182,38 @@ public class DragDropSpineTests
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
}
[Fact]
public void RootOwnedDrag_survivesProceduralSourceCellReplacement_untilRelease()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
object? released = null;
root.DragReleasedOutsideUi += (payload, _, _) => released = payload;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // BeginDrag → root owns ghost
object payload = Assert.IsType<ItemDragPayload>(root.DragPayload);
// InventoryController.Populate/ExternalContainerController.Populate use
// UiItemList.Flush when an unrelated authoritative item update arrives.
// The old source cell is replaced while the physical button is still down.
list.Flush();
Assert.Null(cell.Parent);
Assert.Same(cell, root.DragSource);
Assert.Same(payload, root.DragPayload);
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
Assert.Same(root, root.Captured); // retail drag element owns capture
root.OnMouseMove(600, 500);
root.OnMouseUp(UiMouseButton.Left, 600, 500);
Assert.Same(payload, released);
Assert.Null(root.DragSource);
Assert.Null(root.DragPayload);
Assert.Null(root.DragGhostForTest);
Assert.Null(root.Captured);
}
[Fact]
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
{

View file

@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<(uint Source, uint Target, uint Amount)> Merges = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
public readonly List<uint> Drops = new();
@ -130,7 +131,9 @@ public sealed class ItemInteractionControllerTests
Sells.Add((vendorGuid, items));
return true;
},
interfaceText: (text, type) => InterfaceTexts.Add((text, type)));
interfaceText: (text, type) => InterfaceTexts.Add((text, type)),
sendStackableMerge: (source, target, amount) =>
Merges.Add((source, target, amount)));
}
public ItemInteractionController Controller { get; }
@ -589,7 +592,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndWaitsForServer()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
@ -602,12 +605,12 @@ public sealed class ItemInteractionControllerTests
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);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndWaitsForServer()
{
var h = new Harness();
const EquipMask coatMask =
@ -624,8 +627,8 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
@ -654,7 +657,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
@ -692,12 +695,13 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
public void EquippableItemWithNoFreeSlot_movesBlockerThenWieldsAfterServerConfirm()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Name = "Old Shield",
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
@ -712,9 +716,17 @@ public sealed class ItemInteractionControllerTests
bool activated = h.Controller.ActivateItem(0x50000A06u);
Assert.True(activated);
Assert.Empty(h.Wields);
Assert.False(activated);
Assert.Equal(new[] { (0x50000AF0u, Player, 0) }, h.Puts);
Assert.Equal(new[] { "Moving Old Shield to your backpack" }, h.SystemMessages);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerMove(0x50000AF0u, Player, 0u, 0));
Assert.Equal(
new[] { (0x50000A06u, (uint)EquipMask.Shield) },
h.Wields);
}
[Theory]
@ -752,14 +764,18 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
// Authoritative 0x0022: only now does retail retry AutoWield.
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Equal(EquipMask.None,
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
Assert.Equal(EquipMask.MissileWeapon,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
}
[Theory]
@ -797,7 +813,7 @@ public sealed class ItemInteractionControllerTests
// wand away. The transaction retains the initial active-combat intent
// rather than consulting this intermediate state on its second pass.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Empty(h.CombatModeRequests);
@ -860,7 +876,7 @@ public sealed class ItemInteractionControllerTests
// replacement wield.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields);
Assert.True(h.Objects.ApplyConfirmedServerWield(
@ -899,7 +915,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(wand));
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
wand,
Player,
@ -935,7 +951,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
@ -1027,7 +1043,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Single(h.Wields);
// Bow is optimistic but ACE has not sent WieldObject yet. Retail's
@ -1121,13 +1137,13 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(bow));
Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (shield, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(shield, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(shield, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1164,12 +1180,12 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (arrows, Player, 0) },
h.Puts);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1213,7 +1229,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
Assert.Equal(EquipMask.MissileAmmo,
@ -1247,7 +1263,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
}
@ -1292,14 +1308,14 @@ public sealed class ItemInteractionControllerTests
crossbow, EquipMask.MissileWeapon));
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(
new[] { (bow, Player, 0), (arrows, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(
new[] { (crossbow, (uint)EquipMask.MissileWeapon) },
@ -1333,7 +1349,11 @@ public sealed class ItemInteractionControllerTests
new[] { (ring, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
ring, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
}
@ -1366,14 +1386,61 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(rightRing, Player, 0u, 0));
Assert.Equal(
new[] { (leftRing, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
leftRing, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
}
[Fact]
public void ActivateItem_whenEveryCompatibleSlotIsOccupied_movesRetailPreferredBlockerThenWields()
{
var h = new Harness();
const uint leftRing = 0x50000B74u;
const uint rightRing = 0x50000B75u;
const uint requestedRing = 0x50000B76u;
EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = leftRing,
Name = "Left Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = rightRing,
Name = "Right Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight);
h.AddContained(requestedRing, item =>
{
item.Name = "New Ring";
item.Type = ItemType.Jewelry;
item.ValidLocations = valid;
});
Assert.True(h.Controller.ActivateItem(requestedRing));
Assert.Equal(new[] { (leftRing, Player, 0) }, h.Puts);
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Left Ring to your backpack" }, h.SystemMessages);
Assert.True(h.Objects.ApplyConfirmedServerMove(leftRing, Player, 0u, 0));
Assert.Equal(
new[] { (requestedRing, (uint)EquipMask.FingerWearLeft) },
h.Wields);
}
[Fact]
@ -1400,7 +1467,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
public void InventoryDragOutsideUi_sendsDropAndWaitsForServerPlacement()
{
var h = new Harness();
h.AddContained(0x50000A07u);
@ -1413,7 +1480,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(0x50000A07u)!.ContainerId);
}
/// <summary>
@ -1517,7 +1584,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Gives);
Assert.Equal(new[] { item }, h.Drops);
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Theory]
@ -1898,6 +1965,126 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void KeyboardPickup_AutoMergesWholeStackBeforeContainerPlacement()
{
var h = new Harness();
const uint source = 0x70000B01u;
const uint target = 0x50000B02u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 77u,
Name = "World stack",
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(target, item =>
{
item.WeenieClassId = 77u;
item.StackSize = 5;
item.StackSizeMax = 10;
});
var attempts = new List<(uint Source, uint Target)>();
h.Controller.MergeAttempted += (from, into) => attempts.Add((from, into));
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 3u) }, h.Merges);
Assert.Equal(new[] { (source, target) }, attempts);
Assert.Empty(h.BackpackPlacements);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.Merge, pending.Kind);
Assert.True(pending.Dispatched);
}
[Fact]
public void KeyboardPickup_UsesSelectedSplitQuantityAndSearchesNestedPacks()
{
var h = new Harness();
const uint nestedPack = 0x50000B10u;
const uint source = 0x70000B11u;
const uint target = 0x50000B12u;
h.AddContained(nestedPack, item => item.Type = ItemType.Container);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 88u,
StackSize = 10,
StackSizeMax = 10,
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = target,
WeenieClassId = 88u,
StackSize = 8,
StackSizeMax = 10,
});
h.Objects.MoveItem(target, nestedPack, 0);
h.SelectedObject = source;
h.SplitQuantity.Reset(10u, 2u);
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 2u) }, h.Merges);
Assert.Empty(h.BackpackPlacements);
}
[Fact]
public void KeyboardPickup_SkipsPartialMergeTargetAndFallsBackToPlacement()
{
var h = new Harness();
const uint source = 0x70000B20u;
const uint partialTarget = 0x50000B21u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 99u,
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(partialTarget, item =>
{
item.WeenieClassId = 99u;
item.StackSize = 9;
item.StackSizeMax = 10;
});
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Empty(h.Merges);
Assert.Equal(new[] { (source, Player, 0) }, h.BackpackPlacements);
}
[Fact]
public void TrySplitToContainerDispatchesTheExactSelectedQuantity()
{
var h = new Harness();
const uint source = 0x50000A30u;
h.AddContained(source, item => item.StackSize = 10);
Assert.True(h.Controller.TrySplitToContainer(source, Pack, 3u, 2u));
Assert.Equal(new[] { (source, Pack, 3u, 2u) }, h.SplitPuts);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.SplitToContainer, pending.Kind);
Assert.Equal(source, pending.ItemId);
}
[Fact]
public void TrySplitToContainerRejectsZeroAndWholeStackAmounts()
{
var h = new Harness();
const uint source = 0x50000A31u;
h.AddContained(source, item => item.StackSize = 10);
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 0u));
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 10u));
Assert.Empty(h.SplitPuts);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void MatchingInventoryFailureReleasesGlobalRequest()
{

View file

@ -1156,6 +1156,28 @@ public class CharacterStatControllerTests
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
}
[Fact]
public void ProgrammaticShowTab_UsesTheSameAuthoredStateAsAKeyboardAction()
{
ImportedLayout layout = FixtureLoader.LoadCharacter();
var attributes = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabAttribId));
var skills = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabSkillsId));
CharacterStatController.Binding binding = CharacterStatController.Bind(
layout,
SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
binding.ShowTab(CharacterStatController.CharacterStatTab.Skills);
Assert.Equal(
CharacterStatController.CharacterStatTab.Skills,
binding.CurrentTab());
Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId);
}
/// <summary>
/// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted
/// page and only rebind its content), the Titles page (0x10000539) is a
@ -1267,7 +1289,7 @@ public class CharacterStatControllerTests
CharacterSheet sheet = SampleData.SampleCharacter();
Action refresh = CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16));
spriteResolve: id => (id, 16, 16)).Refresh;
ClickTab(layout, left: 92f);
var untrained = sheet.Skills.First(

View file

@ -225,6 +225,47 @@ public sealed class ChatTranscriptRunsTests
Assert.Equal(detailed[^1].Text, lines[^1].Text);
}
[Fact]
public void OversizedMultilineServerMessageKeepsItsNewestCompleteLines()
{
string response = string.Join(
'\n',
Enumerable.Range(0, 1_500).Select(i => $"@command-{i:D4}"));
var detailed = new List<FormattedLine> { Plain(response) };
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed,
maxW: 100_000f,
Measure,
accept: null,
defaultColor: LineColor);
Assert.NotEmpty(lines);
Assert.Equal("@command-1499", lines[^1].Text);
Assert.DoesNotContain(lines, line => line.Text == "@command-0000");
Assert.All(lines, line => Assert.False(string.IsNullOrWhiteSpace(line.Text)));
}
[Fact]
public void MultilineServerMessageWithinBudgetRendersEveryAuthoredLine()
{
var detailed = new List<FormattedLine>
{
Plain("@acecommands\n@help\n@teleport"),
};
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed,
maxW: 100_000f,
Measure,
accept: null,
defaultColor: LineColor);
Assert.Equal(
new[] { "@acecommands", "@help", "@teleport" },
lines.Select(line => line.Text));
}
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);

View file

@ -494,6 +494,28 @@ public class InventoryControllerTests
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
}
[Fact]
public void DoubleClickOwnedBag_opensOnceOnFirstPress_andNeverRunsGenericUse()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xCu, slot: 0);
var uses = new List<uint>();
Bind(layout, objects, uses: uses);
UiItemSlot bag = containers.GetItem(0)!;
bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.DoubleClick));
Assert.Equal(new[] { 0xCu }, uses);
Assert.Null(bag.DoubleClicked);
Assert.True(containers.GetItem(0)!.IsOpenContainer);
Assert.Equal(0xCu, objects.Get(0xCu)!.ObjectId);
}
[Fact]
public void MouseDownGridItem_movesSquareImmediately_noWire_keepsOpenContainer()
{
@ -684,7 +706,7 @@ public class InventoryControllerTests
Workmanship: null);
[Fact]
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
public void Drop_onOccupiedGridCell_insertsBefore_andWaitsForServer()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -699,7 +721,7 @@ public class InventoryControllerTests
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId);
}
[Fact]
@ -1316,11 +1338,12 @@ public class InventoryControllerTests
StackSizeMax = 100,
});
objects.MoveItem(0xAu, Player, 0);
SeedBag(objects, 0xCu, slot: 1);
var selection = new SelectionState();
selection.Select(0xAu, SelectionChangeSource.Inventory);
var splitQuantity = new StackSplitQuantityState();
splitQuantity.Reset(10u);
splitQuantity.SetValue(1u);
splitQuantity.SetValue(2u);
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
var puts = new List<(uint item, uint container, int placement)>();
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
@ -1328,7 +1351,9 @@ public class InventoryControllerTests
ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu));
Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits);
// Placement counts the main pack's visible loose-item list only;
// side bags occupy the separate selector list and must not shift it.
Assert.Equal(new[] { (0xAu, Player, 1u, 2u) }, splits);
Assert.Empty(puts);
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot);
@ -1464,7 +1489,7 @@ public class InventoryControllerTests
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId);
}
[Fact]
@ -1483,6 +1508,34 @@ public class InventoryControllerTests
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
}
[Fact]
public void MainPackFullness_countsLooseItems_notSideBags_afterAFreeSlotAppears()
{
var (layout, _, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
ItemsCapacity = 2,
});
SeedContained(objects, 0xA0u, Player, slot: 0);
SeedContained(objects, 0xA1u, Player, slot: 1);
SeedBag(objects, 0xC0u, slot: 0);
SeedContained(objects, 0xB0u, 0xC0u, slot: 0);
var controller = (IItemListDragHandler)Bind(layout, objects);
UiItemSlot mainPack = top.GetItem(0)!;
Assert.Equal(ItemDragAcceptance.Reject,
controller.OnDragOver(top, mainPack, Payload(0xB0u)));
Assert.True(objects.Remove(0xA1u));
Assert.Equal(ItemDragAcceptance.Accept,
controller.OnDragOver(top, top.GetItem(0)!, Payload(0xB0u)));
Assert.Equal(0.5f, top.GetItem(0)!.CapacityFill);
}
[Fact]
public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot()
{
@ -1617,7 +1670,7 @@ public class InventoryControllerTests
}
[Fact]
public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back
public void Drop_thenServerReject_keepsCanonicalPlacement()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -1626,11 +1679,10 @@ public class InventoryControllerTests
var ctrl = Bind(layout, objects);
((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu));
Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant)
objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed)
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.False(objects.RejectMove(0xAu, 0x426u));
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot);
}
// Reads the text of the UiText caption child attached by the controller.

View file

@ -20,9 +20,8 @@ namespace AcDream.App.Tests.UI.Layout;
///
/// <para>
/// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now
/// carries <see cref="Binding"/> (chord + activation + scope), the M2 fix means
/// only ONE camera InputMap context maps live, and conflicts open a real confirm
/// dialog instead of auto-reassigning silently.
/// carries <see cref="Binding"/> (chord + activation + scope). Campaign KB gives
/// both camera contexts distinct live identities and removes the store-only tier.
/// </para>
/// </summary>
public sealed class KeyboardConfigControllerTests
@ -55,7 +54,16 @@ public sealed class KeyboardConfigControllerTests
uint inputMapId, uint actionId, RetailActionClass cls,
uint labelHash = 0, uint tooltipHash = 0,
params RetailKeyChord[] defaults) =>
new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults);
new(
inputMapId,
actionId,
cls,
labelHash == 0 ? 0xDE000000u | (actionId & 0x00FFFFFFu) : labelHash,
tooltipHash,
defaults);
private static string? ResolveSyntheticString(uint _, uint hash) =>
(hash & 0xFF000000u) == 0xDE000000u ? $"Action {hash & 0x00FFFFFFu:X}" : null;
private sealed class FakeBindings
{
@ -72,6 +80,9 @@ public sealed class KeyboardConfigControllerTests
public List<uint> InstructionCloses { get; } = new();
public uint NextInstructionContext { get; set; } = 7u;
public bool WireInstructions { get; set; }
public string CurrentKeymapFilename { get; set; } = "acdream.keymap";
public Action? PendingLoadCompleted { get; private set; }
public Action? PendingSaveCompleted { get; private set; }
public void Capture(KeyChord? chord)
{
@ -103,8 +114,26 @@ public sealed class KeyboardConfigControllerTests
BeginCapture: cb => PendingCapture = cb,
Save: () => SaveCalls++,
Toggle: () => ToggleCalls++,
DisplaySystemMessage: msg => Messages.Add(msg),
NonBindableRefusalText: "cannot overwrite",
ResolveTemplate: (key, variables) => key switch
{
"ID_ActionKeyMap_ButtonLabel" => variables[DatStringResolver.ComputeHash("LABEL")],
"ID_ActionKeyMap_TT_ExistingBinding" =>
$"({variables[DatStringResolver.ComputeHash("VALUE")]}) existing binding",
"ID_ActionKeyMap_TT_NewBinding" => "new binding",
"ID_ActionKeyMap_NonUserBindableBinding" =>
$"cannot overwrite {variables[DatStringResolver.ComputeHash("KEY")]}",
"ID_ActionKeyMap_OverwriteExistingBinding" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]} "
+ variables[DatStringResolver.ComputeHash("ACTION")],
"ID_ActionKeyMap_Binding" =>
$"{variables[DatStringResolver.ComputeHash("ACTION")]} "
+ $"({variables[DatStringResolver.ComputeHash("KEY")]})",
"ID_ActionKeyMap_OverwriteExistingBindings" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]}\n"
+ variables[DatStringResolver.ComputeHash("BINDINGS")],
_ => null,
},
ShowMessage: msg => Messages.Add(msg),
ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult),
OpenCaptureInstructions: WireInstructions
? label =>
@ -114,11 +143,23 @@ public sealed class KeyboardConfigControllerTests
}
: null,
CloseCaptureInstructions: context => InstructionCloses.Add(context));
public KeyboardConfigController.Bindings ToProfileBindings() =>
ToBindings() with
{
CurrentKeymapFilename = () => this.CurrentKeymapFilename,
OpenLoadKeymap = completed => PendingLoadCompleted = completed,
OpenSaveKeymap = completed => PendingSaveCompleted = completed,
};
}
private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None);
private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None);
private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None);
private static readonly KeyChord LeftMouse = new(
InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left),
ModifierMask.None,
Device: 1);
[Fact]
public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow()
@ -133,7 +174,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings());
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings());
Assert.NotNull(controller);
Assert.Equal(3, controller!.Rows.Count);
@ -158,7 +199,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
Assert.NotNull(controller);
UiTabPanel tabHost = Assert.IsType<UiTabPanel>(layout.FindElement(0x1000049Bu));
@ -188,28 +229,28 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped()
public void Bind_MapsEveryRetailActionRow()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(InputAction.MovementForward, forward.MappedAction);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
}
[Fact]
public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped()
public void Bind_SeedsEveryRowFromLiveBindings()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
@ -223,11 +264,14 @@ public sealed class KeyboardConfigControllerTests
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA };
fake.Mapped[InputAction.EmoteBowDeep] = new List<Binding>
{
new(ChordA, InputAction.EmoteBowDeep),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current);
@ -243,7 +287,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.NotEmpty(row.KeyButtons);
@ -257,8 +301,8 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(InputAction.MovementForward, written.Action);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
// No live binding existed at build time — falls back to the Binding
// record's own defaults (Press/Game), same as before M1.
// No live binding existed at build time — falls back to the retail
// action identity's activation/scope metadata.
Assert.Equal(ActivationType.Press, onlyBinding.Activation);
Assert.Equal(InputScope.Game, onlyBinding.Scope);
Assert.Equal("W", row.KeyButtons[0].Label);
@ -272,7 +316,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -293,7 +337,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -314,7 +358,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -331,7 +375,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -354,7 +398,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var requests = new List<(uint LayoutId, uint ElementId)>();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(),
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings(),
resolveTemplateFont: (layoutId, elementId) =>
{
requests.Add((layoutId, elementId));
@ -382,7 +426,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(2, row.Model.Current.Count);
@ -400,7 +444,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Empty(row.Model.Current);
@ -411,17 +455,17 @@ public sealed class KeyboardConfigControllerTests
Assert.Empty(fake.MappedSets);
}
/// <summary>S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a
/// row with NO existing bindings must land the captured chord on display
/// index 2, not collapse it onto index 0.</summary>
/// <summary>Retail SetBinding clamps a requested slot past the dense
/// current-list tail to Count. Mapping 3 on an empty row therefore appends
/// at Mapping 1.</summary>
[Fact]
public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton()
public void KeyButtonClick_PastDenseTail_AppendsAtFirstAvailableButton()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(3, row.KeyButtons.Count);
@ -430,12 +474,10 @@ public sealed class KeyboardConfigControllerTests
row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3"
fake.Capture(ChordW);
Assert.Null(row.KeyButtons[0].Label);
Assert.Equal("W", row.KeyButtons[0].Label);
Assert.Null(row.KeyButtons[1].Label);
Assert.Equal("W", row.KeyButtons[2].Label);
Assert.Null(row.KeyButtons[2].Label);
// The write to the live seam only ever carries the REAL chord — no
// default(KeyChord) padding leaks into the persisted Binding list.
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
@ -453,7 +495,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -463,6 +505,7 @@ public sealed class KeyboardConfigControllerTests
// M3: nothing is applied yet — a confirm dialog is pending.
Assert.NotNull(fake.PendingConfirm);
Assert.Equal("overwrite A Action 2A", fake.PendingConfirm?.Message);
Assert.DoesNotContain(ChordA, forward.Model.Current);
Assert.Contains(ChordA, backup.Model.Current);
Assert.Empty(fake.Messages);
@ -473,6 +516,153 @@ public sealed class KeyboardConfigControllerTests
Assert.DoesNotContain(ChordA, backup.Model.Current);
}
[Fact]
public void Capture_BareShiftConflictWithRetailWalkMode_AlwaysPrompts()
{
var shift = new KeyChord(
Silk.NET.Input.Key.ShiftLeft,
ModifierMask.None);
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // Move forward
Row(0x4, 0x32, RetailActionClass.Movement), // Toggle walk/run
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementWalkMode] =
[new Binding(
shift,
InputAction.MovementWalkMode,
ActivationType.Hold,
InputScope.Game)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout,
snapshot,
MakeTemplateResolver(),
ResolveSyntheticString,
fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(
row => row.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(shift);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(shift, forward.Model.Current);
Assert.Equal(
[shift],
controller.Rows.Single(row => row.ActionId == 0x32u).Model.Current);
}
[Fact]
public void Capture_ConflictWithMultipleRows_UsesRetailPluralBindingList()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
Row(0x4, 0x2B, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementBackup] =
new List<Binding> { new(ChordA, InputAction.MovementBackup) };
fake.Mapped[InputAction.MovementStop] =
new List<Binding> { new(ChordA, InputAction.MovementStop) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
controller.Rows.Single(row => row.ActionId == 0x29u).KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Equal(
"overwrite A\nAction 2A (A)\nAction 2B (A)",
fake.PendingConfirm?.Message);
}
[Fact]
public void Refresh_UsesRetailExistingAndNewBindingTooltipTemplates()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
Assert.Equal("(W) existing binding", row.KeyButtons[0].TooltipText);
Assert.Equal("new binding", row.KeyButtons[1].TooltipText);
Assert.Equal("new binding", row.KeyButtons[2].TooltipText);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Equal("new binding", row.KeyButtons[0].TooltipText);
}
[Fact]
public void Capture_ChordAlreadyInAnotherSlotOfSameRow_IsRetailNoOp()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
// Prove retail's same-row early return happens before the
// non-user-bindable conflict check too.
fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List<Binding>
{
new(ChordW, InputAction.AcdreamToggleAudioMute),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[2].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.Equal(new[] { ChordW, ChordUp }, row.Model.Current);
Assert.Null(fake.PendingConfirm);
Assert.Empty(fake.Messages);
Assert.Empty(fake.MappedSets);
}
[Fact]
public void Capture_LeftOrRightMouseButton_RemainsArmedUntilSupportedInput()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings { WireInstructions = true };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(LeftMouse);
Assert.NotNull(fake.PendingCapture);
Assert.Empty(fake.InstructionCloses);
Assert.Empty(row.Model.Current);
fake.Capture(ChordW);
Assert.Null(fake.PendingCapture);
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.Equal(new[] { 7u }, fake.InstructionCloses);
}
[Fact]
public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged()
{
@ -486,7 +676,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -500,7 +690,71 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog()
public void Capture_SharedChordAcrossNonConflictingCombatContexts_KeepsBothBindings()
{
const uint meleeMap = 0x10000003u;
const uint missileMap = 0x10000004u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[meleeMap] = new HashSet<uint> { meleeMap },
[missileMap] = new HashSet<uint> { missileMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(meleeMap, 0x1000005Du, RetailActionClass.Combat),
Row(missileMap, 0x100000F1u, RetailActionClass.Combat),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.CombatAimLow] =
new List<Binding> { new(ChordA, InputAction.CombatAimLow, Scope: InputScope.MissileCombat) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView melee = controller.Rows.Single(
row => row.InputMapId == meleeMap);
KeyboardConfigController.RowView missile = controller.Rows.Single(
row => row.InputMapId == missileMap);
melee.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Null(fake.PendingConfirm);
Assert.Contains(ChordA, melee.Model.Current);
Assert.Contains(ChordA, missile.Model.Current);
}
[Fact]
public void Capture_SharedChordAcrossDatConflictingContexts_StillPrompts()
{
const uint movementMap = 0x4u;
const uint uiMap = 0x10000009u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[movementMap] = new HashSet<uint> { movementMap, uiMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(movementMap, 0x29u, RetailActionClass.Movement),
Row(uiMap, 0x10000019u, RetailActionClass.Ui),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.ToggleInventoryPanel] =
new List<Binding> { new(ChordA, InputAction.ToggleInventoryPanel) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView movement = controller.Rows.Single(
row => row.InputMapId == movementMap);
movement.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(ChordA, movement.Model.Current);
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_ShowsRetailMessageDialog()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
@ -510,16 +764,17 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(muteChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single();
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(muteChord);
// S1: refused outright, no confirm dialog offered.
// S1: refused outright, with the distinct retail message dialog and no
// overwrite-confirmation dialog.
Assert.Null(fake.PendingConfirm);
Assert.DoesNotContain(muteChord, forward.Model.Current);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord);
}
@ -540,14 +795,14 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(sharedChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(sharedChord);
Assert.Null(fake.PendingConfirm);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.DoesNotContain(sharedChord, forward.Model.Current);
}
@ -558,7 +813,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -573,6 +828,102 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(1, fake.ToggleCalls);
}
[Fact]
public void LoadFile_ReplacesRowsAndRevertBaseline_AndRefreshesFilename()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordW, InputAction.MovementForward)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings())!;
((UiButton)layout.FindElement(0x10000027u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingLoadCompleted);
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordUp, InputAction.MovementForward)];
fake.CurrentKeymapFilename = "friends.keymap";
fake.PendingLoadCompleted!();
ActionKeyMapOptionRow row = controller.Rows.Single().Model;
Assert.Equal(new[] { ChordUp }, row.Current);
Assert.Equal(new[] { ChordUp }, row.Saved);
Assert.False(row.Changed);
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
Assert.Equal("friends.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void SaveAs_RefreshesActiveFilenameOnlyAfterSuccessfulCallback()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
_ = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings());
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
((UiButton)layout.FindElement(0x10000029u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingSaveCompleted);
Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text);
fake.CurrentKeymapFilename = "alternate.keymap";
fake.PendingSaveCompleted!();
Assert.Equal("alternate.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void RevertButton_IsEnabledExactlyWhileWorkingMapDiffersFromSavedMap()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton revert = (UiButton)layout.FindElement(0x1000002Bu)!;
// gmKeyboardUI::OnOptionChanged @ 0x004DA890: Ghosted while clean.
Assert.False(revert.Enabled);
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp);
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
revert.OnClick!.Invoke();
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
// Defaults is also a live uncommitted edit when the DAT default does
// not equal the saved user map, and therefore re-enables Revert.
UiButton defaults = (UiButton)layout.FindElement(0x1000002Au)!;
defaults.OnClick!.Invoke();
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!;
ok.OnClick!.Invoke();
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
}
[Fact]
public void CancelButton_RevertsUncommittedEditAndToggles()
{
@ -581,7 +932,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -607,7 +958,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordUp, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(new[] { ChordUp }, row.Model.Current);
@ -639,7 +990,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
@ -665,7 +1016,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -695,20 +1046,24 @@ public sealed class KeyboardConfigControllerTests
Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.CameraRotateLeft] =
[new Binding(ChordA, InputAction.CameraRotateLeft)];
fake.Mapped[InputAction.CameraAlternateRotateLeft] =
[new Binding(
ChordUp,
InputAction.CameraAlternateRotateLeft,
Scope: InputScope.Camera)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u);
KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u);
Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction);
Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2)
Assert.Equal(InputAction.CameraAlternateRotateLeft, ctx6.MappedAction);
// Round-2 SHOULD-FIX: an unmapped row with no persisted chords now
// DISPLAYS its DAT defaults (retail shows the arrow keys; blank read
// as "unbound"). Display-only — storage stays untouched until the
// user edits THIS row.
// Both rows display their independent live bindings.
Assert.NotEmpty(ctx6.Model.Current);
var ctx6InitialDisplay = ctx6.Model.Current.ToArray();
@ -717,13 +1072,15 @@ public sealed class KeyboardConfigControllerTests
fake.Capture(ChordW);
Assert.Contains(ChordW, ctx5.Model.Current);
Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit
Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing
Assert.Empty(fake.Unmapped);
ctx6.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Contains(ChordA, ctx6.Model.Current);
Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit
Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u)));
Assert.Contains(fake.MappedSets, write =>
write.Action == InputAction.CameraAlternateRotateLeft
&& write.Value.Any(binding => binding.Chord == ChordA));
}
[Fact]
@ -756,30 +1113,26 @@ public sealed class KeyboardConfigControllerTests
+ $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber.");
seen[action] = (mapId, actionId);
}
Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen");
Assert.Equal(306, seen.Count);
}
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
[Fact]
public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite()
public void EveryRetailRowCaptionIsEnabled()
{
// AP-203's store-only set: a row whose RetailActionIdentityTable
// lookup fails (MappedAction null -- mostly Emotes/CharacterSettings)
// never reaches the InputDispatcher, so its caption dims. Wiring a
// future mapping for "Bow Deep" (or any other unmapped row) means
// this assertion flips from StoreOnlyCaptionColor to Vector4.One --
// a conscious edit, not a silent pass.
// Campaign KB removes AP-203's store-only set. Every DAT row now has
// a live dispatcher identity and uses the enabled retail caption color.
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped)
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.NotNull(forward.MappedAction);
@ -787,9 +1140,9 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(Vector4.One, forwardCaption.DefaultColor);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
UiText bowDeepCaption = RowCaption(bowDeep);
Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor);
Assert.Equal(Vector4.One, bowDeepCaption.DefaultColor);
}
/// <summary>The row's synthesized caption (composed beside the authored key

View file

@ -0,0 +1,184 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Content;
using AcDream.Core.Input;
using AcDream.Content;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Production-shaped installed-DAT gate for #446. The Core conformance lane
/// proves the 306 identities/defaults; this gate proves the actual retained
/// screen can import its authored layout and row template and expose every one
/// of those identities as a live three-slot row.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class KeyboardConfigInstalledDatConformanceTests
{
[Fact]
public void InstalledEorLayout_MountsEveryBindableActionAsALiveRow()
{
string? datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new AcDream.App.Tests.BoundedTestDatCollection(datDir);
var strings = new DatStringResolver(dats);
ElementInfo? info = LayoutImporter.ImportInfos(
dats,
KeyboardConfigController.LayoutId);
Assert.NotNull(info);
ImportedLayout layout = LayoutImporter.Build(
info!,
_ => (0u, 0, 0),
datFont: null,
fontResolve: null,
strings.Resolve);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(
(IDatObjectSource)dats);
Assert.NotNull(snapshot);
KeyBindings live = KeyBindings.RetailDefaults();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout,
snapshot!,
templateResolver: (templateLayoutId, templateElementId) =>
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
templateLayoutId,
templateElementId);
return template is null
? null
: LayoutImporter.Build(
template,
_ => (0u, 0, 0),
datFont: null,
fontResolve: null,
strings.Resolve,
templateLayoutId).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new KeyboardConfigController.Bindings(
CurrentForAction: action => live.ForAction(action).ToArray(),
SetForAction: (_, _) => { },
CurrentForUnmapped: _ => Array.Empty<KeyChord>(),
SetForUnmapped: (_, _) => { },
BeginCapture: _ => { },
Save: () => { },
Toggle: () => { },
ResolveTemplate: (key, variables) =>
strings.ResolveTemplate(0x23000004u, key, variables),
ShowMessage: _ => { },
ConfirmOverwrite: (_, _) => { },
CurrentKeymapFilename: () => "acdream.keymap",
OpenLoadKeymap: completed => completed(),
OpenSaveKeymap: completed => completed()));
Assert.NotNull(controller);
Assert.Equal(306, snapshot!.Rows.Count);
Assert.Equal(snapshot.Rows.Count, controller!.Rows.Count);
Assert.Equal(306, controller.Page.Rows.Count);
Assert.All(controller.Rows, row =>
{
Assert.NotNull(row.MappedAction);
Assert.False(string.IsNullOrWhiteSpace(row.Label));
Assert.Equal(3, row.KeyButtons.Count);
Assert.All(row.KeyButtons, button =>
{
Assert.NotNull(button.OnClick);
Assert.NotNull(button.OnRightClick);
Assert.False(string.IsNullOrWhiteSpace(button.TooltipText));
});
});
Assert.Equal(
snapshot.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet(),
controller.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet());
uint key = DatStringResolver.ComputeHash("KEY");
uint action = DatStringResolver.ComputeHash("ACTION");
uint bindings = DatStringResolver.ComputeHash("BINDINGS");
Assert.Equal(
"'Ctrl+M' is currently bound to a non user-bindable action. Please select a different binding.",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_NonUserBindableBinding",
new Dictionary<uint, string> { [key] = "Ctrl+M" }));
Assert.Equal(
"'A' is currently bound to 'Move Backward'. Do you wish to erase that binding?",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_OverwriteExistingBinding",
new Dictionary<uint, string>
{
[key] = "A",
[action] = "Move Backward",
}));
Assert.Equal(
"'A' conflicts with the following bindings:\n'Move Backward' ('A')\n'Turn Right' ('A')\nDo you wish to erase those bindings?",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_OverwriteExistingBindings",
new Dictionary<uint, string>
{
[key] = "A",
[bindings] = "'Move Backward' ('A')\n'Turn Right' ('A')",
}));
foreach (uint buttonId in new[]
{
0x10000027u, // Load File
0x10000029u, // Save As
0x1000002Au, // Defaults
0x1000002Bu, // Revert
0x1000002Cu, // OK
0x1000002Du, // Cancel
})
{
UiButton button = Assert.IsType<UiButton>(layout.FindElement(buttonId));
Assert.NotNull(button.OnClick);
}
UiText filename = Assert.IsType<UiText>(layout.FindElement(0x10000028u));
Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text);
// The same installed catalog must contain the type-7 presenter retail's
// Load File button opens: root 0x1F, menu 0x21, accept/reject 0x22/0x23.
uint dialogLayoutId = RetailDataIdResolver.Resolve(dats, 2u, 5u);
Assert.NotEqual(0u, dialogLayoutId);
ElementInfo? menuInfo = LayoutImporter.ImportInfos(
dats,
dialogLayoutId,
RetailConfirmationMenuDialogView.RootElementId);
Assert.NotNull(menuInfo);
ImportedLayout menuLayout = LayoutImporter.Build(
menuInfo!, _ => (0u, 0, 0), null, null, strings.Resolve);
Assert.IsType<UiDialogRoot>(menuLayout.Root);
UiMenu catalogMenu = Assert.IsType<UiMenu>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.MenuElementId));
Assert.NotEqual(0u, catalogMenu.NormalSprite);
Assert.NotEqual(0u, catalogMenu.PressedSprite);
Assert.NotEqual(0u, catalogMenu.PopupBgSprite);
Assert.NotEqual(0u, catalogMenu.ItemNormalSprite);
Assert.IsType<UiButton>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.AcceptButtonId));
Assert.IsType<UiButton>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.RejectButtonId));
}
private static string? ResolveDatDir()
{
string? fromEnvironment = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnvironment) && Directory.Exists(fromEnvironment))
return fromEnvironment;
string installed = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(installed) ? installed : null;
}
}

View file

@ -221,6 +221,13 @@ public sealed class KeyboardConfigLiveMountProbeTests
string[] keys =
{
"ID_ActionKeyMap_MapInstructions",
"ID_ActionKeyMap_Binding",
"ID_ActionKeyMap_ButtonLabel",
"ID_ActionKeyMap_NonUserBindableBinding",
"ID_ActionKeyMap_OverwriteExistingBinding",
"ID_ActionKeyMap_OverwriteExistingBindings",
"ID_ActionKeyMap_TT_ExistingBinding",
"ID_ActionKeyMap_TT_NewBinding",
"ID_KeyDescDelimiter",
"ID_KeyNameWithSubControl",
"ID_KeyMapCantOverwriteReadOnlyKeymap_Label",
@ -251,7 +258,11 @@ public sealed class KeyboardConfigLiveMountProbeTests
}
// Candidate variable-name hashes for the MapInstructions template slot.
foreach (string candidate in new[] { "ACTION", "NAME", "KEY", "SUBCONTROL", "PLAYER", "COMMAND" })
foreach (string candidate in new[]
{
"ACTION", "BINDINGS", "KEY", "LABEL", "VALUE",
"NAME", "SUBCONTROL", "PLAYER", "COMMAND",
})
Console.WriteLine(
$"[kbstr] hash('{candidate}') = 0x{DatStringResolver.ComputeHash(candidate):X8}");

View file

@ -214,6 +214,22 @@ public sealed class MapHousePanelControllerTests
Assert.Contains("house-shown", calls);
}
[Fact]
public void ShowMap_UsesTheSameAuthoredTabStateAsAClick()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController controller = MapHousePanelController.Bind(
rootInfo, layout, MakeCallbacks())!;
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x100001F7u); // House
controller.ShowMap();
Assert.True(controller.IsShowingMap);
Assert.False(controller.IsShowingHouse);
}
[Fact]
public void CloseButton_InvokesToggle()
{

View file

@ -162,6 +162,22 @@ public sealed class OptionsPanelControllerTests
Assert.Equal(["flush"], flushes);
}
[Fact]
public void ShowGameplay_UsesTheSameAuthoredTabStateAsAClick()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls))!;
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x10000211u); // Character
controller.ShowGameplay();
Assert.True(controller.IsShowingGameplay);
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId);
}
[Fact]
public void WholeWindowHide_RevertsCurrentlyActivePage()
{

View file

@ -254,7 +254,7 @@ public class PaperdollControllerTests
}
[Fact]
public void HandleDropRelease_wields_optimistically_and_sends_wire()
public void HandleDropRelease_sendsWieldAndWaitsForServerPlacement()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
@ -263,8 +263,8 @@ public class PaperdollControllerTests
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
Assert.Equal(EquipMask.None, objects.Get(0xD01u)!.CurrentlyEquippedLocation);
Assert.Equal(Pack, objects.Get(0xD01u)!.ContainerId);
Assert.Single(wields);
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
}
@ -334,10 +334,10 @@ public class PaperdollControllerTests
Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation);
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, messages);
objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields);
Assert.Equal(EquipMask.MeleeWeapon,
Assert.Equal(EquipMask.None,
objects.Get(sword)!.CurrentlyEquippedLocation);
}
@ -371,7 +371,7 @@ public class PaperdollControllerTests
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
Assert.Equal((uint)coatMask, wields[0].mask);
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
}
[Fact]
@ -392,7 +392,7 @@ public class PaperdollControllerTests
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
Assert.Equal((uint)hauberkMask, wields[0].mask);
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
}
[Fact]

View file

@ -55,6 +55,42 @@ public sealed class RetailDialogFactoryTests
Assert.False(factory.IsOpen);
}
[Fact]
public void ConfirmationMenu_ReturnsSelectedIndex_AndRejectReturnsMinusOne()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = new RetailDialogFactory(root, type =>
{
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add(layout);
return layout;
});
int? result = null;
factory.MakeConfirmationMenu(
new[] { "acdream.keymap", "friends.keymap" },
selectedIndex: 1,
data => result = data.GetInt32(RetailDialogProperty.MenuSelection));
ImportedLayout first = Assert.Single(layouts);
UiMenu menu = Assert.IsType<UiMenu>(
first.FindElement(RetailConfirmationMenuDialogView.MenuElementId));
Assert.Equal(1, menu.Selected);
menu.Selected = 0;
Button(first, RetailConfirmationMenuDialogView.AcceptButtonId).OnClick!();
Assert.Equal(0, result);
result = null;
factory.MakeConfirmationMenu(
new[] { "acdream.keymap" },
selectedIndex: 0,
data => result = data.GetInt32(RetailDialogProperty.MenuSelection));
ImportedLayout second = layouts[^1];
Button(second, RetailConfirmationMenuDialogView.RejectButtonId).OnClick!();
Assert.Equal(-1, result);
}
[Fact]
public void SameQueuePresentsFifoUsingFreshLiveRoots()
{
@ -691,6 +727,7 @@ public sealed class RetailDialogFactoryTests
{
RetailDialogType.Message => 0x17u,
RetailDialogType.ConfirmationTextInput => 0x15u,
RetailDialogType.ConfirmationMenu => 0x14u,
RetailDialogType.Wait => 0x19u,
_ => 0x13u,
};
@ -708,15 +745,18 @@ public sealed class RetailDialogFactoryTests
Width = 400f,
Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f,
};
popup.Children.Add(new ElementInfo
if (type != RetailDialogType.ConfirmationMenu)
{
Id = 0x3Eu,
Type = 12u,
X = 15f,
Y = 15f,
Width = 370f,
Height = 18f,
});
popup.Children.Add(new ElementInfo
{
Id = 0x3Eu,
Type = 12u,
X = 15f,
Y = 15f,
Width = 370f,
Height = 18f,
});
}
if (type == RetailDialogType.Message)
{
popup.Children.Add(new ElementInfo
@ -793,6 +833,36 @@ public sealed class RetailDialogFactoryTests
Height = 32f,
});
}
else if (type == RetailDialogType.ConfirmationMenu)
{
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.MenuElementId,
Type = 6u,
X = 80f,
Y = 15f,
Width = 240f,
Height = 24f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.AcceptButtonId,
Type = 1u,
X = 80f,
Y = 48f,
Width = 80f,
Height = 32f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.RejectButtonId,
Type = 1u,
X = 240f,
Y = 48f,
Width = 80f,
Height = 32f,
});
}
root.Children.Add(popup);
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}

View file

@ -54,9 +54,8 @@ public sealed class RetailKeyNamesTests
[Fact]
public void SelfModifier_ShowsOnlyTheKeyName_NeverShiftPlusShiftLeft()
{
// acdream's wire-side chord for retail's bare DIK_LSHIFT walk-mode row
// carries the self-modifier bit; retail's QualifiedControl has
// meta-mode 0 and displays just the key.
// Also accept a legacy self-modifier bit while migrated JSON is read;
// retail's exact row has meta-mode 0 and displays just the key.
var names = new RetailKeyNames(
NoStrings,
osKeyName: (scan, _) => scan == 0x2A ? "SKIFT" : null);
@ -130,15 +129,37 @@ public sealed class RetailKeyNamesTests
}
[Fact]
public void ControlsOutsideTheDikTable_KeepTheEnumSpelling()
public void KeymapInterchangeControls_UseTheirRetailDikNames()
{
var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null);
// Key.F13 never appears in the DAT's 84 observed scan codes.
Assert.Equal("F13", names.Describe(new KeyChord(Key.F13, ModifierMask.None)));
Assert.Equal(
"Shift+F13",
"LSHIFT+F13",
names.Describe(new KeyChord(Key.F13, ModifierMask.Shift)));
Assert.Equal(
"LWIN+F13",
names.Describe(new KeyChord(Key.F13, ModifierMask.Win)));
}
[Fact]
public void MouseButtonUsesRetailSemanticTableThenReadableFallback()
{
var authored = new RetailKeyNames(
Table((RetailKeyNames.KeyNameTableId, "DIMOFS_BUTTON0", "Primary Mouse")),
osKeyName: (_, _) => null);
var fallback = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null);
var left = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Left),
ModifierMask.None,
Device: 1);
var rightWithCtrl = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Right),
ModifierMask.Ctrl,
Device: 1);
Assert.Equal("Primary Mouse", authored.Describe(left));
Assert.Equal("LCONTROL+Mouse Button 2", fallback.Describe(rightWithCtrl));
}
[Fact]

View file

@ -94,6 +94,8 @@ public class SelectedObjectControllerTests
public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, float> ManaMap = new();
public readonly Dictionary<uint, uint> StackMap = new();
public readonly Dictionary<uint, bool> CoinstackMap = new();
public int CoinTotal;
// Slice 6.2: vendor-owned split-exempt predicate — see
// SelectedObjectController.Bind's isVendorSplitExempt parameter.
public readonly Dictionary<uint, bool> VendorSplitExemptMap = new();
@ -139,7 +141,9 @@ public class SelectedObjectControllerTests
{
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
},
isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v);
isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v,
isCoinstack: g => CoinstackMap.TryGetValue(g, out var v) && v,
coinTotal: () => CoinTotal);
}
// ── B1: Bind initialisation ──────────────────────────────────────────────
@ -165,6 +169,25 @@ public class SelectedObjectControllerTests
Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order");
}
[Fact]
public void OwnedCoinstack_usesRetailsExactStackNameAndTotalFormat()
{
var (layout, nameEl, _, _) = FakeLayout();
var h = new Harness { CoinTotal = 12_345 };
const uint coins = 0x50000111u;
h.NameMap[coins] = "Pyreals";
h.StackMap[coins] = 2_345u;
h.OwnedMap[coins] = true;
h.CoinstackMap[coins] = true;
h.Bind(layout);
h.FireSelection(coins);
Assert.Equal(
"2345 Pyreals (of 12345)",
nameEl.Children.OfType<UiText>().First().LinesProvider().Single().Text);
}
[Fact]
public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected()
{

View file

@ -0,0 +1,47 @@
using AcDream.App.UI.Layout;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellcastingShortcutInputTests
{
[Fact]
public void EveryRetailFavoriteSpellSlotHasALiveConsumer()
{
InputAction[] slots = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x10000005u)
.Select(entry => entry.Value)
.Where(action => action.ToString().StartsWith(
"UseSpellSlot_",
StringComparison.Ordinal))
.ToArray();
Assert.Equal(12, slots.Length);
Assert.Equal(
Enumerable.Range(0, 12),
slots.Select(action =>
{
Assert.True(
SpellcastingUiController.TryMapSpellShortcut(
action,
out int index),
$"No favorite-spell consumer for {action}");
return index;
}).Order());
}
[Theory]
[InlineData(InputAction.UseSpellSlot_1, 0)]
[InlineData(InputAction.UseSpellSlot_9, 8)]
[InlineData(InputAction.UseSpellSlot_10, 9)]
[InlineData(InputAction.UseSpellSlot_11, 10)]
[InlineData(InputAction.UseSpellSlot_12, 11)]
public void AllRetailSpellSlotsMapToFavoriteIndex(
InputAction action,
int expectedIndex)
{
Assert.True(
SpellcastingUiController.TryMapSpellShortcut(action, out int index));
Assert.Equal(expectedIndex, index);
}
}

View file

@ -5,11 +5,41 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class ToolbarInputControllerTests
{
[Fact]
public void EveryRetailQuickslotRowHasALiveToolbarConsumer()
{
InputAction[] actions = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x1000000Cu)
.OrderBy(entry => entry.Key.ActionId)
.Select(entry => entry.Value)
.ToArray();
Assert.Equal(28, actions.Length);
Assert.Equal(InputAction.CreateShortcut, actions[21]);
foreach (InputAction action in actions)
{
if (action == InputAction.CreateShortcut)
continue;
Assert.True(
ToolbarInputController.TryMapShortcut(
action,
out int slot,
out _),
$"No toolbar consumer for {action}");
Assert.InRange(slot, 0, 17);
}
}
[Theory]
[InlineData(InputAction.UseQuickSlot_1, 0, true)]
[InlineData(InputAction.UseQuickSlot_9, 8, true)]
[InlineData(InputAction.SelectQuickSlot_1, 0, false)]
[InlineData(InputAction.SelectQuickSlot_9, 8, false)]
[InlineData(InputAction.UseQuickSlot_10, 9, true)]
[InlineData(InputAction.UseQuickSlot_11, 10, true)]
[InlineData(InputAction.UseQuickSlot_12, 11, true)]
[InlineData(InputAction.UseQuickSlot_13, 12, true)]
[InlineData(InputAction.UseQuickSlot_14, 13, true)]
[InlineData(InputAction.UseQuickSlot_18, 17, true)]
public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use)

View file

@ -206,6 +206,7 @@ public sealed class VendorUiControllerTests
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<string> SystemMessages = new();
public readonly ItemInteractionController ItemInteraction;
public readonly RetailDialogFactory Dialogs;
@ -365,6 +366,9 @@ public sealed class VendorUiControllerTests
sendWield: null,
sendDrop: null,
sendExamine: Examines.Add,
systemMessage: SystemMessages.Add,
sendSplitToContainer: (item, container, placement, amount) =>
SplitPuts.Add((item, container, placement, amount)),
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
{
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
@ -655,6 +659,47 @@ public sealed class VendorUiControllerTests
GetText(h.ItemCostText));
}
[Fact]
public void AlternateCurrencyPurchaseUpdatesImmediatelyThenReconcilesToInventory()
{
var h = new Harness();
const uint currencyWcid = 0x12345678u;
const uint currencyGuid = 0x60000A01u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = currencyGuid,
WeenieClassId = currencyWcid,
Name = "Colosseum Coin",
Type = ItemType.Misc,
StackSize = 10,
});
h.Objects.MoveItem(currencyGuid, Harness.PlayerGuid, 0);
h.State.Apply(
VendorGuid,
Profile(sellRate: 1.0f, altCurrency: currencyWcid, altName: "Colosseum Coins", altAmount: 10u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2),
});
h.BuyButton.OnClick!.Invoke();
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText));
// An unrelated appraisal/property refresh on the currency object is
// not an authoritative count response and must not erase m_last_sale.
Assert.True(h.Objects.UpdateIntProperty(currencyGuid, 0x7FFFu, 1));
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
// The server's stack update replaces the optimistic m_last_sale
// subtraction with the canonical inventory count without bouncing
// the displayed purse back to the stale vendor snapshot.
Assert.True(h.Objects.UpdateStackSize(currencyGuid, 8, value: 0));
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText));
}
[Fact]
public void NoSelection_DisablesBuyButton_SelectionEnablesIt()
{
@ -1850,6 +1895,29 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Buys);
}
[Fact]
public void DoubleClickStagedBuyingRow_RemovesOneUnitAndReportsRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 3u);
h.AddButton.OnClick!.Invoke();
h.BuyingList.GetItem(0)!.DoubleClicked!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(StackedItemGuid, h.Selection.SelectedObjectId);
Assert.Equal(new[] { "Removing Arrows from shopping list" }, h.SystemMessages);
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(new[] { (2, StackedItemGuid) }, items);
}
/// <summary>
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
/// visibly move the highlight — a prior version of this port only
@ -1887,12 +1955,19 @@ public sealed class VendorUiControllerTests
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
private static void MakePlayerOwned(
Harness h,
uint guid,
ItemType type,
int value,
int stackSize = 1,
uint weenieClassId = 0u)
{
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = $"Item {guid:X8}",
WeenieClassId = weenieClassId,
Type = type,
Value = value,
StackSize = stackSize,
@ -2041,29 +2116,79 @@ public sealed class VendorUiControllerTests
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
/// LITERAL <c>-1</c> "full stack" argument
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
/// slider read. A prior version of this port read the LIVE split
/// slider here instead — this proves a PARTIAL slider selection at
/// drop time does not leak into the staged (or sent) quantity.
/// Retail's AcceptDragObject splits the selected amount first, retains
/// the source as a temporary staging row, then substitutes the newly
/// created split stack before Sell All is sent.
/// </summary>
[Fact]
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
public void HandleDropRelease_PartialStack_SplitsThenStagesTheNewExactStack()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
const uint wcid = 0x2345u;
const uint splitGuid = 0x60000222u;
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 20,
weenieClassId: wcid);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
h.SplitQuantity.Reset(20u, initialValue: 5u);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(
new[] { (PlayerOwnedWeaponGuid, Harness.PlayerGuid, 0u, 5u) },
h.SplitPuts);
Assert.Equal(PlayerOwnedWeaponGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Equal(
new[] { "Splitting the Item 60000202 before selling them" },
h.SystemMessages);
// SetStackSize completes the split request. CreateObject + placement
// identify the server-assigned split guid and replace the placeholder.
Assert.True(h.Objects.UpdateStackSize(PlayerOwnedWeaponGuid, 15, value: 75));
MakePlayerOwned(
h,
splitGuid,
ItemType.MissileWeapon,
25,
stackSize: 5,
weenieClassId: wcid);
Assert.Equal(splitGuid, h.SellingList.GetItem(0)!.ItemId);
h.SellAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (5, splitGuid) }, items);
}
[Fact]
public void HandleDropRelease_PartialStackFailure_RemovesTheTemporarySellRow()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 10,
weenieClassId: 0x2345u);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(10u, initialValue: 2u);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.Objects.RejectMove(PlayerOwnedWeaponGuid, weenieError: 0x29u);
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.Sells);
}
[Fact]
@ -2309,6 +2434,84 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Sells);
}
[Fact]
public void DoubleClickStagedSellingRow_RemovesTheEntryAndReportsRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.SellingList.GetItem(0)!.DoubleClicked!.Invoke();
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Equal(
new[] { "Removing Item 60000201 from shopping list" },
h.SystemMessages);
Assert.Empty(h.Sells);
}
[Fact]
public void DragStagedSellingRow_RemovesItAndPartialSelectionPrintsExactRefusalThenResets()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
SellProfile((uint)ItemType.MissileWeapon),
Array.Empty<VendorShopItem>());
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 10);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
h.SplitQuantity.Reset(10u, initialValue: 2u);
UiItemSlot staged = h.SellingList.GetItem(0)!;
h.Controller.OnDragLift(
h.SellingList,
staged,
new ItemDragPayload(
PlayerOwnedWeaponGuid,
ItemDragSource.Inventory,
staged.SlotIndex,
staged));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(
new[] { "You cannot split items from this panel" },
h.SystemMessages);
Assert.Equal(10u, h.SplitQuantity.Value);
Assert.Equal(10u, h.SplitQuantity.Maximum);
}
[Fact]
public void RightClickStagedBuyingAndSellingRows_SelectsAndExaminesBoth()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
{
new VendorShopItem(
ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
UiItemSlot buying = h.BuyingList.GetItem(0)!;
buying.OnEvent(new UiEvent(0u, buying, UiEventType.RightClick));
UiItemSlot selling = h.SellingList.GetItem(0)!;
selling.OnEvent(new UiEvent(0u, selling, UiEventType.RightClick));
Assert.Equal(new[] { ArmorItemGuid, PlayerOwnedArmorGuid }, h.Examines);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
}
// ══════════════════════════════════════════════════════════════════════
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
// ══════════════════════════════════════════════════════════════════════

View file

@ -297,10 +297,19 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var pending = probe.AssertItem(
Hauberk,
equippedLocation: EquipMask.None,
containerId: Player);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Hauberk, Player, HauberkMask));
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
containerId: 0u);
Assert.True(state.Success, state.Message);
}
@ -337,9 +346,15 @@ public sealed class RetailUiInteractionFlowTests
Assert.Equal(EquipMask.MeleeWeapon,
h.Objects.Get(Sword)!.CurrentlyEquippedLocation);
h.Objects.MoveItem(Sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(Sword, Player, 0u, 0));
Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Equal(EquipMask.None,
h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Bow, Player, EquipMask.MissileWeapon));
Assert.Equal(EquipMask.MissileWeapon,
h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
}
@ -453,6 +468,11 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
Assert.Equal(new[] { Hauberk }, h.Drops);
var pending = probe.AssertItem(Hauberk, containerId: Player, slot: 0);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerMove(Hauberk, 0u, 0u, -1));
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
Assert.True(state.Success, state.Message);
}
@ -470,10 +490,19 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var pending = probe.AssertItem(
Hauberk,
equippedLocation: EquipMask.None,
containerId: Player);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Hauberk, Player, HauberkMask));
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
containerId: 0u);
Assert.True(state.Success, state.Message);
}
}

View file

@ -6,6 +6,44 @@ namespace AcDream.App.Tests.UI;
public class UiRootInputTests
{
[Fact]
public void KeypadEnter_DoesNotUseTheRawChatActivationFallback()
{
var root = new UiRoot { Width = 800, Height = 600 };
var field = new UiField { Width = 100, Height = 20 };
root.AddChild(field);
root.DefaultTextInput = field;
root.OnKeyDown((int)Silk.NET.Input.Key.KeypadEnter);
Assert.Null(root.KeyboardFocus);
}
[Fact]
public void SemanticChatActivation_SuppressesTheSameNativeEnterTail()
{
var root = new UiRoot { Width = 800, Height = 600 };
var field = new UiField { Width = 100, Height = 20 };
int submissions = 0;
field.SetText("hello");
field.OnSubmit = _ => submissions++;
root.AddChild(field);
root.DefaultTextInput = field;
root.SetKeyboardFocus(field);
root.SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key.Enter);
root.OnKeyDown((int)Silk.NET.Input.Key.Enter);
root.OnChar('x');
Assert.Equal(0, submissions);
Assert.Equal("hello", field.Text);
Assert.Same(field, root.KeyboardFocus);
root.OnKeyUp((int)Silk.NET.Input.Key.Enter);
root.OnChar('x');
Assert.Equal("hellox", field.Text);
}
[Fact]
public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks()
{