Campaign OP slice OP9 code retirement (docs/plans/2026-08-10-options-panel-campaign.md
§OP9). The retail four-tab Options panel (OP1-OP8) is now acdream's one
in-client settings surface (D1) — this commit deletes the pre-retail-UI
surfaces it fully superseded. Pure retirement: no behavior change to
anything live, verified by dispositioning every one of the 15 src files
and 8 test files that referenced the deleted types before touching any
of them.
1. SettingsVM + SettingsPanel (the old F11 IPanel surface, unrendered
since Campaign V slice V11's ImGui deletion) deleted outright, plus
their two dedicated test files. IPanel/IPanelRenderer stay — ChatPanel/
DebugPanel/VitalsPanel still implement IPanel, so the contract does NOT
become unreferenced.
2. RuntimeSettingsController's SettingsVM binding seam deleted:
CreateViewModel, CreateViewModelBinding, RuntimeSettingsViewModelBinding,
the _viewModel field, UnbindViewModel, and every _viewModel? notification
call (ToggleFrameRate, LoadCharacterContext, RestoreDefaultCharacterContext,
SyncChatFromServerOptions). CreateViewModelBinding had zero production
callers (test-only). HasDraftPreview/DisplayPreview/AudioPreview stay on
IRuntimeSettingsPreviewSource (WorldRenderFrameBuilder and
SettingsParticleRangeSource still consume it) but now trivially mirror
the committed Display/Audio snapshot — HasDraftPreview was already
always false in production. GameWindowLifetime's matching
"settings view model" shutdown stage is deleted.
3. IDevToolsGameplayCommands + DevToolsGameplayCommands deleted from
GameplayInputCommandController.cs. All three members were dead:
ToggleSettingsPanel() had ZERO dispatch sites (ToggleOptionsPanel always
routed to _retained, never to _devTools); ToggleDebugPanel()/
FocusChatInput() had dispatch sites (F1/Ctrl+F1, Tab) but empty no-op
bodies (their ImGui DebugPanel/ChatPanel targets were already gone).
The two live dispatch sites are kept as inert `return true;` cases
(still consuming the key, matching the prior no-op's "handled"
contract) instead of falling through to a lower-priority scope.
SessionPlayerComposition.cs's `new DevToolsGameplayCommands()`
construction is removed. No `ToggleSettingsPanel` InputAction exists
(only `ToggleOptionsPanel`, rebound at OP3) — nothing to remove there
or from KeyBindings.RetailDefaults()/keymap fixtures.
4. GameplaySettings deleted entirely (the type, SettingsStore's
LoadGameplay/SaveGameplay/BuildGameplayObject, RuntimeSettingsController's
Gameplay property/SetAcceptLootPermits). Verified all 13 remaining
members (ToggleRun, AdvancedCombatUI, ShowTooltips,
VividTargetingIndicator, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, AllowGive, ShowHelm, ShowCloak, LockUI, UseMouseTurning,
AcceptLootPermits — the three combat ones already died at OP4/AP-196)
were ALREADY bound through CharacterOptionTable/
CharacterOptionsPageController's server-bit seam at OP4 before deleting
the client-local mirror — no (c)-case genuinely-client-local member
was found; disposition (b) covers 100% of the surface. SetUiLocked
rewritten to compare only against the last value actually pushed to
_runtimeTargets (MUST-FIX 4's guard), with no second store left to
read or write. LiveSessionRuntimeFactory's SetAcceptLootPermits binding
now sends the wire option only (the GameplaySettings write-behind call
removed as dead output). CharacterSettings/DisplaySettings/
AudioSettings/ChatSettings and their SettingsStore Load/Save surfaces
are UNTOUCHED per the campaign contract.
Per-file disposition (15 src + 8 test files that referenced
GameplaySettings before this commit):
- GameplaySettings.cs, SettingsVM.cs, SettingsPanel.cs: the types
themselves — deleted.
- SettingsStore.cs, RuntimeSettingsController.cs,
LiveSessionRuntimeFactory.cs: real usage — API deleted/rewritten.
- RetailUiRuntime.cs, InteractionRetainedUiComposition.cs,
SessionPlayerComposition.cs, CombatUiController.cs,
LiveCombatAttackOperations.cs, LivePresentationComposition.cs,
FrameRootComposition.cs, RuntimeCharacterState.cs,
CombatCameraTargetSource.cs: doc-comment-only or interface-name
substring matches (ICombatGameplaySettingsSource) — left as accurate
historical record, no forward reference to the deleted type.
- Tests: RuntimeSettingsControllerTests.cs and SettingsStoreTests.cs
rewritten (Gameplay-specific tests deleted; SaveDisplay/SaveAudio/
SaveChat tests re-targeted off the now-public methods instead of the
retired SettingsVM draft/Save() indirection); GameplaySettingsTests.cs/
SettingsVMTests.cs/SettingsPanelTests.cs deleted; the remaining three
(CharacterOptionCombatSettingsSourceTests.cs,
CombatCameraTargetSourceTests.cs, LiveCombatAttackOperationsTests.cs)
were comment/interface-name-only, untouched.
5. Register: AP-196 (OP4's partial GameplaySettings retirement, which left
five fields as write-behind mirrors) is fully retired now that the
record is gone outright — marked ~~AP-196~~ RETIRED with its retirement
note, active-row count 143 -> 142. No other row cited the deleted types
directly (AP-194/AP-193 cite CharacterOptionTable.cs, not
GameplaySettings.cs).
6. Settings.json migration honesty: SettingsStore no longer reads or
writes the "gameplay" top-level key, so an existing file carrying one
from a pre-OP9 build is neither parsed nor dropped — the existing
SaveSection raw-JSON-text preservation mechanism (unknown top-level
keys survive every subsequent save) carries it forward untouched.
Two new targeted tests
(LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads,
LeftoverGameplaySection_SurvivesAnUnrelatedSave) pin this.
InputAction.ToggleOptionsPanel's stale doc comment (still describing the
retired ImGui SettingsPanel) and a handful of other dangling doc
references (DisplaySettings.cs, ChatOpacityLink.cs,
SettingsDevToolsComposition.cs, InputDispatcherCaptureTests.cs) are
reworded to point at the current retail Options panel / OP8
KeyboardConfigController.
Build: dotnet build -c Release green, 0 errors. Tests: dotnet test -c
Release --no-build — 13,075 passed / 4 skipped / 0 failed (13,079 total),
down from the stated baseline of 13,155 passed / 4 skipped / 0 failed
(13,159 total) — the -80 delta is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface (three whole files plus the
Gameplay-specific cases trimmed from RuntimeSettingsControllerTests.cs/
SettingsStoreTests.cs), with zero regressions elsewhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
558 lines
21 KiB
C#
558 lines
21 KiB
C#
using System.IO;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
|
|
|
/// <summary>
|
|
/// L.0: <see cref="SettingsStore"/> reads / writes <c>settings.json</c>.
|
|
/// Tests use a temp-file path so they don't touch the user's
|
|
/// %LOCALAPPDATA% file.
|
|
/// </summary>
|
|
public sealed class SettingsStoreTests : System.IDisposable
|
|
{
|
|
private readonly string _tempPath;
|
|
|
|
public SettingsStoreTests()
|
|
{
|
|
// Unique per-test file under the system temp dir so parallel test
|
|
// runners don't clobber each other.
|
|
_tempPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"acdream-settings-test-{System.Guid.NewGuid():N}.json");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadDisplay_returns_defaults_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var loaded = store.LoadDisplay();
|
|
Assert.Equal(DisplaySettings.Default, loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveDisplay_then_LoadDisplay_round_trips_all_fields()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = new DisplaySettings(
|
|
Resolution: "2560x1440",
|
|
Fullscreen: true,
|
|
VSync: false,
|
|
FieldOfView: 100f,
|
|
Gamma: 1.4f,
|
|
ShowFps: true,
|
|
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra,
|
|
ParticleRange: ParticleRange.Extended);
|
|
|
|
store.SaveDisplay(original);
|
|
var loaded = store.LoadDisplay();
|
|
|
|
Assert.Equal(original, loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadDisplay_falls_back_to_defaults_when_file_is_corrupt()
|
|
{
|
|
File.WriteAllText(_tempPath, "{ this is not valid json");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
var loaded = store.LoadDisplay();
|
|
|
|
Assert.Equal(DisplaySettings.Default, loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadDisplay_falls_back_per_field_when_keys_missing()
|
|
{
|
|
// Partial file — only resolution set; everything else should
|
|
// pick up DisplaySettings.Default values.
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 1,
|
|
"display": { "resolution": "1366x768" }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
var loaded = store.LoadDisplay();
|
|
|
|
Assert.Equal("1366x768", loaded.Resolution);
|
|
Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen);
|
|
Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
|
|
Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
|
|
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadDisplay_invalid_particle_range_falls_back_to_default()
|
|
{
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 2,
|
|
"display": { "particleRange": "Unlimited" }
|
|
}
|
|
""");
|
|
|
|
var loaded = new SettingsStore(_tempPath).LoadDisplay();
|
|
|
|
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadDisplay_numeric_undefined_particle_range_falls_back_to_default()
|
|
{
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 2,
|
|
"display": { "particleRange": "999" }
|
|
}
|
|
""");
|
|
|
|
var loaded = new SettingsStore(_tempPath).LoadDisplay();
|
|
|
|
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveDisplay_preserves_unknown_top_level_keys()
|
|
{
|
|
// Forward-compat: a newer client may have written sections we
|
|
// don't know about (audio, gameplay). Saving display must not
|
|
// delete those, otherwise running an older client would silently
|
|
// drop the user's other-tab preferences.
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 1,
|
|
"display": { "resolution": "1280x720" },
|
|
"audio": { "master": 0.5, "music": 0.7 }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
|
|
|
|
var raw = File.ReadAllText(_tempPath);
|
|
Assert.Contains("\"audio\"", raw);
|
|
Assert.Contains("\"master\"", raw);
|
|
Assert.Contains("0.5", raw);
|
|
// And the new display value did get written.
|
|
Assert.Contains("1920x1080", raw);
|
|
}
|
|
|
|
// -- Audio section round-trip ----------------------------------------
|
|
|
|
[Fact]
|
|
public void LoadAudio_returns_defaults_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
Assert.Equal(AudioSettings.Default, store.LoadAudio());
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveAudio_then_LoadAudio_round_trips_all_fields()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = new AudioSettings(Master: 0.3f, Sfx: 0.9f, Ambient: 0.6f);
|
|
|
|
store.SaveAudio(original);
|
|
var loaded = store.LoadAudio();
|
|
|
|
Assert.Equal(original, loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadAudio_falls_back_per_field_when_keys_missing()
|
|
{
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 1,
|
|
"audio": { "master": 0.25 }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
var loaded = store.LoadAudio();
|
|
|
|
Assert.Equal(0.25f, loaded.Master);
|
|
Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx);
|
|
Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveAudio_preserves_display_section()
|
|
{
|
|
// Save display first, then audio — display values must survive.
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
store.SaveAudio(AudioSettings.Default with { Master = 0.4f });
|
|
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
Assert.Equal(0.4f, store.LoadAudio().Master);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveDisplay_after_SaveAudio_preserves_audio_section()
|
|
{
|
|
// Reverse order — audio must survive a subsequent display save.
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveAudio(AudioSettings.Default with { Ambient = 0.1f });
|
|
store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
|
|
|
|
Assert.Equal(0.1f, store.LoadAudio().Ambient);
|
|
Assert.True(store.LoadDisplay().ShowFps);
|
|
}
|
|
|
|
// -- Gameplay section: OP9 migration honesty ---------------------------
|
|
//
|
|
// Campaign OP slice OP9 (docs/plans/2026-08-10-options-panel-campaign.md
|
|
// §OP9) retired the Gameplay tab's client-local GameplaySettings record
|
|
// and Load/SaveGameplay outright — all 13 fields were already re-pointed
|
|
// to the server-bit seam (RuntimeCharacterOptionsState) at OP4. An
|
|
// EXISTING settings.json written by a pre-OP9 build can still carry a
|
|
// "gameplay" top-level block; these two tests pin that SettingsStore
|
|
// tolerates it (no Load* method reads it, so it neither throws nor is
|
|
// silently dropped) rather than requiring a migration step.
|
|
|
|
[Fact]
|
|
public void LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads()
|
|
{
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 2,
|
|
"display": { "resolution": "1366x768" },
|
|
"gameplay": { "lockUI": true, "toggleRun": false }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
Assert.Equal("1366x768", store.LoadDisplay().Resolution);
|
|
Assert.Equal(AudioSettings.Default, store.LoadAudio());
|
|
Assert.Equal(ChatSettings.Default, store.LoadChat());
|
|
}
|
|
|
|
[Fact]
|
|
public void LeftoverGameplaySection_SurvivesAnUnrelatedSave()
|
|
{
|
|
// The generic SaveSection round-trip preservation (raw-JSON-text
|
|
// carry-forward of unknown top-level keys, exercised elsewhere by
|
|
// SaveDisplay_preserves_unknown_top_level_keys) is exactly what
|
|
// keeps an orphaned "gameplay" block alive forever once nothing
|
|
// reads or writes it — this pins that mechanism for the specific
|
|
// section OP9 just retired.
|
|
File.WriteAllText(_tempPath, """
|
|
{
|
|
"version": 2,
|
|
"gameplay": { "lockUI": true, "showHelm": false }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(_tempPath);
|
|
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
|
|
|
|
var raw = File.ReadAllText(_tempPath);
|
|
Assert.Contains("\"gameplay\"", raw);
|
|
Assert.Contains("\"lockUI\": true", raw);
|
|
Assert.Contains("\"showHelm\": false", raw);
|
|
Assert.Contains("1920x1080", raw);
|
|
}
|
|
|
|
// -- Chat section round-trip ------------------------------------------
|
|
|
|
[Fact]
|
|
public void LoadChat_returns_defaults_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
Assert.Equal(ChatSettings.Default, store.LoadChat());
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveChat_then_LoadChat_round_trips_all_fields()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = new ChatSettings(
|
|
HearGeneralChat: false,
|
|
HearTradeChat: false,
|
|
HearLFGChat: false,
|
|
HearRoleplayChat: true,
|
|
HearSocietyChat: true,
|
|
AppearOffline: true,
|
|
ShowTimestamps: false,
|
|
FilterProfanity: false,
|
|
FontSize: 16f);
|
|
|
|
store.SaveChat(original);
|
|
Assert.Equal(original, store.LoadChat());
|
|
}
|
|
|
|
// -- Campaign CH slice CH6b: floating chat window filter round-trip ---
|
|
|
|
[Fact]
|
|
public void LoadChat_returns_retail_PostInit_filter_defaults_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(0x0000101Cu, loaded.ChatWindow1Filter);
|
|
Assert.Equal(0x00040C00u, loaded.ChatWindow2Filter);
|
|
Assert.Equal(0x00080000u, loaded.ChatWindow3Filter);
|
|
Assert.Equal(0x78000000u, loaded.ChatWindow4Filter);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveChat_then_LoadChat_round_trips_floating_window_filters()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = ChatSettings.Default with
|
|
{
|
|
ChatWindow1Filter = 0x1u,
|
|
ChatWindow2Filter = 0xFFFFFFFFu,
|
|
ChatWindow3Filter = 0x8000000000000000u, // exercises the high dword (Society/reserved bits)
|
|
ChatWindow4Filter = 0ul,
|
|
};
|
|
|
|
store.SaveChat(original);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(original.ChatWindow1Filter, loaded.ChatWindow1Filter);
|
|
Assert.Equal(original.ChatWindow2Filter, loaded.ChatWindow2Filter);
|
|
Assert.Equal(original.ChatWindow3Filter, loaded.ChatWindow3Filter);
|
|
Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter);
|
|
}
|
|
|
|
// -- Campaign OP slice OP5: main chat window filter round-trip ---------
|
|
|
|
[Fact]
|
|
public void LoadChat_returns_retail_main_window_filter_default_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(0xFBFFFFFFu, loaded.ChatWindowMainFilter);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveChat_then_LoadChat_round_trips_main_window_filter()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = ChatSettings.Default with { ChatWindowMainFilter = 0x1ul };
|
|
|
|
store.SaveChat(original);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(original.ChatWindowMainFilter, loaded.ChatWindowMainFilter);
|
|
}
|
|
|
|
// -- Campaign CH slice CH6c: window opacity round-trip -----------------
|
|
|
|
[Fact]
|
|
public void LoadChat_returns_acdream_opaque_opacity_defaults_when_file_is_missing()
|
|
{
|
|
// CH6c review fix (2026-08-10): the pre-fix default was retail's base
|
|
// ChatInterface value (0.5/1.0), which faded every registered window
|
|
// to 50% opacity out of the box. acdream now ships gmMainChatUI's own
|
|
// 1.0/1.0 override (0x004CD0F0) as the shared global default — see
|
|
// ChatSettings.DefaultOpacity and register row AP-190.
|
|
var store = new SettingsStore(_tempPath);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(1.0f, loaded.DefaultOpacity);
|
|
Assert.Equal(1.0f, loaded.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveChat_then_LoadChat_round_trips_opacity()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = ChatSettings.Default with
|
|
{
|
|
DefaultOpacity = 0.2f,
|
|
ActiveOpacity = 0.8f,
|
|
};
|
|
|
|
store.SaveChat(original);
|
|
ChatSettings loaded = store.LoadChat();
|
|
|
|
Assert.Equal(original.DefaultOpacity, loaded.DefaultOpacity);
|
|
Assert.Equal(original.ActiveOpacity, loaded.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void All_three_sections_coexist_in_one_settings_json()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
|
|
store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f });
|
|
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
Assert.Equal(0.5f, store.LoadAudio().Master);
|
|
Assert.False(store.LoadChat().HearTradeChat);
|
|
Assert.Equal(14f, store.LoadChat().FontSize);
|
|
}
|
|
|
|
// -- Character section round-trip (per-toon) --------------------------
|
|
|
|
[Fact]
|
|
public void LoadCharacter_returns_defaults_when_file_is_missing()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
Assert.Equal(CharacterSettings.Default, store.LoadCharacter("default"));
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadCharacter_returns_defaults_when_toonKey_not_in_file()
|
|
{
|
|
// File exists with a different toon's data; asking for "+Acdream"
|
|
// returns defaults rather than the other toon's data.
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveCharacter("Bob", CharacterSettings.Default with { AutoAttack = true });
|
|
|
|
var loaded = store.LoadCharacter("+Acdream");
|
|
Assert.Equal(CharacterSettings.Default, loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCharacter_then_LoadCharacter_round_trips_all_fields()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var original = new CharacterSettings(
|
|
DefaultChatChannel: "Allegiance",
|
|
AutoAttack: true,
|
|
ConfirmSalvage: false,
|
|
ShowPickupMessages: false);
|
|
|
|
store.SaveCharacter("+Acdream", original);
|
|
Assert.Equal(original, store.LoadCharacter("+Acdream"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCharacter_preserves_other_toons_within_character_section()
|
|
{
|
|
// Two different toons, each with distinct settings — saving one
|
|
// must not clobber the other.
|
|
var store = new SettingsStore(_tempPath);
|
|
var alice = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" };
|
|
var bob = CharacterSettings.Default with { DefaultChatChannel = "Fellowship", AutoAttack = true };
|
|
|
|
store.SaveCharacter("Alice", alice);
|
|
store.SaveCharacter("Bob", bob);
|
|
|
|
Assert.Equal(alice, store.LoadCharacter("Alice"));
|
|
Assert.Equal(bob, store.LoadCharacter("Bob"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCharacter_preserves_other_top_level_sections()
|
|
{
|
|
// Display/audio survive when SaveCharacter writes its nested map.
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
store.SaveAudio(AudioSettings.Default with { Master = 0.4f });
|
|
store.SaveCharacter("+Acdream", CharacterSettings.Default with { AutoAttack = true });
|
|
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
Assert.Equal(0.4f, store.LoadAudio().Master);
|
|
Assert.True(store.LoadCharacter("+Acdream").AutoAttack);
|
|
}
|
|
|
|
[Fact]
|
|
public void All_four_sections_coexist_in_one_settings_json()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
|
|
store.SaveChat(ChatSettings.Default with { HearTradeChat = false });
|
|
store.SaveCharacter("+Acdream",
|
|
CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
|
|
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
Assert.Equal(0.5f, store.LoadAudio().Master);
|
|
Assert.False(store.LoadChat().HearTradeChat);
|
|
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
|
|
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
|
|
|
|
Assert.Equal(new UiWindowPosition(321.5f, 18f),
|
|
store.LoadWindowPosition("Alice", "radar"));
|
|
Assert.Equal(new UiWindowPosition(44f, 55f),
|
|
store.LoadWindowPosition("Bob", "radar"));
|
|
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
|
var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true);
|
|
var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false);
|
|
var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false);
|
|
|
|
store.SaveWindowLayout("Alice", "1920x1080", "chat", alice1080);
|
|
store.SaveWindowLayout("Alice", "2560x1440", "chat", alice1440);
|
|
store.SaveWindowLayout("Bob", "1920x1080", "toolbar", bob1080);
|
|
|
|
Assert.Equal(alice1080, store.LoadWindowLayout("Alice", "1920x1080", "chat", default));
|
|
Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default));
|
|
Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default));
|
|
Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default));
|
|
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowLayouts_MigrateLegacyRadarPositionOverCurrentDefaults()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
|
|
var current = new UiWindowLayout(10f, 10f, 160f, 180f, true, false, false);
|
|
|
|
UiWindowLayout migrated = Assert.IsType<UiWindowLayout>(
|
|
store.LoadWindowLayout("Alice", "1920x1080", "radar", current));
|
|
|
|
Assert.Equal(321.5f, migrated.X);
|
|
Assert.Equal(18f, migrated.Y);
|
|
Assert.Equal(current.Width, migrated.Width);
|
|
Assert.Equal(current.Height, migrated.Height);
|
|
Assert.Equal(current.Visible, migrated.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowLayouts_FallBackToNearestSavedResolution()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var hd = new UiWindowLayout(100f, 110f, 400f, 250f, true, false, false);
|
|
var uhd = new UiWindowLayout(300f, 310f, 600f, 450f, true, false, false);
|
|
store.SaveWindowLayout("Alice", "1280x720", "chat", hd);
|
|
store.SaveWindowLayout("Alice", "3840x2160", "chat", uhd);
|
|
|
|
Assert.Equal(hd, store.LoadWindowLayout("Alice", "1600x900", "chat", default));
|
|
Assert.Equal(uhd, store.LoadWindowLayout("Alice", "3200x1800", "chat", default));
|
|
}
|
|
|
|
[Fact]
|
|
public void NamedWindowLayouts_RoundTripIndependentlyOfCharacterAndResolution()
|
|
{
|
|
var store = new SettingsStore(_tempPath);
|
|
var hunting = new UiWindowLayout(10f, 20f, 500f, 250f, true, false, true);
|
|
var crafting = new UiWindowLayout(30f, 40f, 300f, 400f, false, true, false);
|
|
|
|
store.SaveNamedWindowLayout("hunting", "chat", hunting);
|
|
store.SaveNamedWindowLayout("crafting", "chat", crafting);
|
|
|
|
Assert.Equal(hunting, store.LoadNamedWindowLayout("hunting", "chat", default));
|
|
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
|
|
Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default));
|
|
}
|
|
}
|