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
|
|
@ -61,6 +61,33 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailScreenshotRequest_UsesFirstFreeScreenShotNumber()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(Path.Combine(directory, "ScreenShot00000.png"), [0]);
|
||||
var controller = new FrameScreenshotController(
|
||||
(_, _) => [255, 255, 255, 255],
|
||||
directory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(controller.TryRequestRetailScreenshot(
|
||||
out string path,
|
||||
out string error), error);
|
||||
Assert.Equal(
|
||||
Path.Combine(directory, "ScreenShot00001.png"),
|
||||
path);
|
||||
Assert.True(controller.CapturePending(1, 1));
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
|
|
@ -175,31 +176,63 @@ public sealed class CameraPointerInputControllerTests
|
|||
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChaseMouseWheel_RetainsExtendedZoomOutRange()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
var legacy = new ChaseCamera();
|
||||
var retail = new RetailChaseCamera();
|
||||
fixture.Mode.IsPlayerMode = true;
|
||||
fixture.Chase.Legacy = legacy;
|
||||
fixture.Chase.Retail = retail;
|
||||
fixture.Camera.EnterChaseMode(legacy, retail);
|
||||
|
||||
float before = CameraDiagnostics.UseRetailChaseCamera
|
||||
? retail.Distance
|
||||
: legacy.Distance;
|
||||
fixture.Owner.HandleScroll(InputAction.ScrollDown);
|
||||
float afterOne = CameraDiagnostics.UseRetailChaseCamera
|
||||
? retail.Distance
|
||||
: legacy.Distance;
|
||||
for (int i = 0; i < 100; i++)
|
||||
fixture.Owner.HandleScroll(InputAction.ScrollDown);
|
||||
float afterMany = CameraDiagnostics.UseRetailChaseCamera
|
||||
? retail.Distance
|
||||
: legacy.Distance;
|
||||
|
||||
Assert.Equal(before + 0.8f, afterOne, 5);
|
||||
Assert.Equal(40f, afterMany, 5);
|
||||
}
|
||||
|
||||
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
|
||||
{
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
var capture = new Capture();
|
||||
var mouse = new Mouse();
|
||||
var cursor = new Cursor();
|
||||
var mode = new LocalPlayerModeState();
|
||||
var chase = new ChaseCameraInputState();
|
||||
var owner = new CameraPointerInputController(
|
||||
surfaces,
|
||||
cursor,
|
||||
new HostQuiescenceGate(),
|
||||
capture,
|
||||
new LocalPlayerModeState(),
|
||||
mode,
|
||||
camera,
|
||||
new ChaseCameraInputState(),
|
||||
chase,
|
||||
mouse,
|
||||
new PointerPositionState(),
|
||||
new Clock());
|
||||
return new Fixture(owner, camera, capture, cursor);
|
||||
return new Fixture(owner, camera, capture, cursor, mode, chase);
|
||||
}
|
||||
|
||||
private sealed record Fixture(
|
||||
CameraPointerInputController Owner,
|
||||
CameraController Camera,
|
||||
Capture Capture,
|
||||
Cursor Cursor);
|
||||
Cursor Cursor,
|
||||
LocalPlayerModeState Mode,
|
||||
ChaseCameraInputState Chase);
|
||||
|
||||
private sealed class RawSurface : IRawPointerSurface
|
||||
{
|
||||
|
|
@ -282,6 +315,7 @@ public sealed class CameraPointerInputControllerTests
|
|||
{
|
||||
public void Tick() { }
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) { }
|
||||
public void AbortAutomaticAttack() { }
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
|
@ -11,9 +12,10 @@ public sealed class GameplayInputActionRouterTests
|
|||
[InlineData("pointer", "pointer")]
|
||||
[InlineData("combat", "pointer,combat")]
|
||||
[InlineData("retained", "pointer,combat,retained")]
|
||||
[InlineData("selection", "pointer,combat,retained,selection")]
|
||||
[InlineData("movement", "pointer,combat,retained,selection,movement")]
|
||||
[InlineData("command", "pointer,combat,retained,selection,movement,command")]
|
||||
[InlineData("character-option", "pointer,combat,retained,character-option")]
|
||||
[InlineData("selection", "pointer,combat,retained,character-option,selection")]
|
||||
[InlineData("movement", "pointer,combat,retained,character-option,selection,movement")]
|
||||
[InlineData("command", "pointer,combat,retained,character-option,selection,movement,command")]
|
||||
public void Press_PreservesFrozenPriorityAndStopsAtConsumer(
|
||||
string consumeAt,
|
||||
string expectedCsv)
|
||||
|
|
@ -66,7 +68,7 @@ public sealed class GameplayInputActionRouterTests
|
|||
ActivationType.DoubleClick);
|
||||
|
||||
Assert.Equal(
|
||||
["pointer", "combat", "retained", "selection", "movement", "command"],
|
||||
["pointer", "combat", "retained", "character-option", "selection", "movement", "command"],
|
||||
harness.Targets.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ public sealed class GameplayInputActionRouterTests
|
|||
ActivationType.Click);
|
||||
|
||||
Assert.Equal(
|
||||
["pointer", "combat", "retained", "selection"],
|
||||
["pointer", "combat", "retained", "character-option", "selection"],
|
||||
harness.Targets.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +102,60 @@ public sealed class GameplayInputActionRouterTests
|
|||
harness.Actions.Scopes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InputAction.Ready, RuntimeMovementCommand.Ready)]
|
||||
[InlineData(InputAction.Sitting, RuntimeMovementCommand.Sit)]
|
||||
[InlineData(InputAction.Crouch, RuntimeMovementCommand.Crouch)]
|
||||
[InlineData(InputAction.Sleeping, RuntimeMovementCommand.Sleep)]
|
||||
public void RetailPostureKeys_MapToCanonicalRuntimeCommands(
|
||||
InputAction action,
|
||||
RuntimeMovementCommand expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
RuntimeGameplayInputPriorityTargets.ResolvePressedMovementCommand(action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeMovementRung_PreservesRetailPriority()
|
||||
{
|
||||
var charging = new AcDream.Runtime.Gameplay.JumpChargeSnapshot(
|
||||
IsCharging: true,
|
||||
Power: 0.5f);
|
||||
var repeat = new RuntimeCombatAttackSnapshot(
|
||||
0,
|
||||
AttackHeight.Medium,
|
||||
0f,
|
||||
0f,
|
||||
false,
|
||||
false,
|
||||
0f,
|
||||
RepeatAttackInProgress: true);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeMovementCommand.FinishJump,
|
||||
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
|
||||
InputAction.EscapeKey,
|
||||
isStandingStill: false,
|
||||
charging,
|
||||
repeat));
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeMovementCommand.StopCompletely,
|
||||
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
|
||||
InputAction.EscapeKey,
|
||||
isStandingStill: false,
|
||||
jumpCharge: default,
|
||||
repeat));
|
||||
|
||||
Assert.Null(
|
||||
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
|
||||
InputAction.EscapeKey,
|
||||
isStandingStill: true,
|
||||
jumpCharge: default,
|
||||
repeat with { RepeatAttackInProgress = false }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "remove-actions")]
|
||||
[InlineData(1, "remove-combat,remove-actions")]
|
||||
|
|
@ -277,6 +333,9 @@ public sealed class GameplayInputActionRouterTests
|
|||
|
||||
public void SetCombatScope(InputScope? scope) => Scopes.Add(scope);
|
||||
|
||||
public void SetCameraAlternateScope(bool active) =>
|
||||
calls.Add($"camera-scope:{active}");
|
||||
|
||||
public void Raise(InputAction action, ActivationType activation) =>
|
||||
Callback?.Invoke(action, activation);
|
||||
}
|
||||
|
|
@ -326,6 +385,9 @@ public sealed class GameplayInputActionRouterTests
|
|||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
Record("retained");
|
||||
|
||||
public bool HandleCharacterOptionAction(InputAction action) =>
|
||||
Record("character-option");
|
||||
|
||||
public bool HandleSelectionAction(InputAction action) =>
|
||||
Record("selection");
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ public sealed class GameplayInputCommandControllerTests
|
|||
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
|
||||
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
|
||||
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
|
||||
[InlineData(InputAction.ToggleChatEntry, "focus-chat")]
|
||||
[InlineData(InputAction.EnterChatMode, "focus-chat")]
|
||||
[InlineData(InputAction.LOGOUT, "logout")]
|
||||
public void RecognizedCommand_RoutesToTypedOwner(
|
||||
InputAction action,
|
||||
string expected)
|
||||
|
|
@ -35,21 +38,12 @@ public sealed class GameplayInputCommandControllerTests
|
|||
Assert.Equal([expected], harness.Calls);
|
||||
}
|
||||
|
||||
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the
|
||||
// IDevToolsGameplayCommands seam they used to forward to — both
|
||||
// targets (the ImGui-era DebugPanel/ChatPanel) were already gone
|
||||
// (Campaign V slice V11), so the seam's own body was an unconditional
|
||||
// no-op. The action is still consumed (handled == true, matching the
|
||||
// prior no-op's contract) but claims no typed-owner call.
|
||||
[Theory]
|
||||
[InlineData(InputAction.AcdreamToggleDebugPanel)]
|
||||
[InlineData(InputAction.ToggleChatEntry)]
|
||||
public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner(
|
||||
InputAction action)
|
||||
[Fact]
|
||||
public void RetiredDebugPanelCommand_IsConsumedWithoutClaimingATypedOwner()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
bool handled = harness.Controller.Handle(action);
|
||||
bool handled = harness.Controller.Handle(InputAction.AcdreamToggleDebugPanel);
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Empty(harness.Calls);
|
||||
|
|
@ -80,8 +74,9 @@ public sealed class GameplayInputCommandControllerTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape's priority chain: cancel a target mode, else leave player mode,
|
||||
/// else close a window.
|
||||
/// Escape's command-tier priority: cancel a target mode, otherwise toggle
|
||||
/// retail's Gameplay Options page. It must never expose the developer/fly
|
||||
/// camera or close the game window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly
|
||||
|
|
@ -90,21 +85,15 @@ public sealed class GameplayInputCommandControllerTests
|
|||
/// rather than silently exiting a camera the player has no way to enter.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(true, true, true, "cancel-target")]
|
||||
[InlineData(false, true, true, "exit-player")]
|
||||
[InlineData(false, false, true, "exit-player")]
|
||||
[InlineData(false, false, false, "close")]
|
||||
public void Escape_PreservesTargetPlayerWindowPriority(
|
||||
[InlineData(true, "cancel-target")]
|
||||
[InlineData(false, "gameplay-options")]
|
||||
public void Escape_PreservesRetailTargetThenGameplayOptionsPriority(
|
||||
bool targetMode,
|
||||
bool flyMode,
|
||||
bool playerMode,
|
||||
string expected)
|
||||
{
|
||||
var harness = new Harness
|
||||
{
|
||||
TargetMode = { IsActive = targetMode },
|
||||
Camera = { IsFly = flyMode },
|
||||
Player = { IsPlayer = playerMode },
|
||||
};
|
||||
|
||||
bool handled = harness.Controller.Handle(InputAction.EscapeKey);
|
||||
|
|
@ -121,19 +110,15 @@ public sealed class GameplayInputCommandControllerTests
|
|||
Diagnostics = new FakeDiagnostics(Calls);
|
||||
Player = new FakePlayerMode(Calls);
|
||||
TargetMode = new FakeTargetMode(Calls);
|
||||
Camera = new FakeCamera(Calls);
|
||||
Combat = new FakeCombat(Calls);
|
||||
Runtime = new FakeRuntimeView();
|
||||
Window = new FakeWindow(Calls);
|
||||
Controller = new GameplayInputCommandController(
|
||||
Retained,
|
||||
Diagnostics,
|
||||
Player,
|
||||
TargetMode,
|
||||
Camera,
|
||||
Runtime,
|
||||
Combat,
|
||||
Window);
|
||||
Combat);
|
||||
}
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
|
@ -141,10 +126,8 @@ public sealed class GameplayInputCommandControllerTests
|
|||
public FakeDiagnostics Diagnostics { get; }
|
||||
public FakePlayerMode Player { get; }
|
||||
public FakeTargetMode TargetMode { get; }
|
||||
public FakeCamera Camera { get; }
|
||||
public FakeCombat Combat { get; }
|
||||
public FakeRuntimeView Runtime { get; }
|
||||
public FakeWindow Window { get; }
|
||||
public GameplayInputCommandController Controller { get; }
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +140,12 @@ public sealed class GameplayInputCommandControllerTests
|
|||
calls.Add($"chat-window-{windowId}");
|
||||
|
||||
public void ToggleOptionsPanel() => calls.Add("options");
|
||||
|
||||
public void ToggleGameplayOptionsPage() => calls.Add("gameplay-options");
|
||||
|
||||
public void FocusChatEntry() => calls.Add("focus-chat");
|
||||
|
||||
public void LogOutCharacter() => calls.Add("logout");
|
||||
}
|
||||
|
||||
private sealed class FakeDiagnostics(List<string> calls)
|
||||
|
|
@ -180,11 +169,8 @@ public sealed class GameplayInputCommandControllerTests
|
|||
private sealed class FakePlayerMode(List<string> calls)
|
||||
: IPlayerModeGameplayCommands
|
||||
{
|
||||
public bool IsPlayer { get; set; }
|
||||
public bool IsPlayerMode => IsPlayer;
|
||||
public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
|
||||
public void TogglePlayerMode() => calls.Add("player-mode");
|
||||
public void ExitPlayerMode() => calls.Add("exit-player");
|
||||
}
|
||||
|
||||
private sealed class FakeTargetMode(List<string> calls)
|
||||
|
|
@ -195,14 +181,6 @@ public sealed class GameplayInputCommandControllerTests
|
|||
public void CancelTargetMode() => calls.Add("cancel-target");
|
||||
}
|
||||
|
||||
private sealed class FakeCamera(List<string> calls)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
public bool IsFly { get; set; }
|
||||
public bool IsFlyMode => IsFly;
|
||||
public void ExitFlyMode() => calls.Add("exit-fly");
|
||||
}
|
||||
|
||||
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
|
||||
{
|
||||
public RuntimeCommandResult Execute(
|
||||
|
|
@ -248,8 +226,4 @@ public sealed class GameplayInputCommandControllerTests
|
|||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
|
||||
{
|
||||
public void Close() => calls.Add("close");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ public sealed class GameplayInputFrameControllerTests
|
|||
public void Tick() => _calls.Add("combat");
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) =>
|
||||
_calls.Add("combat-movement");
|
||||
public void AbortAutomaticAttack() => _calls.Add("combat-abort");
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
_calls.Add("combat-action");
|
||||
|
|
|
|||
45
tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs
Normal file
45
tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class RetailEmoteMotionTableTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryRetailEmoteActionHasExactMotionConsumer()
|
||||
{
|
||||
InputAction[] emotes = RetailActionIdentityTable.Map
|
||||
.Where(entry => entry.Key.InputMapId == 0x10000006u)
|
||||
.OrderBy(entry => entry.Key.ActionId)
|
||||
.Select(entry => entry.Value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(87, RetailEmoteMotionTable.Count);
|
||||
Assert.Equal(87, emotes.Length);
|
||||
foreach (InputAction emote in emotes)
|
||||
Assert.True(RetailEmoteMotionTable.TryGetMotion(emote, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InputAction.EmoteAfkState, 0x43000118u)]
|
||||
[InlineData(InputAction.Cheer, 0x1300004Cu)]
|
||||
[InlineData(InputAction.Cry, 0x1300007Fu)]
|
||||
[InlineData(InputAction.Laugh, 0x13000080u)]
|
||||
[InlineData(InputAction.PointState, 0x430000F0u)]
|
||||
[InlineData(InputAction.Wave, 0x13000087u)]
|
||||
[InlineData(InputAction.EmoteYmca, 0x1200009Bu)]
|
||||
public void RepresentativeActionsMatchNamedRetailGlobals(
|
||||
InputAction action,
|
||||
uint expectedMotion)
|
||||
{
|
||||
Assert.True(RetailEmoteMotionTable.TryGetMotion(action, out uint motion));
|
||||
Assert.Equal(expectedMotion, motion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InputAction.Ready)]
|
||||
[InlineData(InputAction.ToggleOptionsPanel)]
|
||||
[InlineData(InputAction.UseQuickSlot_1)]
|
||||
public void NonEmoteActionsAreRejected(InputAction action) =>
|
||||
Assert.False(RetailEmoteMotionTable.TryGetMotion(action, out _));
|
||||
}
|
||||
113
tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs
Normal file
113
tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class RetailKeymapFileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_CommittedRetailFile_ReproducesEveryUserBindableDefault()
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(), "docs", "research", "named-retail",
|
||||
"retail-default.keymap.txt"));
|
||||
KeyBindings expected = KeyBindings.RetailDefaults();
|
||||
KeyBindings actual = RetailKeymapFile.Parse(text, expected);
|
||||
|
||||
Assert.Equal(
|
||||
expected.ForAction(InputAction.MovementForward).ToArray(),
|
||||
actual.ForAction(InputAction.MovementForward).ToArray());
|
||||
Assert.Equal(
|
||||
expected.ForAction(InputAction.MovementWalkMode).ToArray(),
|
||||
actual.ForAction(InputAction.MovementWalkMode).ToArray());
|
||||
Assert.Equal(
|
||||
expected.ForAction(InputAction.ToggleInventoryPanel).ToArray(),
|
||||
actual.ForAction(InputAction.ToggleInventoryPanel).ToArray());
|
||||
Assert.Equal(
|
||||
expected.ForAction(InputAction.CameraAlternateRotateLeft).ToArray(),
|
||||
actual.ForAction(InputAction.CameraAlternateRotateLeft).ToArray());
|
||||
// The user's captured retail file omits slots 10-13; omission means
|
||||
// unbound rather than "inherit a compiled default".
|
||||
Assert.Empty(actual.ForAction(InputAction.UseQuickSlot_10));
|
||||
|
||||
// Host-only actions are outside retail's fourteen editable maps and survive.
|
||||
Assert.Equal(
|
||||
expected.ForAction(InputAction.AcdreamToggleAudioMute).ToArray(),
|
||||
actual.ForAction(InputAction.AcdreamToggleAudioMute).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteThenParse_RoundTripsAll306RetailActionIdentities()
|
||||
{
|
||||
var source = new KeyBindings();
|
||||
foreach (((uint inputMapId, uint actionId), InputAction action) in
|
||||
RetailActionIdentityTable.Map)
|
||||
{
|
||||
source.Add(new Binding(
|
||||
new KeyChord(
|
||||
Key.SuperRight,
|
||||
ModifierMask.Shift | ModifierMask.Ctrl | ModifierMask.Win),
|
||||
action,
|
||||
RetailActionIdentityTable.ActivationFor(inputMapId, actionId),
|
||||
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
|
||||
}
|
||||
|
||||
string text = RetailKeymapFile.Write(source);
|
||||
KeyBindings loaded = RetailKeymapFile.Parse(text, new KeyBindings());
|
||||
|
||||
Assert.Equal(306, loaded.All.Count);
|
||||
foreach (Binding expected in source.All)
|
||||
Assert.Contains(expected, loaded.All);
|
||||
Assert.Contains("CharacterOptionCommands", text, StringComparison.Ordinal);
|
||||
Assert.Contains("AutoRepeatAttacks", text, StringComparison.Ordinal);
|
||||
Assert.Contains("AFKState", text, StringComparison.Ordinal);
|
||||
Assert.Contains("DIK_RWIN", text, StringComparison.Ordinal);
|
||||
Assert.Contains("EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]", text, StringComparison.Ordinal);
|
||||
Assert.Contains("TargetedUsage", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileStore_SaveAsSelectsProfile_AndLoadReplacesRetailRows()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "acdream-keymap-" + Guid.NewGuid().ToString("N"));
|
||||
string config = Path.Combine(root, "config");
|
||||
string documents = Path.Combine(root, "documents", "Asheron's Call");
|
||||
string json = Path.Combine(config, "keybinds.json");
|
||||
try
|
||||
{
|
||||
var source = KeyBindings.RetailDefaults();
|
||||
var store = new RetailKeymapProfileStore(json, documents);
|
||||
RetailKeymapSaveResult saved = store.Save("friends", source, overwrite: false);
|
||||
|
||||
Assert.Equal(RetailKeymapSaveStatus.Saved, saved.Status);
|
||||
Assert.Equal("friends.keymap", store.CurrentFileName);
|
||||
Assert.Equal(new[] { "friends.keymap" }, store.ListFiles());
|
||||
Assert.True(store.TryLoad(
|
||||
"friends.keymap", new KeyBindings(), out KeyBindings loaded, out string? error),
|
||||
error);
|
||||
Assert.Equal(
|
||||
source.ForAction(InputAction.MovementForward).ToArray(),
|
||||
loaded.ForAction(InputAction.MovementForward).ToArray());
|
||||
Assert.Equal(
|
||||
RetailKeymapSaveStatus.Exists,
|
||||
store.Save("friends", source, overwrite: false).Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
||||
return directory;
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate acdream.sln.");
|
||||
}
|
||||
}
|
||||
|
|
@ -234,6 +234,25 @@ public sealed class SelectionInteractionControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_ClearsCurrentSelectionBeforeTheOptionsFallback()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||
|
||||
Assert.True(h.Controller.HandleInputAction(InputAction.EscapeKey));
|
||||
|
||||
Assert.Null(h.Selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_WithNoTargetModeOrSelection_FallsThrough()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
Assert.False(h.Controller.HandleInputAction(InputAction.EscapeKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf()
|
||||
{
|
||||
|
|
@ -323,6 +342,25 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Contains(h.Toasts, text => text.Contains("Target 70000001"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryRetailItemSelectionRowHasALiveControllerConsumer()
|
||||
{
|
||||
InputAction[] actions = RetailActionIdentityTable.Map
|
||||
.Where(entry => entry.Key.InputMapId == 0x10000007u)
|
||||
.OrderBy(entry => entry.Key.ActionId)
|
||||
.Select(entry => entry.Value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(26, actions.Length);
|
||||
foreach (InputAction action in actions)
|
||||
{
|
||||
var harness = new Harness();
|
||||
Assert.True(
|
||||
harness.Controller.HandleInputAction(action),
|
||||
$"No selection consumer for {action}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseUseSendsImmediatelyWithoutSpeculativeMovement()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
|
|
@ -45,7 +46,10 @@ public sealed class WorldSelectionQueryTests
|
|||
public readonly LiveEntityRuntime Runtime;
|
||||
public readonly RetailSelectionScene Scene;
|
||||
public readonly WorldSelectionQuery Query;
|
||||
public readonly ExternalContainerState ExternalContainers = new();
|
||||
public readonly HashSet<uint> Fellows = [];
|
||||
public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero);
|
||||
public CombatMode CurrentCombatMode = CombatMode.NonCombat;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for EntityEffectPoseRegistry: the composed equipped-child
|
||||
|
|
@ -74,7 +78,10 @@ public sealed class WorldSelectionQueryTests
|
|||
_ => (new Vector3(1f, 0f, 0f), 2f),
|
||||
localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root)
|
||||
? root
|
||||
: null);
|
||||
: null,
|
||||
ExternalContainers.HasCorpseBeenOpened,
|
||||
() => CurrentCombatMode,
|
||||
Fellows.Contains);
|
||||
|
||||
Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
|
||||
}
|
||||
|
|
@ -89,7 +96,8 @@ public sealed class WorldSelectionQueryTests
|
|||
ushort instance = 1,
|
||||
float scale = 1f,
|
||||
Quaternion? rotation = null,
|
||||
float? useRadius = null)
|
||||
float? useRadius = null,
|
||||
byte? radarBehavior = null)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
|
||||
{
|
||||
|
|
@ -109,6 +117,7 @@ public sealed class WorldSelectionQueryTests
|
|||
Name = $"Object {guid:X8}",
|
||||
Type = type,
|
||||
PublicWeenieBitfield = publicFlags,
|
||||
RadarBehavior = radarBehavior,
|
||||
});
|
||||
return entity;
|
||||
}
|
||||
|
|
@ -301,6 +310,187 @@ public sealed class WorldSelectionQueryTests
|
|||
Assert.Equal(64f, closest?.DistanceSquared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailItemSelectionUsesRadarAndSpecialObjectRules()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint radarItem = 0x7000_0020u;
|
||||
const uint ordinaryItem = 0x7000_0021u;
|
||||
const uint portal = 0x7000_0022u;
|
||||
h.Add(
|
||||
radarItem,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Misc,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Add(ordinaryItem, new Vector3(2f, 0f, 0f), ItemType.Misc);
|
||||
h.Add(
|
||||
portal,
|
||||
new Vector3(3f, 0f, 0f),
|
||||
ItemType.Misc,
|
||||
publicFlags: (uint)PublicWeenieFlags.Portal,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
|
||||
Assert.Equal(
|
||||
ordinaryItem,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.Item,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
|
||||
// A radar-authored item is not in the Item cycle unless it carries
|
||||
// one of retail's three explicit exceptions (lifestone/portal/
|
||||
// bindstone).
|
||||
h.Objects.Get(ordinaryItem)!.ContainerId = Player;
|
||||
Assert.Equal(
|
||||
portal,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.Item,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailCompassSelectionChangesPredicateInPhysicalCombat()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint peaceful = 0x7000_0030u;
|
||||
const uint fellow = 0x7000_0031u;
|
||||
const uint vendor = 0x7000_0032u;
|
||||
const uint environment = 0x7000_0033u;
|
||||
const uint hostile = 0x7000_0034u;
|
||||
h.Add(
|
||||
peaceful,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Misc,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Add(
|
||||
fellow,
|
||||
new Vector3(2f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)PublicWeenieFlags.Attackable,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Fellows.Add(fellow);
|
||||
h.Add(
|
||||
vendor,
|
||||
new Vector3(3f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor),
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Add(
|
||||
environment,
|
||||
new Vector3(4f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)PublicWeenieFlags.Attackable,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
Assert.True(h.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
environment,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.ReportAsEnvironment),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2),
|
||||
out _));
|
||||
h.Add(
|
||||
hostile,
|
||||
new Vector3(5f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)PublicWeenieFlags.Attackable,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
|
||||
Assert.Equal(
|
||||
peaceful,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.CompassItem,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
|
||||
h.CurrentCombatMode = CombatMode.Melee;
|
||||
Assert.Equal(
|
||||
hostile,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.CompassItem,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
|
||||
// Retail's special combat-only compass restriction does not apply
|
||||
// in magic mode.
|
||||
h.CurrentCombatMode = CombatMode.Magic;
|
||||
Assert.Equal(
|
||||
peaceful,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.CompassItem,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailMonsterSelectionUsesObjectIsAttackableAndRejectsFellowsAndVendors()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint fellow = 0x7000_0040u;
|
||||
const uint vendor = 0x7000_0041u;
|
||||
const uint hostile = 0x7000_0042u;
|
||||
h.Add(
|
||||
fellow,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)PublicWeenieFlags.Attackable,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Fellows.Add(fellow);
|
||||
h.Add(
|
||||
vendor,
|
||||
new Vector3(2f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor),
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
h.Add(
|
||||
hostile,
|
||||
new Vector3(3f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: (uint)PublicWeenieFlags.Attackable,
|
||||
radarBehavior: (byte)RadarBehavior.ShowAlways);
|
||||
|
||||
Assert.Equal(
|
||||
hostile,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.Monster,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailUnopenedCorpseSelectionRemembersOpenUntilDelete()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint corpse = 0x7000_0050u;
|
||||
h.Add(
|
||||
corpse,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Container,
|
||||
publicFlags: (uint)PublicWeenieFlags.Corpse);
|
||||
|
||||
Assert.Equal(
|
||||
corpse,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.UnopenedCorpse,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
|
||||
Assert.True(h.ExternalContainers.RequestOpen(corpse, isCorpse: true));
|
||||
Assert.Null(h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.UnopenedCorpse,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
|
||||
Assert.True(h.ExternalContainers.SetCorpseDeleted(corpse));
|
||||
Assert.Equal(
|
||||
corpse,
|
||||
h.Query.FindSelectionTarget(
|
||||
RetailSelectionKind.UnopenedCorpse,
|
||||
RetailSelectionDirection.Closest,
|
||||
anchor: null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
|
||||
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
|
||||
|
|
|
|||
|
|
@ -9,17 +9,18 @@ namespace AcDream.App.Tests.Rendering;
|
|||
public sealed class PaperdollFramePresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void HiddenView_DoesNotBuildOrRender()
|
||||
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
|
||||
{
|
||||
var renderer = new RecordingRenderer();
|
||||
var view = new RecordingView { Visible = false };
|
||||
var factory = new RecordingFactory();
|
||||
var factory = new RecordingFactory { Doll = CreateDoll() };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
|
||||
Assert.True(presenter.IsDirty);
|
||||
Assert.Equal(0, factory.BuildCount);
|
||||
Assert.False(presenter.IsDirty);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
Assert.Equal(1, renderer.PrepareCount);
|
||||
Assert.Equal(0, renderer.RenderCount);
|
||||
Assert.Empty(view.TextureHandles);
|
||||
}
|
||||
|
|
@ -255,9 +256,12 @@ public sealed class PaperdollFramePresenterTests
|
|||
public List<WorldEntity?> Dolls { get; } = [];
|
||||
public List<(int Width, int Height)> RenderSizes { get; } = [];
|
||||
public int RenderCount => RenderSizes.Count;
|
||||
public int PrepareCount { get; private set; }
|
||||
|
||||
public void SetDoll(WorldEntity? doll) => Dolls.Add(doll);
|
||||
|
||||
public void Prepare() => PrepareCount++;
|
||||
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
RenderSizes.Add((width, height));
|
||||
|
|
|
|||
|
|
@ -209,6 +209,25 @@ public class RetailChaseCameraTests
|
|||
Assert.Equal(Vector3.Normalize(pivot - eye), forward);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapMode_TargetDirectionTransformsViewerOffsetIntoOverheadPose()
|
||||
{
|
||||
var pivot = new Vector3(10f, 20f, 1.5f);
|
||||
float distance = MathF.Sqrt(450f * 450f + 0.75f * 0.75f);
|
||||
float pitch = MathF.Atan2(0.75f, 450f);
|
||||
|
||||
var (eye, forward) = RetailChaseCamera.ComputeTargetDirectionPose(
|
||||
pivot,
|
||||
Vector3.UnitX,
|
||||
distance,
|
||||
pitch,
|
||||
new Vector3(0f, 0.5f, -1.8f));
|
||||
|
||||
Assert.True(eye.Z > 430f, $"expected retail overhead eye, got Z={eye.Z}");
|
||||
Assert.InRange(Vector2.Distance(new Vector2(eye.X, eye.Y), new Vector2(pivot.X, pivot.Y)), 119f, 122f);
|
||||
Assert.True(forward.Z < -0.95f, $"expected steep downward view, got {forward}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
|
||||
{
|
||||
|
|
@ -528,6 +547,41 @@ public class RetailChaseCameraTests
|
|||
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetRetailFirstPersonView_PlacesEyeAheadAndLooksForward()
|
||||
{
|
||||
var cam = new RetailChaseCamera();
|
||||
cam.SetRetailFirstPersonView();
|
||||
|
||||
cam.Update(
|
||||
playerPosition: Vector3.Zero,
|
||||
playerYaw: 0f,
|
||||
playerVelocity: Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
Assert.True(cam.IsInHead);
|
||||
Assert.Equal(new Vector3(0.18f, 0f, 1.5f), cam.Position);
|
||||
Assert.Equal(1f, cam.PlayerTranslucency, 5);
|
||||
var (_, forward) = RetailChaseCamera.ComputeInHeadPose(
|
||||
new Vector3(0f, 0f, 1.5f),
|
||||
Vector3.UnitX);
|
||||
Assert.Equal(Vector3.UnitX, forward);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustingZoomExitsRetailFirstPersonView()
|
||||
{
|
||||
var cam = new RetailChaseCamera();
|
||||
cam.SetRetailFirstPersonView();
|
||||
|
||||
cam.AdjustDistance(1f);
|
||||
|
||||
Assert.False(cam.IsInHead);
|
||||
Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustPitch_ClampsToRange()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -338,6 +338,11 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Press));
|
||||
Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress);
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Hold));
|
||||
Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress);
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Release));
|
||||
|
|
@ -1280,6 +1285,10 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
{
|
||||
}
|
||||
|
||||
public void AbortAutomaticAttack()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HandleInputAction(
|
||||
InputAction action,
|
||||
ActivationType activation) => false;
|
||||
|
|
|
|||
|
|
@ -1400,6 +1400,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public bool IsRecenterPending => RecenterPending;
|
||||
public bool RecenterPending;
|
||||
public bool ResetResult = true;
|
||||
public int ResetCalls;
|
||||
public bool LastResetWasSessionEnding;
|
||||
public readonly List<(int X, int Y, bool Sealed)> Recenters = new();
|
||||
public readonly List<(long Generation, uint Cell, int Radius)>
|
||||
|
|
@ -1414,6 +1415,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
public bool ResetRecenter(bool sessionEnding)
|
||||
{
|
||||
ResetCalls++;
|
||||
LastResetWasSessionEnding = sessionEnding;
|
||||
return ResetResult;
|
||||
}
|
||||
|
|
@ -1954,6 +1956,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.ResetGenerationPresentation();
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
Assert.Equal(1, harness.Streaming.ResetCalls);
|
||||
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
|
||||
// The transaction's world reset retired the presentation.
|
||||
Assert.Contains("presentation-reset", order);
|
||||
|
|
@ -1966,6 +1969,34 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmation_WaitsForOldStreamingWindowBeforeFreshGeneration()
|
||||
{
|
||||
var harness = new Harness(worldReady: true);
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
harness.Logout.IsCharacterLogOffConfirmed = true;
|
||||
harness.Streaming.ResetResult = false;
|
||||
harness.Logout.OnComplete = () =>
|
||||
harness.Controller.ResetGenerationPresentation();
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(RuntimeLogoutStage.Confirmed, harness.Transit.LogoutStage);
|
||||
Assert.Equal(0, harness.Logout.CompleteCalls);
|
||||
Assert.Equal(1, harness.Streaming.ResetCalls);
|
||||
|
||||
// StreamingController.Tick advances the retained retirement between
|
||||
// these controller ticks. Once converged, the logout transaction may
|
||||
// expose the next generation. Its reset callback consumes the same
|
||||
// retirement instead of beginning a second one.
|
||||
harness.Streaming.ResetResult = true;
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
|
||||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
Assert.Equal(2, harness.Streaming.ResetCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public sealed class ClientCommandRequestsTests
|
|||
{
|
||||
{ ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode },
|
||||
{ ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode },
|
||||
{ ClientCommandRequests.BuildSoulEmote, ClientCommandRequests.SoulEmoteOpcode },
|
||||
{ ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode },
|
||||
{ ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ public sealed class ServerMessageTests
|
|||
Assert.Equal(5u, parsed.Value.ChatType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_PreservesEmbeddedCommandResponseNewlines()
|
||||
{
|
||||
const string text = "@acecommands\n@help\n@teleport";
|
||||
byte[] msg = PackString16L(text);
|
||||
byte[] body = new byte[4 + msg.Length + 4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode);
|
||||
Array.Copy(msg, 0, body, 4, msg.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + msg.Length), 0u);
|
||||
|
||||
ServerMessage.Parsed parsed = Assert.IsType<ServerMessage.Parsed>(
|
||||
ServerMessage.TryParse(body));
|
||||
|
||||
Assert.Equal(text, parsed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -77,6 +77,18 @@ public sealed class WorldSessionChatTests
|
|||
Assert.Throws<ArgumentNullException>(() => session.SendTalk(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendSoulEmote_EmitsRetailGameAction()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendSoulEmote("waves.");
|
||||
|
||||
Assert.Equal(ClientCommandRequests.BuildSoulEmote(1u, "waves."), captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendTeleportToLifestone_EmitsRetailGameAction()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,6 +17,22 @@ public sealed class ChatCommandTargetStateTests
|
|||
Assert.Equal("Caith", targets.LastOutgoingTellTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TracksIndependentMonarchAndPatronReplyTargetsFromLegacyBroadcasts()
|
||||
{
|
||||
var chat = new ChatLog();
|
||||
using var targets = new ChatCommandTargetState(chat);
|
||||
|
||||
// 0x0147 does not carry a sender GUID; the incoming sender name is
|
||||
// nevertheless authoritative for retail's monarch/patron reply keys.
|
||||
chat.OnChannelBroadcast(0x4000u, "Monarch", "orders");
|
||||
chat.OnChannelBroadcast(0x2000u, "Patron", "hello");
|
||||
chat.OnChannelBroadcast(0x4000u, "New Monarch", "new orders");
|
||||
|
||||
Assert.Equal("New Monarch", targets.LastMonarchSender);
|
||||
Assert.Equal("Patron", targets.LastPatronSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSessionForgetsTargetsButPreservesTranscript()
|
||||
{
|
||||
|
|
@ -29,6 +45,8 @@ public sealed class ChatCommandTargetStateTests
|
|||
|
||||
Assert.Null(targets.LastIncomingTellSender);
|
||||
Assert.Null(targets.LastOutgoingTellTarget);
|
||||
Assert.Null(targets.LastMonarchSender);
|
||||
Assert.Null(targets.LastPatronSender);
|
||||
Assert.Equal(2, chat.Count);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ public sealed class InventoryFailureMessagesTests
|
|||
[InlineData(
|
||||
InventoryRequestKind.SplitToContainer, "Arrows", 0x36u,
|
||||
"The Arrows can't be split - action cancelled")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Move, "Sword", 0u,
|
||||
"The Sword can't be moved")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Wield, "Sword", 0x1Du,
|
||||
"The Sword can't be wielded - you're too busy")]
|
||||
public void ComposeMatchesServerSaysAttemptFailed(
|
||||
InventoryRequestKind kind,
|
||||
string name,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Input;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using DatReaderWriter;
|
||||
using Xunit;
|
||||
|
|
@ -9,84 +10,13 @@ using Xunit;
|
|||
namespace AcDream.Core.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP8: pins <see cref="RetailActionIdentityTable"/>'s agreement
|
||||
/// with <see cref="KeyBindings.RetailDefaults"/> — for every <see cref="InputAction"/>
|
||||
/// this slice's table resolves, the UNION of DAT default bindings across every DAT
|
||||
/// row mapped to that action must equal <c>KeyBindings.RetailDefaults()</c>'s chord
|
||||
/// set for it. Per the slice contract: "investigate + report any disagreement rather
|
||||
/// than silently preferring one." Skips cleanly when the installed dats are
|
||||
/// unavailable (CI), matching every other live-DAT conformance test in this project.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Two real, byte-verified disagreements survive after the mechanism fixes</b>
|
||||
/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this
|
||||
/// slice's table; both are PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// gaps/design choices this slice does not touch, listed in
|
||||
/// <see cref="KnownRetailDefaultsDisagreements"/> with citations). A THIRD
|
||||
/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED
|
||||
/// at the M2 rework: <see cref="RetailActionIdentityTable"/> no longer maps InputMap
|
||||
/// 0x6 to any <see cref="InputAction"/> at all (the aliasing that produced two
|
||||
/// independent rows fighting over one live target — M2, 2026-08-11 review), so this
|
||||
/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches
|
||||
/// <c>RetailDefaults()</c> exactly for all twelve Camera actions with no allowlist
|
||||
/// entry needed:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c>
|
||||
/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate
|
||||
/// "modifier" to report when the primary key and the modifier are the same physical
|
||||
/// key). <c>RetailDefaults()</c> deliberately encodes <c>Modifiers=Shift</c> anyway —
|
||||
/// its own comment (K-fix1, 2026-04-26) explains the OS echoes
|
||||
/// <c>CurrentModifiers=Shift</c> alongside a Shift key-DOWN event, so the chord must
|
||||
/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT
|
||||
/// artifact this slice's reader faithfully reproduces.</description></item>
|
||||
/// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9
|
||||
/// counterpart).</b> The DAT's own default
|
||||
/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" —
|
||||
/// <c>UseQuickSlot_N</c>), NOT to the separate "Select Quickslot N" action id
|
||||
/// (<c>SelectQuickSlot_N</c>, DAT action ids <c>0x1000004E-56</c>) — those carry NO
|
||||
/// default binding at all in the shipped DAT. <c>RetailDefaults()</c>'s own comment
|
||||
/// (citing <c>gmToolbarUI::ListenToGlobalMessage @0x004BE4E0</c>) asserts retail's
|
||||
/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw
|
||||
/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch
|
||||
/// function's own modifier branching). Both readings are independently retail-
|
||||
/// sourced; reconciling them needs the decompiled dispatch function, out of scope
|
||||
/// here. Reported, not silently resolved either way.</description></item>
|
||||
/// </list>
|
||||
/// Campaign KB's installed-DAT contract: all 306 user-bindable ActionMap rows
|
||||
/// have distinct live identities and their default chord sets match the two
|
||||
/// retail MasterInputMaps exactly. Missing, aliased, or guessed rows fail here.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class RetailActionIdentityRoundTripTests
|
||||
{
|
||||
/// <summary>Actions with a citation-backed, pre-existing reason their DAT-union
|
||||
/// default set legitimately differs from <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// — see class doc. Every other mapped action must match exactly.</summary>
|
||||
private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new()
|
||||
{
|
||||
InputAction.MovementWalkMode,
|
||||
InputAction.UseQuickSlot_1,
|
||||
InputAction.UseQuickSlot_2,
|
||||
InputAction.UseQuickSlot_3,
|
||||
InputAction.UseQuickSlot_4,
|
||||
InputAction.UseQuickSlot_5,
|
||||
InputAction.UseQuickSlot_6,
|
||||
InputAction.UseQuickSlot_7,
|
||||
InputAction.UseQuickSlot_8,
|
||||
InputAction.UseQuickSlot_9,
|
||||
// Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's
|
||||
// own "Select Quickslot N" action ids carry NO default binding at all —
|
||||
// RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled
|
||||
// dispatch function's runtime modifier check, not the raw keymap default.
|
||||
InputAction.SelectQuickSlot_1,
|
||||
InputAction.SelectQuickSlot_2,
|
||||
InputAction.SelectQuickSlot_3,
|
||||
InputAction.SelectQuickSlot_4,
|
||||
InputAction.SelectQuickSlot_5,
|
||||
InputAction.SelectQuickSlot_6,
|
||||
InputAction.SelectQuickSlot_7,
|
||||
InputAction.SelectQuickSlot_8,
|
||||
InputAction.SelectQuickSlot_9,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults()
|
||||
{
|
||||
|
|
@ -98,13 +28,44 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
Assert.Equal(306, snapshot!.Rows.Count);
|
||||
var unresolvedRows = snapshot.Rows
|
||||
.Where(row => !RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out _))
|
||||
.Select(row => $"0x{row.InputMapId:X8}/0x{row.ActionId:X8}")
|
||||
.ToArray();
|
||||
Assert.Empty(unresolvedRows);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Count);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Values.Distinct().Count());
|
||||
Assert.Equal(306, RetailActionIdentityTable.ReverseMap.Count);
|
||||
|
||||
var optionIds = new HashSet<uint>();
|
||||
foreach (RetailActionMapRow row in snapshot.Rows.Where(
|
||||
static row => row.InputMapId == 0x10000008u))
|
||||
{
|
||||
Assert.True(RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out InputAction action));
|
||||
Assert.True(
|
||||
RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId),
|
||||
$"CharacterSettings row 0x{row.ActionId:X8} has no PlayerOption id");
|
||||
Assert.True(CharacterOptionTable.TryGet(optionId, out _));
|
||||
Assert.True(optionIds.Add(optionId), $"duplicate PlayerOption id 0x{optionId:X2}");
|
||||
}
|
||||
Assert.Equal(48, optionIds.Count);
|
||||
|
||||
KeyBindings retailDefaults = KeyBindings.RetailDefaults();
|
||||
|
||||
// Aggregate DAT default chords by resolved InputAction — a single action can
|
||||
// be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair).
|
||||
var datChordsByAction = new Dictionary<InputAction, HashSet<KeyChord>>();
|
||||
var unresolvedScanCodes = new List<string>();
|
||||
foreach (RetailActionMapRow row in snapshot!.Rows)
|
||||
foreach (RetailActionMapRow row in snapshot.Rows)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action))
|
||||
continue;
|
||||
|
|
@ -125,15 +86,12 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
}
|
||||
|
||||
Assert.True(datChordsByAction.Count > 100,
|
||||
$"expected >100 mapped actions, got {datChordsByAction.Count}");
|
||||
Assert.Equal(306, datChordsByAction.Count);
|
||||
Assert.Empty(unresolvedScanCodes);
|
||||
|
||||
var mismatches = new List<string>();
|
||||
foreach ((InputAction action, HashSet<KeyChord> datChords) in datChordsByAction)
|
||||
{
|
||||
if (KnownRetailDefaultsDisagreements.Contains(action)) continue;
|
||||
|
||||
var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet();
|
||||
if (!datChords.SetEquals(acdreamChords))
|
||||
{
|
||||
|
|
@ -144,8 +102,7 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0,
|
||||
$"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements "
|
||||
+ "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n"
|
||||
$"{mismatches.Count} DAT-vs-RetailDefaults() disagreements:\n"
|
||||
+ string.Join("\n", mismatches));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,38 @@ public sealed class RetailActionMapReaderTests
|
|||
Assert.Empty(row.DefaultBindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_PreservesRetailInputMapConflictPolicy()
|
||||
{
|
||||
var actionMap = new ActionMap
|
||||
{
|
||||
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>(),
|
||||
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>
|
||||
{
|
||||
[0x10000003u] = new InputsConflictsValue
|
||||
{
|
||||
InputMap = 0x10000003u,
|
||||
ConflictingInputMaps = new List<uint>
|
||||
{
|
||||
0x10000003u,
|
||||
0x10000002u,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
|
||||
|
||||
RetailActionMapSnapshot snapshot = Assert.IsType<RetailActionMapSnapshot>(
|
||||
RetailActionMapReader.Read(dats));
|
||||
|
||||
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000003u));
|
||||
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000002u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
|
||||
Assert.True(snapshot.InputMapsConflict(0xDEADBEEFu, 0xDEADBEEFu));
|
||||
Assert.False(snapshot.InputMapsConflict(0xDEADBEEFu, 0x10000003u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
|
||||
{
|
||||
|
|
@ -245,5 +277,12 @@ public sealed class RetailActionMapReader_LiveDatTests
|
|||
Assert.Equal(2, moveForward.DefaultBindings.Count);
|
||||
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W
|
||||
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW
|
||||
|
||||
// The authored combat modes intentionally share the five attack/aim
|
||||
// keys. Retail's conflict table keeps those mode-local contexts apart;
|
||||
// Configure Keyboard must not erase one mode while editing another.
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000005u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000004u, 0x10000005u));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests
|
|||
Assert.Equal(2, delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
const uint corpse = 0x70000010u;
|
||||
|
||||
Assert.True(state.RequestOpen(corpse, isCorpse: true));
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.Equal(1, state.OpenedCorpseCount);
|
||||
|
||||
state.ApplyViewContents(corpse);
|
||||
state.ApplyClose(corpse);
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.True(state.SetCorpseDeleted(corpse));
|
||||
Assert.False(state.HasCorpseBeenOpened(corpse));
|
||||
|
||||
state.RequestOpen(corpse, isCorpse: true);
|
||||
Assert.True(state.Reset());
|
||||
Assert.False(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.Equal(0, state.OpenedCorpseCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
const uint corpse = 0x70000011u;
|
||||
|
||||
Assert.True(state.RequestOpen(corpse));
|
||||
Assert.False(state.RequestOpen(corpse, isCorpse: true));
|
||||
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
}
|
||||
|
||||
private static ExternalContainerState Open(uint id)
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class InventoryContainerPlacementPolicyTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void FullItemCapacityRejectsNewItemButAllowsReorder()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
ItemsCapacity = 1,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 2u });
|
||||
objects.MoveItem(2u, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 3u });
|
||||
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.ItemCapacityFull,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player));
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.None,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerCycleAndTradeAreRejected()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24,
|
||||
});
|
||||
objects.MoveItem(11u, 10u, 0);
|
||||
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.RecursiveContainment,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player));
|
||||
|
||||
objects.Get(10u)!.TradeState = 1;
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.SourceBeingTraded,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullMessageMatchesRetailContainerTypeBranches()
|
||||
{
|
||||
var player = new ClientObject { ObjectId = Player, Name = "Backpack" };
|
||||
var bag = new ClientObject { ObjectId = 2u, Name = "Pack" };
|
||||
Assert.Equal(
|
||||
"Backpack is completely full!",
|
||||
InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
InventoryContainerPlacementRejection.ItemCapacityFull,
|
||||
null,
|
||||
player,
|
||||
Player));
|
||||
Assert.Equal(
|
||||
"The Pack can fit no more containers!",
|
||||
InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
InventoryContainerPlacementRejection.ContainerCapacityFull,
|
||||
null,
|
||||
bag,
|
||||
Player));
|
||||
}
|
||||
}
|
||||
|
|
@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests
|
|||
Assert.Equal(ItemPolicyActionKind.Reject,
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { TradeState = 1 })).Actions).Kind);
|
||||
Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You cannot use the item because you are trading it",
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { TradeState = 1 })).Actions).Message);
|
||||
Assert.Equal("You must wield the item to use it",
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages()
|
||||
{
|
||||
var source = OwnedDirect() with
|
||||
{
|
||||
Name = "mana stone",
|
||||
Useability = 0x00080008u,
|
||||
TargetType = (uint)ItemType.Misc,
|
||||
};
|
||||
var target = Obj(0x6002) with
|
||||
{
|
||||
Name = "armor",
|
||||
Type = ItemType.Armor,
|
||||
ContainerId = Player,
|
||||
OwnedByPlayer = true,
|
||||
};
|
||||
|
||||
var missing = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
});
|
||||
Assert.Equal("Select your target before using the mana stone",
|
||||
Assert.Single(missing.Actions).Message);
|
||||
|
||||
var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
SelectedTarget = target,
|
||||
});
|
||||
Assert.Equal("Cannot use the mana stone with the armor",
|
||||
Assert.Single(incompatible.Actions).Message);
|
||||
|
||||
var traded = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
SelectedTarget = target with { TradeState = 1 },
|
||||
});
|
||||
Assert.Equal("You can't use the mana stone on an item you are trading",
|
||||
Assert.Single(traded.Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests
|
|||
PlayerOnGround = false,
|
||||
});
|
||||
Assert.False(airborne.ReturnValue);
|
||||
Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind);
|
||||
Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message);
|
||||
|
||||
var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 });
|
||||
Assert.True(split.ReturnValue);
|
||||
|
|
@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests
|
|||
var alreadyWorld = ItemInteractionPolicy.DecidePlacement(
|
||||
PlaceOnGround(item with { IsIn3DView = true }));
|
||||
Assert.False(alreadyWorld.ReturnValue);
|
||||
Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -204,6 +204,28 @@ public sealed class VendorStagingListTests
|
|||
Assert.False(list.TryGet(ItemB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 2);
|
||||
list.Add(ItemB, 9);
|
||||
const uint splitGuid = 0x60000003u;
|
||||
int fired = 0;
|
||||
list.Changed += () => fired++;
|
||||
|
||||
Assert.True(list.Replace(ItemA, splitGuid));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
new VendorStagingEntry(splitGuid, 2),
|
||||
new VendorStagingEntry(ItemB, 9),
|
||||
},
|
||||
list.Entries);
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRemovesEveryEntryAndFiresChangedOnce()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,4 +59,69 @@ public sealed class LiveChatCommandRouteTests
|
|||
route.Publish(new SendServerCommandCmd("@stale"));
|
||||
Assert.Equal(6, sent.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Say_ConsumesValidDatPoseAndLeavesUnknownTokenAsSpeech()
|
||||
{
|
||||
using var communication = new RuntimeCommunicationState();
|
||||
using var character = new RuntimeCharacterState();
|
||||
var sent = new List<string>();
|
||||
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
|
||||
_ => { },
|
||||
communication,
|
||||
communication.Chat,
|
||||
communication.TurbineChat,
|
||||
character,
|
||||
() => 0x50000001u,
|
||||
text => sent.Add($"talk:{text}"),
|
||||
(_, _) => { },
|
||||
(_, _) => { },
|
||||
(_, _, _, _, _, _) => { },
|
||||
ResolvePose: command => string.Equals(
|
||||
command,
|
||||
"wave",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? new RetailChatPose(0x13000087u, "wave.", "waves.")
|
||||
: null,
|
||||
ExecuteMotion: motion => sent.Add($"motion:{motion:X8}"),
|
||||
SendSoulEmote: text => sent.Add($"soul:{text}")));
|
||||
route.Activate();
|
||||
|
||||
route.Publish(new SendChatCmd(
|
||||
ChatChannelKind.Say,
|
||||
null,
|
||||
"hello *WAVE* there *not-a-pose*"));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"motion:13000087",
|
||||
"soul:waves.",
|
||||
"talk:hello there *not-a-pose*",
|
||||
],
|
||||
sent);
|
||||
ChatEntry local = Assert.Single(communication.Chat.Snapshot());
|
||||
Assert.Equal(ChatKind.SoulEmote, local.Kind);
|
||||
Assert.Equal("You", local.Sender);
|
||||
Assert.Equal("wave.", local.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Say_ContainingOnlyValidPoseDoesNotSendEmptyTalk()
|
||||
{
|
||||
using var communication = new RuntimeCommunicationState();
|
||||
using var character = new RuntimeCharacterState();
|
||||
var sent = new List<string>();
|
||||
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
|
||||
_ => { }, communication, communication.Chat,
|
||||
communication.TurbineChat, character, () => 1u,
|
||||
text => sent.Add($"talk:{text}"), (_, _) => { }, (_, _) => { },
|
||||
(_, _, _, _, _, _) => { },
|
||||
ResolvePose: _ => new RetailChatPose(7u, string.Empty, string.Empty),
|
||||
ExecuteMotion: motion => sent.Add($"motion:{motion}")));
|
||||
route.Activate();
|
||||
|
||||
route.Publish(new SendChatCmd(ChatChannelKind.Say, null, " *wave* "));
|
||||
|
||||
Assert.Equal(["motion:7"], sent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Chat;
|
||||
|
||||
public sealed class RetailPublicChatParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvalidAndUnmatchedTokensRemainLiteral()
|
||||
{
|
||||
string text = RetailPublicChatParser.ExtractPoses(
|
||||
"*unknown* and *unfinished",
|
||||
_ => null,
|
||||
_ => throw new Xunit.Sdk.XunitException("must not execute"));
|
||||
|
||||
Assert.Equal("*unknown* and *unfinished", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleValidStarAndAngleTokensAreRemovedInOrder()
|
||||
{
|
||||
var motions = new List<uint>();
|
||||
string text = RetailPublicChatParser.ExtractPoses(
|
||||
"a *one* b <two> c",
|
||||
command => command switch
|
||||
{
|
||||
"one" => new RetailChatPose(1u, "", ""),
|
||||
"two" => new RetailChatPose(2u, "", ""),
|
||||
_ => null,
|
||||
},
|
||||
pose => motions.Add(pose.MotionCommand));
|
||||
|
||||
Assert.Equal("a b c", text);
|
||||
Assert.Equal([1u, 2u], motions);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,7 @@ public sealed class RuntimeCombatAttackStateTests
|
|||
now += 0.5d;
|
||||
controller.ReleaseAttack();
|
||||
Assert.Single(sent);
|
||||
Assert.True(controller.RepeatAttackInProgress);
|
||||
|
||||
controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
|
|
@ -161,6 +162,7 @@ public sealed class RuntimeCombatAttackStateTests
|
|||
|
||||
Assert.Equal(1, cancels);
|
||||
Assert.Single(sent);
|
||||
Assert.False(controller.RepeatAttackInProgress);
|
||||
Assert.False(controller.BuildInProgress);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,26 @@ public sealed class RuntimeInventoryStateTests
|
|||
Assert.Empty(inventory.Shortcuts.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingAnObjectRetiresRetailOpenedCorpseHistory()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
using var inventory = new RuntimeInventoryState(entities);
|
||||
const uint corpse = 0x70000020u;
|
||||
inventory.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = corpse,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Corpse,
|
||||
});
|
||||
inventory.ExternalContainers.RequestOpen(corpse, isCorpse: true);
|
||||
Assert.True(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
|
||||
|
||||
Assert.True(inventory.Objects.Remove(corpse));
|
||||
|
||||
Assert.False(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
|
||||
Assert.Equal(0, inventory.CaptureOwnership().OpenedCorpseCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ namespace AcDream.Runtime.Tests.Gameplay;
|
|||
|
||||
public sealed class RuntimeLocalPlayerMovementStateTests
|
||||
{
|
||||
private static PhysicsEngine MakeFlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
|
||||
{
|
||||
|
|
@ -59,6 +77,30 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
|||
Assert.False(movement.View.Snapshot.HasCommandInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeCommandsFinishJumpAndStopThroughCanonicalController()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SeedPlacementForTest(
|
||||
new Vector3(96f, 96f, 50f),
|
||||
0xA9B40001u,
|
||||
new Vector3(96f, 96f, 50f));
|
||||
using var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = controller,
|
||||
};
|
||||
|
||||
controller.Update(0.25f, new MovementInput(Jump: true));
|
||||
Assert.True(movement.View.JumpCharge.IsCharging);
|
||||
Assert.True(movement.Execute(RuntimeMovementCommand.FinishJump));
|
||||
Assert.False(movement.View.JumpCharge.IsCharging);
|
||||
|
||||
controller.Update(1f / 60f, new MovementInput(Forward: true));
|
||||
Assert.False(movement.View.IsStandingStill);
|
||||
Assert.True(movement.Execute(RuntimeMovementCommand.StopCompletely));
|
||||
Assert.Equal(MotionCommand.Ready, controller.Motion.RawState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
|
||||
{
|
||||
|
|
@ -123,6 +165,65 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandMotionUsesCanonicalControllerAndEmitsOneMovementEdge()
|
||||
{
|
||||
const uint afkState = 0x43000118u;
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
using var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = controller,
|
||||
};
|
||||
|
||||
Assert.True(movement.ExecuteMotion(afkState));
|
||||
Assert.Equal(afkState, controller.Motion.RawState.ForwardCommand);
|
||||
|
||||
MovementResult first = controller.Update(1f / 60f, default);
|
||||
MovementResult second = controller.Update(1f / 60f, default);
|
||||
|
||||
Assert.True(first.ShouldSendMovementEvent);
|
||||
RawMotionState outbound =
|
||||
LocalPlayerOutboundController.BuildRawMotionState(first);
|
||||
Assert.Equal(afkState, outbound.ForwardCommand);
|
||||
Assert.False(second.ShouldSendMovementEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundRawOverridePreservesRetailActionAndStamp()
|
||||
{
|
||||
const uint cheer = 0x1300004Cu;
|
||||
var raw = new RawMotionState();
|
||||
raw.AddAction(
|
||||
cheer,
|
||||
speed: 1f,
|
||||
actionStamp: 7u,
|
||||
autonomous: true);
|
||||
var result = new MovementResult(
|
||||
default,
|
||||
default,
|
||||
0u,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
RawMotionStateOverride: new RawMotionState(raw));
|
||||
|
||||
RawMotionState firstRaw =
|
||||
LocalPlayerOutboundController.BuildRawMotionState(result);
|
||||
RawMotionAction firstAction = Assert.Single(firstRaw.Actions);
|
||||
|
||||
Assert.Equal((ushort)0x004C, firstAction.Command);
|
||||
Assert.Equal(7, firstAction.Stamp);
|
||||
Assert.True(firstAction.Autonomous);
|
||||
|
||||
raw.RemoveAction();
|
||||
Assert.Single(firstRaw.Actions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ namespace AcDream.UI.Abstractions.Tests.Input;
|
|||
/// non-modifier chord is reported via the supplied callback and the
|
||||
/// dispatcher does NOT fire normal action events for that chord. Esc
|
||||
/// cancels capture (callback receives a sentinel <c>default</c> chord).
|
||||
/// Modifier-only key transitions don't complete capture — the user can
|
||||
/// dial in Shift / Ctrl / Alt before pressing the trigger key.
|
||||
/// A modifier key is captured on release when used alone, or remains a
|
||||
/// modifier prefix when another key is pressed while it is held.
|
||||
/// </summary>
|
||||
public class InputDispatcherCaptureTests
|
||||
{
|
||||
|
|
@ -93,6 +93,23 @@ public class InputDispatcherCaptureTests
|
|||
Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_modifier_released_alone_becomes_bare_primary_key()
|
||||
{
|
||||
var (dispatcher, kb, _, _, fired) = Build();
|
||||
KeyChord? captured = null;
|
||||
dispatcher.BeginCapture(chord => captured = chord);
|
||||
|
||||
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
|
||||
Assert.Null(captured);
|
||||
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
|
||||
|
||||
Assert.Equal(
|
||||
new KeyChord(Key.ShiftLeft, ModifierMask.None),
|
||||
captured);
|
||||
Assert.Empty(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_completes_with_modifier_state()
|
||||
{
|
||||
|
|
@ -106,6 +123,26 @@ public class InputDispatcherCaptureTests
|
|||
Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_consumes_mouse_button_as_retail_qualified_control()
|
||||
{
|
||||
var (dispatcher, _, mouse, bindings, fired) = Build();
|
||||
var left = new KeyChord(
|
||||
InputDispatcher.MouseButtonToKey(MouseButton.Left),
|
||||
ModifierMask.Ctrl,
|
||||
Device: 1);
|
||||
bindings.Add(new Binding(left, InputAction.ToggleInventoryPanel));
|
||||
mouse.WantCaptureMouse = true;
|
||||
|
||||
KeyChord? captured = null;
|
||||
dispatcher.BeginCapture(chord => captured = chord);
|
||||
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.Ctrl);
|
||||
|
||||
Assert.Equal(left, captured);
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
Assert.Empty(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelCapture_invokes_callback_with_default_chord_and_clears_state()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -101,6 +101,22 @@ public class InputDispatcherTests
|
|||
fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_scope_retail_duplicate_chord_fires_every_distinct_action()
|
||||
{
|
||||
var (_, kb, _, bindings, fired) = Build();
|
||||
var chord = new KeyChord(Key.Number1, ModifierMask.Alt);
|
||||
bindings.Add(new Binding(chord, InputAction.ToggleFloatingChatWindow1));
|
||||
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_10));
|
||||
|
||||
kb.EmitKeyDown(Key.Number1, ModifierMask.Alt);
|
||||
|
||||
Assert.Equal(
|
||||
[(InputAction.ToggleFloatingChatWindow1, ActivationType.Press),
|
||||
(InputAction.UseQuickSlot_10, ActivationType.Press)],
|
||||
fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
|
||||
{
|
||||
|
|
@ -188,6 +204,30 @@ public class InputDispatcherTests
|
|||
Assert.Empty(fired); // no longer held
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailBareLeftShiftBinding_NormalizesSilkSelfModifierBit()
|
||||
{
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
kb,
|
||||
mouse,
|
||||
KeyBindings.RetailDefaults());
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
|
||||
|
||||
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
|
||||
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
|
||||
|
||||
Assert.Contains(
|
||||
(InputAction.MovementWalkMode, ActivationType.Press),
|
||||
fired);
|
||||
Assert.Contains(
|
||||
(InputAction.MovementWalkMode, ActivationType.Release),
|
||||
fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -104,10 +104,19 @@ public class KeyBindingsJsonTests
|
|||
var path = TempFile();
|
||||
try
|
||||
{
|
||||
// User customizes ONE action — replace MovementForward with Q.
|
||||
var custom = new KeyBindings();
|
||||
custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward));
|
||||
custom.SaveToFile(path);
|
||||
// A pre-v7 partial file customizes ONE action. Missing actions in
|
||||
// those schemas mean "not stored yet", so they default-merge.
|
||||
const string legacyJson = """
|
||||
{
|
||||
"version": 6,
|
||||
"actions": {
|
||||
"MovementForward": [
|
||||
{ "key": "Q" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(path, legacyJson);
|
||||
|
||||
var loaded = KeyBindings.LoadOrDefault(path);
|
||||
|
||||
|
|
@ -128,6 +137,32 @@ public class KeyBindingsJsonTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_preserves_explicitly_unbound_retail_action()
|
||||
{
|
||||
var path = TempFile();
|
||||
try
|
||||
{
|
||||
KeyBindings defaults = KeyBindings.RetailDefaults();
|
||||
var customized = new KeyBindings();
|
||||
foreach (Binding binding in defaults.All)
|
||||
{
|
||||
if (binding.Action != InputAction.ToggleHelp)
|
||||
customized.Add(binding);
|
||||
}
|
||||
|
||||
customized.SaveToFile(path);
|
||||
KeyBindings loaded = KeyBindings.LoadOrDefault(path);
|
||||
|
||||
Assert.Empty(loaded.ForAction(InputAction.ToggleHelp));
|
||||
Assert.NotEmpty(loaded.ForAction(InputAction.ToggleOptionsPanel));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_handles_version_zero_legacy_file()
|
||||
{
|
||||
|
|
@ -193,17 +228,16 @@ public class KeyBindingsJsonTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect()
|
||||
public void LoadOrDefault_migratesV5CtrlNumberQuickSlotFromSelectBackToRetailUse()
|
||||
{
|
||||
var path = TempFile();
|
||||
try
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": 1,
|
||||
"version": 5,
|
||||
"actions": {
|
||||
"UseQuickSlot_5": [
|
||||
{ "key": "Number5" },
|
||||
"SelectQuickSlot_5": [
|
||||
{ "key": "Number5", "mod": "Ctrl" }
|
||||
]
|
||||
}
|
||||
|
|
@ -214,9 +248,8 @@ public class KeyBindingsJsonTests
|
|||
var loaded = KeyBindings.LoadOrDefault(path);
|
||||
|
||||
Assert.Equal(InputAction.UseQuickSlot_5,
|
||||
loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action);
|
||||
Assert.Equal(InputAction.SelectQuickSlot_5,
|
||||
loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action);
|
||||
Assert.Empty(loaded.ForAction(InputAction.SelectQuickSlot_5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -76,9 +76,11 @@ public class KeyBindingsRetailTests
|
|||
{
|
||||
var b = KeyBindings.RetailDefaults();
|
||||
var binds = b.ForAction(InputAction.MovementWalkMode).ToList();
|
||||
Assert.NotEmpty(binds);
|
||||
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
|
||||
Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft);
|
||||
Binding binding = Assert.Single(binds);
|
||||
Assert.Equal(ActivationType.Hold, binding.Activation);
|
||||
Assert.Equal(
|
||||
new KeyChord(Key.ShiftLeft, ModifierMask.None),
|
||||
binding.Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -135,14 +137,15 @@ public class KeyBindingsRetailTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickSlot_5_bareUsesAndCtrlSelects()
|
||||
public void QuickSlot_5_BareAndCtrlBothUseRetailAction()
|
||||
{
|
||||
var b = KeyBindings.RetailDefaults();
|
||||
var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press);
|
||||
var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press);
|
||||
|
||||
Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action);
|
||||
Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action);
|
||||
Assert.Equal(InputAction.UseQuickSlot_5, ctrl?.Action);
|
||||
Assert.Empty(b.ForAction(InputAction.SelectQuickSlot_5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -216,6 +219,18 @@ public class KeyBindingsRetailTests
|
|||
var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList();
|
||||
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None));
|
||||
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None));
|
||||
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InputAction.CombatAimLow)]
|
||||
[InlineData(InputAction.CombatAimMedium)]
|
||||
[InlineData(InputAction.CombatAimHigh)]
|
||||
public void Missile_aim_actions_use_retail_press_and_release_edges(InputAction action)
|
||||
{
|
||||
var binding = Assert.Single(KeyBindings.RetailDefaults().ForAction(action));
|
||||
Assert.Equal(ActivationType.Hold, binding.Activation);
|
||||
Assert.Equal(InputScope.MissileCombat, binding.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue