fix(client): restore retail interaction parity
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:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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}");
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue