acdream/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs
Erik 2215c76c7e feat(combat): port retail basic combat bar
Mount authored gmCombatUI, share one press/hold/release request state machine across DAT buttons and keybindings, and recover the exact 1.0s/0.8s power timing from matching retail x86. The same timer fixes jump charge, while ready-stance, response queueing, auto-repeat, layout binding, migration, and conformance coverage keep behavior architectural rather than panel-local.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 14:03:28 +02:00

300 lines
9.2 KiB
C#

using System.IO;
using System.Linq;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// K.1c: <see cref="KeyBindings.LoadOrDefault"/> /
/// <see cref="KeyBindings.SaveToFile"/> round-trip + version migration.
/// Pins schema-stable behavior so future K.1d / Phase L panel-binding
/// edits don't accidentally break existing user keymap files.
/// </summary>
public class KeyBindingsJsonTests
{
private static string TempFile(string suffix = ".json")
{
var f = Path.Combine(Path.GetTempPath(),
$"acdream_kb_test_{System.Guid.NewGuid():N}{suffix}");
return f;
}
[Fact]
public void LoadOrDefault_missing_file_returns_RetailDefaults()
{
var path = TempFile();
// No file written.
var loaded = KeyBindings.LoadOrDefault(path);
var defaults = KeyBindings.RetailDefaults();
Assert.Equal(defaults.All.Count, loaded.All.Count);
}
[Fact]
public void Roundtrip_preserves_every_binding()
{
var path = TempFile();
try
{
var original = KeyBindings.RetailDefaults();
original.SaveToFile(path);
var loaded = KeyBindings.LoadOrDefault(path);
Assert.Equal(original.All.Count, loaded.All.Count);
// Every binding from the original must round-trip with structural
// equality (chord + action + activation).
foreach (var b in original.All)
{
Assert.Contains(loaded.All, x =>
x.Chord == b.Chord
&& x.Action == b.Action
&& x.Activation == b.Activation);
}
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_corrupt_file_returns_RetailDefaults()
{
var path = TempFile();
try
{
File.WriteAllText(path, "{ this is not valid JSON :: garbage");
var loaded = KeyBindings.LoadOrDefault(path);
var defaults = KeyBindings.RetailDefaults();
Assert.Equal(defaults.All.Count, loaded.All.Count);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_corrupt_file_does_NOT_overwrite_user_file()
{
var path = TempFile();
try
{
const string corrupt = "{ this is not valid JSON :: garbage";
File.WriteAllText(path, corrupt);
_ = KeyBindings.LoadOrDefault(path);
// The corrupt file must remain untouched on disk so the user
// can recover or hand-edit. We never silently blow it away.
Assert.Equal(corrupt, File.ReadAllText(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void Custom_binding_for_one_action_keeps_defaults_for_others()
{
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);
var loaded = KeyBindings.LoadOrDefault(path);
// Custom: MovementForward → Q (only — the user's file
// overrode all defaults for that action).
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
Assert.Single(fwd);
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord);
// Default-merged: MovementBackup still has its retail bindings
// because the user file didn't customize it.
var back = loaded.ForAction(InputAction.MovementBackup).ToList();
Assert.Contains(back, x => x.Chord == new KeyChord(Key.X, ModifierMask.None));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_handles_version_zero_legacy_file()
{
// A pretend "old schema" file with version=0 + just one action.
// Should still parse — unknown fields are ignored, missing
// actions get default-merged.
var path = TempFile();
try
{
const string legacyJson = """
{
"version": 0,
"actions": {
"MovementForward": [
{ "key": "W", "mod": "None" }
]
}
}
""";
File.WriteAllText(path, legacyJson);
var loaded = KeyBindings.LoadOrDefault(path);
// The custom binding survives.
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
Assert.Single(fwd);
// Default-merged: the user file didn't define MovementBackup
// so it gets the retail default chords.
var back = loaded.ForAction(InputAction.MovementBackup).ToList();
Assert.NotEmpty(back);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_preserves_Hold_activation()
{
var path = TempFile();
try
{
var original = new KeyBindings();
original.Add(new(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
InputAction.MovementWalkMode,
ActivationType.Hold));
original.SaveToFile(path);
var loaded = KeyBindings.LoadOrDefault(path);
var binds = loaded.ForAction(InputAction.MovementWalkMode).ToList();
// The user binding is preserved with Hold activation.
Assert.Contains(binds, x =>
x.Chord.Key == Key.ShiftLeft
&& x.Activation == ActivationType.Hold);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect()
{
var path = TempFile();
try
{
const string json = """
{
"version": 1,
"actions": {
"UseQuickSlot_5": [
{ "key": "Number5" },
{ "key": "Number5", "mod": "Ctrl" }
]
}
}
""";
File.WriteAllText(path, json);
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);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_migratesV2CombatPressBindingToHold()
{
var path = TempFile();
try
{
const string json = """
{
"version": 2,
"actions": {
"CombatHighAttack": [
{ "key": "F7" }
]
}
}
""";
File.WriteAllText(path, json);
var loaded = KeyBindings.LoadOrDefault(path);
Assert.Equal(InputAction.CombatHighAttack,
loaded.Find(
new KeyChord(Key.F7, ModifierMask.None),
ActivationType.Hold)?.Action);
Assert.Null(loaded.Find(
new KeyChord(Key.F7, ModifierMask.None),
ActivationType.Press));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_unknown_action_name_skipped_silently()
{
var path = TempFile();
try
{
const string json = """
{
"version": 1,
"actions": {
"NotARealActionName": [
{ "key": "W", "mod": "None" }
],
"MovementForward": [
{ "key": "Q", "mod": "None" }
]
}
}
""";
File.WriteAllText(path, json);
var loaded = KeyBindings.LoadOrDefault(path);
// The known action loaded; unknown action skipped without
// throwing.
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
Assert.Contains(fwd, x => x.Chord.Key == Key.Q);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void DefaultPath_lives_under_LocalAppData_acdream()
{
var path = KeyBindings.DefaultPath();
Assert.Contains("acdream", path);
Assert.EndsWith("keybinds.json", path);
}
}