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
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue