diff --git a/AGENTS.md b/AGENTS.md index 0fb0cc35..f3486d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,12 @@ Requirements, architecture, and technology choices are made fresh. ## Repository state - **`main`** — production. Enginseer (the injected modding runtime + launcher) is - merged as the production seed; Magos Modificus is scaffolded (Phase 0: - .NET 10 + Avalonia 12 foundation — the backend libraries (Profiles, Steam, - Integrations, Enginseer-client, SharedMods) are implemented in Phases 1–2; - the UI is still the bare Phase-0 window, and the Launcher is a stub). + merged as the production seed; Magos Modificus has the backend libraries + implemented (Phases 1–2) and the Phase 3 Track A UI in progress: the app shell + + profile management (dropdown switch, persisted active profile, create/rename + /delete dialog, switch-blocked-while-running) are wired (milestones 1–2); + mod-list UI (Track B) + Launch behavior (Track C) are still pending, and the + Launcher is a stub (Phase 5). - **`poc`** — historical proof-of-concept, reference only. Not built upon. - Development is branch + PR; no unreviewed merges to `main` (reviewed + covered + qa'd + CI green). @@ -64,8 +66,10 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo magos-modificus.sln solution root (classic .sln) Directory.Build.props shared MSBuild props (net10.0, nullable, implicit usings) ui/ Magos.Modificus.UI — the Avalonia executable + DI composition root + (Phase 3 Track A: shell + profile management — dropdown switch, + persisted active profile, create/rename/delete dialog) general/ Magos.Modificus.General — cross-cutting infra (logging bootstrap, - config loader, AddGeneral() DI ext) + config loader, app-state store, AddGeneral() DI ext) config/ Magos.Modificus.Config — the MagosConfig schema + defaults (POCO) profiles/ Magos.Modificus.Profiles — profile data model, persistence, shared-first staging (ProfileService.PrepareModRoot builds the @@ -95,6 +99,10 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo Magos.Modificus.Steam.Tests/ xUnit tests for discovery + IsGameRunning Magos.Modificus.EnginseerClient.Tests/ xUnit tests for the launch façade (dual-purpose: `dotnet test` = xUnit; `dotnet run` = composition smoke harness) + Magos.Modificus.UI.Tests/ xUnit tests for the shell + manage-profiles + view models (profile CRUD/switch, active-profile + persist, switch-blocked-while-running; dialog via + an injectable IDialogService seam) docs/ architecture/ + reference/ (darktide/, community-tools/, magos-modificus/) .github/workflows/ CI: mingw-build + msvc-build (Enginseer) + magos-build (Magos Modificus) .gitignore ignores enginseer/target, enginseer/bin, .NET bin/obj, build artifacts, _local/ @@ -145,7 +153,7 @@ Build + test the mod-manager app — run from the repo root (.NET 10 SDK require ```sh dotnet build magos-modificus/magos-modificus.sln --configuration Release dotnet test magos-modificus/magos-modificus.sln --configuration Release -dotnet run --project magos-modificus/ui --configuration Release # bare Avalonia window +dotnet run --project magos-modificus/ui --configuration Release # app shell window ``` - The composition root is `magos-modificus/ui/MagosComposition.cs` (loads config → builds the Serilog logger → wires every `Add()`). @@ -162,8 +170,15 @@ dotnet run --project magos-modificus/ui --configuration Release # bare Avalo `LinuxProcessLookup` via `/proc` argv[0] under Proton), **Integrations** (Phase 1: GitHub Releases client), **Enginseer-client** (Phase 1: the launch façade), **SharedMods** (Phase 2: shared mod store + version-policy model + - allocation resolution). The **UI is still the bare Phase-0 window** and the - **Launcher** is a stub (Phase 5). See `docs/architecture/MAGOS-MODIFICUS.md`. + allocation resolution). **General** carries cross-cutting infra: logging, + `ConfigLoader`, and `AppStateStore` (runtime app-state: the active-profile id, + persisted to a separate `app-state.json`, not `MagosConfig`). **Phase 3 Track A + UI** (the shell + profile management: dropdown switch, persisted active profile, + create/rename/delete dialog) is wired, with an `IProfileSession` (ui/) as the + single authority for the active profile, the switch-block gate, and the live + running-state (polled). Next: Track D (Preferences + i18n), then B (mod-list) + and C (launch); the **Launcher** is a stub (Phase 5). See + `docs/architecture/MAGOS-MODIFICUS.md`. - **CI** (`magos-build.yml`) is scoped to `magos-modificus/**` + the workflow file, matrixed on Windows + Ubuntu; gates on build + tests. @@ -182,6 +197,15 @@ dotnet run --project magos-modificus/ui --configuration Release # bare Avalo - **Conventional Commits** (`type(scope): subject`); commit freely on feature branches. Branch + PR flow; no unreviewed merges to `main`. - Don't commit secrets, the game binary, or anything under `_local/`. +- **UI icons + decorative markers are drawn geometry, not Unicode glyphs.** In the + Avalonia UI, icons are `` (standard Material/Fluent-style path + data, dependency-free, themed via foreground) and dots/markers are ``, + never `✏`/`🗑`/`⚙`/`●` symbol/emoji glyphs (which render unreliably across + fonts/platforms). Scoped to icons/markers; prose punctuation is covered by the + writing convention below. +- **No em-dashes in prose** (code comments, docs, commits, chat). Em-dashes read + as an AI-generated tell; use a comma, colon, parentheses, semicolon, or period + instead. ## Naming convention diff --git a/README.md b/README.md index d76660b0..ed19f531 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,12 @@ Magos has two components: production seed of this repo. See [`enginseer/README.md`](enginseer/README.md) for build + developer details. - **Magos Modificus** — the mod manager app (UI, staging, load order, profiles, - dependency resolution). Phases 0–2 complete: the backend libraries (Profiles, - Steam, Integrations, Enginseer-client, SharedMods) are implemented; the UI is - still the bare Phase-0 window (no profile/mod-management UI yet) and the - Launcher is a stub (Phase 5). See + dependency resolution). Phases 0–2 complete; Phase 3 Track A in progress: the + backend libraries (Profiles, Steam, Integrations, Enginseer-client, + SharedMods) are implemented, and the UI shell + profile management (dropdown + switch, persisted active profile, create/rename/delete dialog) are wired. + Mod-list UI and Launch behavior are still pending, and the Launcher is a stub + (Phase 5). See [`magos-modificus/README.md`](magos-modificus/README.md) for developer/build details. diff --git a/docs/reference/magos-modificus/general.md b/docs/reference/magos-modificus/general.md index 8d0bc96f..455771a6 100644 --- a/docs/reference/magos-modificus/general.md +++ b/docs/reference/magos-modificus/general.md @@ -1,7 +1,8 @@ # General (`Magos.Modificus.General`) — reference -> Cross-cutting infrastructure: structured logging, JSON config loading, and the -> DI registration that wires both into the container. Status: implemented (Phase 0). +> Cross-cutting infrastructure: structured logging, JSON config loading, runtime +> app-state persistence, and the DI registration that wires all three into the +> container. Status: implemented (Phase 0; app-state store added in Phase 3). The composition root (`magos-modificus/ui/MagosComposition.cs`) calls into this library first — before any domain library — to load `MagosConfig` and build the @@ -65,6 +66,40 @@ public sealed class ConfigLoader : IConfigLoader - `DefaultConfigPath()` — `/Magos Modificus/config.json` (`%LOCALAPPDATA%` on Windows, `~/.local/share` on Linux). +### `IAppStateStore` / `AppStateStore` + +Persists **runtime application state**: values that capture "where the app left +off" rather than user system settings. Kept deliberately narrow: the only state +today is the last-chosen active profile. A separate file (not `MagosConfig`) +holds it so the settings schema stays pure (system settings vs. runtime state). + +```csharp +public interface IAppStateStore +{ + Guid? ActiveProfileId { get; set; } // set persists immediately +} + +public sealed class AppStateStore : IAppStateStore +{ + public AppStateStore(string? path = null); + public string Path { get; } + public static string DefaultStatePath(); +} +``` + +- File: `/Magos Modificus/app-state.json` + (`{ "ActiveProfileId": "" | null }`), derived the same way + `ConfigLoader` derives its config path. +- JSON is handled with `System.Text.Json` directly (read + write); + `Microsoft.Extensions.Configuration` is binding-oriented and read-only, the + wrong fit for a tiny writable state file. +- **First-run safe:** a missing or corrupt file never throws; `get` just + returns `null`. Writes are best-effort (runtime state is non-critical; a + persistence failure is swallowed rather than crashing the app). +- Used by `IProfileSession` (the active-profile authority) to restore the active + profile on construction and persist it on changes. The shell and the Manage + dialog read the active id through the session; they do not touch this store. + ## DI registration ```csharp @@ -83,10 +118,13 @@ DI itself needs them). It registers: - `AddLogging()` — wires `ILogger` resolution through the factory. - `AddSingleton()` — so a re-load is available if ever needed (the path is re-resolved to the default location). +- `TryAddSingleton()`: the runtime app-state + store. `TryAdd` (not `Add`) so a test or host may pre-register an override + (e.g. an in-memory or temp-path store) before `AddGeneral` runs. -There are no `TryAdd` seams here: `config` and `loggerFactory` are constructed -objects passed in, not overridable from the container. Tests that want fakes -construct their own `ServiceCollection`. +`config` and `loggerFactory` are constructed objects passed in, not overridable +from the container; `IAppStateStore` is the one seam here (overridable via +pre-registration). ## Dependencies @@ -99,8 +137,10 @@ construct their own `ServiceCollection`. ## Testing `Magos.Modificus.General.Tests` covers `ConfigLoader` (first-run-safe + JSON -override binding), `LoggingBootstrap` (level parsing, truncation, file/dir -creation), and the `AddGeneral` DI wiring. +override binding), `AppStateStore` (round-trip + first-run + corrupt-file +safety + the app-data default path), `LoggingBootstrap` (level parsing, +truncation, file/dir creation), and the `AddGeneral` DI wiring (including the +`TryAdd` `IAppStateStore` override). ```sh dotnet test magos-modificus/magos-modificus.sln -c Release diff --git a/magos-modificus/README.md b/magos-modificus/README.md index 258ea1a4..50df808e 100644 --- a/magos-modificus/README.md +++ b/magos-modificus/README.md @@ -5,11 +5,15 @@ top of the Enginseer runtime. It owns profiles, mod staging, load order, dependency resolution, mod-source integrations (Nexus Mods, GitHub Releases, Steam), and the "Launch Darktide" button that invokes the Enginseer launcher. -> **Status: Phases 0–2 complete.** The foundation + all backend libraries are -> implemented: Profiles, Steam, Integrations, Enginseer-client (Phase 1) + -> SharedMods (Phase 2). The UI is still the bare Phase-0 window (no -> profile/mod-management UI yet); the Launcher is a stub (Phase 5). Target -> architecture: +> **Status: Phases 0–2 complete; Phase 3 Track A in progress.** The foundation + +> all backend libraries are implemented: Profiles, Steam, Integrations, +> Enginseer-client (Phase 1) + SharedMods (Phase 2). Phase 3 Track A wires the +> UI: milestone 1 landed the app shell (top bar + status strip, live +> profile/game-running state) and milestone 2 makes the profile controls work +> (dropdown switch + persisted active profile + a "Manage profiles…" create / +> rename / delete dialog, switch-blocked-while-running). Mod-list UI (Track B) +> and Launch behavior (Track C) are still pending; the Launcher is a stub +> (Phase 5). Target architecture: > [`../docs/architecture/MAGOS-MODIFICUS.md`](../docs/architecture/MAGOS-MODIFICUS.md). ## Tech stack @@ -31,7 +35,9 @@ magos-modificus/ Directory.Build.props shared MSBuild properties (net10.0, nullable) config.example.json sample global config (schema reference) ui/ Magos.Modificus.UI Avalonia executable + DI composition root - general/ Magos.Modificus.General cross-cutting infra: logging, config loader, DI + (Phase 3 Track A: shell + profile management) + general/ Magos.Modificus.General cross-cutting infra: logging, config loader, + app-state store, DI config/ Magos.Modificus.Config the MagosConfig schema + defaults (POCO) profiles/ Magos.Modificus.Profiles implemented (Phase 1 + Phase 2 staging) shared-mods/ Magos.Modificus.SharedMods implemented (Phase 2) @@ -46,6 +52,7 @@ magos-modificus/ Magos.Modificus.Integrations.Tests/ xUnit tests for the GitHub Releases client Magos.Modificus.Steam.Tests/ xUnit tests for discovery + IsGameRunning Magos.Modificus.EnginseerClient.Tests/ xUnit tests for the launch façade (dual-purpose: dotnet test / dotnet run smoke harness) + Magos.Modificus.UI.Tests/ xUnit tests for the shell + manage-profiles view models ``` Each library exposes an `Add()` extension method on @@ -66,8 +73,11 @@ dotnet build magos-modificus/magos-modificus.sln --configuration Release dotnet run --project magos-modificus/ui --configuration Release ``` -The bare Phase-0 window displays the loaded config values, and the startup log -lines (`Magos Modificus starting`, `Config loaded …`, `DI wired …`) go to the +The window shows the top bar (app title, profile dropdown + "Manage profiles…" +gear, Launch Darktide) and the status strip (Darktide running indicator). The +profile dropdown switches the active profile (persisted across restarts via +`IAppStateStore`); "Manage profiles…" opens the create / rename / delete dialog. +The startup log lines (`Magos Modificus starting`, `Config loaded …`) go to the console and to the configured log file. ## Test diff --git a/magos-modificus/general/AppStateStore.cs b/magos-modificus/general/AppStateStore.cs new file mode 100644 index 00000000..544c47cf --- /dev/null +++ b/magos-modificus/general/AppStateStore.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace Magos.Modificus.General; + +/// +/// Default . Loads + saves a single JSON file at +/// <app-data>/Magos Modificus/app-state.json ({ "ActiveProfileId": "<guid>" | null }). +/// The app-data dir is derived the same way derives +/// its config path. JSON is handled with (direct, +/// read+write) rather than Microsoft.Extensions.Configuration. The +/// latter is binding-oriented and read-only; a tiny writable state file is the +/// wrong fit for it. +/// +public sealed class AppStateStore : IAppStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; + + private readonly string _path; + + /// + /// Creates a store for ; null resolves to + /// . + /// + public AppStateStore(string? path = null) + { + _path = path ?? DefaultStatePath(); + } + + /// The state file this store reads + writes. + public string Path => _path; + + /// + public Guid? ActiveProfileId + { + get => Load(); + set => Save(value); + } + + /// The conventional state-file location: <app-data>/Magos Modificus/app-state.json. + public static string DefaultStatePath() => + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Magos Modificus", + "app-state.json"); + + private Guid? Load() + { + // First-run safe: missing or corrupt file → null, never throws. + try + { + if (!File.Exists(_path)) + { + return null; + } + + var json = File.ReadAllText(_path); + var model = JsonSerializer.Deserialize(json, JsonOptions); + return model?.ActiveProfileId; + } + catch + { + // Missing/corrupt/permission-denied: treat as "no state recorded." + return null; + } + } + + private void Save(Guid? value) + { + // Best-effort: runtime app-state is non-critical, so a persistence + // failure must not crash the app mid-interaction. + try + { + var dir = System.IO.Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var json = JsonSerializer.Serialize(new StateModel { ActiveProfileId = value }, JsonOptions); + File.WriteAllText(_path, json); + } + catch + { + // Swallow: the app keeps working without persisted state. + } + } + + private sealed class StateModel + { + public Guid? ActiveProfileId { get; set; } + } +} diff --git a/magos-modificus/general/IAppStateStore.cs b/magos-modificus/general/IAppStateStore.cs new file mode 100644 index 00000000..71c62319 --- /dev/null +++ b/magos-modificus/general/IAppStateStore.cs @@ -0,0 +1,26 @@ +namespace Magos.Modificus.General; + +/// +/// Persists non-critical **runtime application state**: values that capture +/// "where the app left off" (e.g. the last-selected profile) rather than user +/// system settings. Backed by a small JSON file kept under the app-data dir, +/// separate from (which holds system settings +/// only). Kept deliberately narrow on purpose: when the only state is +/// , a tiny dedicated store is the honest model +/// and keeps the settings schema pure. +/// +/// +/// First-run safe: a missing or corrupt state file never throws; +/// reads just return null. Writes are best-effort; runtime app-state is +/// non-critical, so a persistence failure (unwritable dir, full disk) is +/// swallowed rather than crashing the app mid-interaction. +/// +public interface IAppStateStore +{ + /// + /// The last-chosen active profile id, or null when none is recorded. + /// Reading returns the persisted value (or null on first run / + /// corrupt file); assigning persists the value immediately. + /// + Guid? ActiveProfileId { get; set; } +} diff --git a/magos-modificus/general/ServiceCollectionExtensions.cs b/magos-modificus/general/ServiceCollectionExtensions.cs index e0dac134..d6d12a49 100644 --- a/magos-modificus/general/ServiceCollectionExtensions.cs +++ b/magos-modificus/general/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Magos.Modificus.Config; @@ -13,8 +14,10 @@ public static class ServiceCollectionExtensions { /// /// Registers General services: the singleton, - /// the , AddLogging(), and the - /// . + /// the , AddLogging(), + /// , and (runtime + /// app-state: the active-profile id, persisted separately from + /// ). /// public static IServiceCollection AddGeneral( this IServiceCollection services, @@ -25,6 +28,9 @@ public static IServiceCollection AddGeneral( services.AddSingleton(loggerFactory); services.AddLogging(); services.AddSingleton(); + // TryAdd so a test/host may pre-register an override (e.g. an in-memory + // or temp-path state store) before AddGeneral runs. + services.TryAddSingleton(); return services; } } diff --git a/magos-modificus/magos-modificus.sln b/magos-modificus/magos-modificus.sln index 61a0753d..b0025f69 100644 --- a/magos-modificus/magos-modificus.sln +++ b/magos-modificus/magos-modificus.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.SharedMods" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.SharedMods.Tests", "tests\Magos.Modificus.SharedMods.Tests\Magos.Modificus.SharedMods.Tests.csproj", "{E5644330-721B-48A6-B4E9-120A2C8ABDF2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.UI.Tests", "tests\Magos.Modificus.UI.Tests\Magos.Modificus.UI.Tests.csproj", "{336516CA-10CA-4357-A949-E3F4163B2D3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +245,18 @@ Global {E5644330-721B-48A6-B4E9-120A2C8ABDF2}.Release|x64.Build.0 = Release|Any CPU {E5644330-721B-48A6-B4E9-120A2C8ABDF2}.Release|x86.ActiveCfg = Release|Any CPU {E5644330-721B-48A6-B4E9-120A2C8ABDF2}.Release|x86.Build.0 = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|x64.Build.0 = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Debug|x86.Build.0 = Debug|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|Any CPU.Build.0 = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|x64.ActiveCfg = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|x64.Build.0 = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|x86.ActiveCfg = Release|Any CPU + {336516CA-10CA-4357-A949-E3F4163B2D3C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -263,5 +277,6 @@ Global {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {D457B28D-3409-4BDB-8180-06C53999C23A} = {82E21D46-CB55-58F2-B30A-44B68825D3B3} {E5644330-721B-48A6-B4E9-120A2C8ABDF2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {336516CA-10CA-4357-A949-E3F4163B2D3C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/magos-modificus/tests/Magos.Modificus.General.Tests/AppStateStoreTests.cs b/magos-modificus/tests/Magos.Modificus.General.Tests/AppStateStoreTests.cs new file mode 100644 index 00000000..826bf493 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.General.Tests/AppStateStoreTests.cs @@ -0,0 +1,108 @@ +using Magos.Modificus.General; + +namespace Magos.Modificus.General.Tests; + +/// +/// : round-trip + first-run + corrupt-file safety. +/// Establishes the active-profile persistence contract the shell VM relies on. +/// +public sealed class AppStateStoreTests +{ + [Fact] + public void ActiveProfileId_is_null_when_file_is_missing() + { + var path = TempPath(); + var store = new AppStateStore(path); + + Assert.Null(store.ActiveProfileId); + Assert.False(File.Exists(path)); + } + + [Fact] + public void Set_persists_and_get_round_trips_the_value() + { + var path = TempPath(); + var id = Guid.NewGuid(); + try + { + var store = new AppStateStore(path); + + store.ActiveProfileId = id; + + Assert.True(File.Exists(path)); + // A fresh instance over the same file reads the persisted value. + Assert.Equal(id, new AppStateStore(path).ActiveProfileId); + } + finally + { + Cleanup(path); + } + } + + [Fact] + public void Set_null_clears_the_recorded_value() + { + var path = TempPath(); + try + { + var store = new AppStateStore(path); + store.ActiveProfileId = Guid.NewGuid(); + store.ActiveProfileId = null; + + Assert.Null(new AppStateStore(path).ActiveProfileId); + } + finally + { + Cleanup(path); + } + } + + [Fact] + public void Get_returns_null_for_corrupt_file_without_throwing() + { + var path = TempPath(); + try + { + var dir = System.IO.Path.GetDirectoryName(path)!; + Directory.CreateDirectory(dir); + File.WriteAllText(path, "{ this is not json"); + + var store = new AppStateStore(path); + + Assert.Null(store.ActiveProfileId); + } + finally + { + Cleanup(path); + } + } + + [Fact] + public void Get_returns_null_when_parent_directory_is_missing() + { + // First-run case: neither the directory nor the file exist yet. + var path = System.IO.Path.Combine(Path.GetTempPath(), "magos-state-missing-" + Guid.NewGuid(), "app-state.json"); + + Assert.Null(new AppStateStore(path).ActiveProfileId); + } + + [Fact] + public void Default_state_path_is_under_app_data() + { + var path = AppStateStore.DefaultStatePath(); + + Assert.EndsWith(System.IO.Path.Combine("Magos Modificus", "app-state.json"), path); + } + + private static string TempPath() => + System.IO.Path.Combine(Path.GetTempPath(), "magos-state-" + Guid.NewGuid(), "app-state.json"); + + private static void Cleanup(string path) + { + var dir = System.IO.Path.GetDirectoryName(path); + if (dir is not null && Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/magos-modificus/tests/Magos.Modificus.General.Tests/GeneralServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.General.Tests/GeneralServiceCollectionExtensionsTests.cs index 4405f32f..1f438e17 100644 --- a/magos-modificus/tests/Magos.Modificus.General.Tests/GeneralServiceCollectionExtensionsTests.cs +++ b/magos-modificus/tests/Magos.Modificus.General.Tests/GeneralServiceCollectionExtensionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Magos.Modificus.Config; using Magos.Modificus.General; @@ -7,9 +8,9 @@ namespace Magos.Modificus.General.Tests; /// /// Proves the General DI registration is resolvable: AddGeneral() registers -/// the config, logger factory, logging, and config loader so any component can -/// take ILogger<T> / via constructor -/// injection. +/// the config, logger factory, logging, config loader, and app-state store so +/// any component can take ILogger<T> / / +/// via constructor injection. /// public sealed class GeneralServiceCollectionExtensionsTests { @@ -27,5 +28,27 @@ public void AddGeneral_registers_resolvable_services() Assert.Same(loggerFactory, provider.GetRequiredService()); Assert.NotNull(provider.GetService>()); Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void AddGeneral_allows_an_IAppStateStore_override_via_TryAdd() + { + // TryAdd so a test/host can pre-register an override before AddGeneral. + var config = MagosConfig.CreateDefault(); + using var loggerFactory = new LoggerFactory(); + var custom = new CustomAppStateStore(); + + var services = new ServiceCollection(); + services.AddSingleton(custom); + services.AddGeneral(config, loggerFactory); + var provider = services.BuildServiceProvider(); + + Assert.Same(custom, provider.GetRequiredService()); + } + + private sealed class CustomAppStateStore : IAppStateStore + { + public Guid? ActiveProfileId { get; set; } } } diff --git a/magos-modificus/tests/Magos.Modificus.UI.Tests/Magos.Modificus.UI.Tests.csproj b/magos-modificus/tests/Magos.Modificus.UI.Tests/Magos.Modificus.UI.Tests.csproj new file mode 100644 index 00000000..2f1aaecb --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.UI.Tests/Magos.Modificus.UI.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + true + Magos.Modificus.UI.Tests + Magos.Modificus.UI.Tests + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/magos-modificus/tests/Magos.Modificus.UI.Tests/ManageProfilesViewModelTests.cs b/magos-modificus/tests/Magos.Modificus.UI.Tests/ManageProfilesViewModelTests.cs new file mode 100644 index 00000000..96822054 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.UI.Tests/ManageProfilesViewModelTests.cs @@ -0,0 +1,529 @@ +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.ViewModels; + +namespace Magos.Modificus.UI.Tests; + +/// +/// Manage-profiles dialog VM, the editable-list mechanics: inline rename +/// (commit / cancel / empty-rejected / no-op-same-name), delete-confirm +/// (yes deletes / no aborts / delete-of-active clears the active id), the delete +/// gate (active row's trash disabled while Darktide runs via +/// ), "+ New profile" add +/// row (create requests active through the session / empty cancels), and the active +/// marker reading the session's authoritative active id. Delete confirmation is +/// exercised through the seam; the active state is +/// exercised through the seam. +/// +public sealed class ManageProfilesViewModelTests +{ + private static ManageProfilesViewModel Build( + FakeProfileService profiles, + FakeDialogService? dialogs = null, + FakeProfileSession? session = null) + { + dialogs ??= new FakeDialogService(); + session ??= new FakeProfileSession(() => profiles.ListProfiles()); + return new ManageProfilesViewModel(profiles, dialogs, session); + } + + private static ProfileSummary Profile(string name) => + new(Guid.NewGuid(), name); + + private static ProfileItemViewModel Row(ManageProfilesViewModel vm, string name) => + vm.Items.Single(i => i.Name == name); + + // ---- construction / seeding -------------------------------------------- + + [Fact] + public void Construction_loads_the_rows_and_marks_the_active_profile() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var session = new FakeProfileSession { ActiveProfileId = b.Id }; + var vm = Build(TestDoubles.Profiles(a, b), session: session); + + Assert.Equal(2, vm.Items.Count); + Assert.True(Row(vm, "Bravo").IsActive); + Assert.False(Row(vm, "Alpha").IsActive); + } + + [Fact] + public void Construction_marks_no_row_active_when_the_active_id_is_unknown() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = Guid.NewGuid() }; + var vm = Build(TestDoubles.Profiles(a), session: session); + + Assert.False(Row(vm, "Alpha").IsActive); + Assert.Single(vm.Items); + } + + [Fact] + public void Construction_marks_no_row_active_when_none_is_active() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = null }; + var vm = Build(TestDoubles.Profiles(a), session: session); + + Assert.False(Row(vm, "Alpha").IsActive); + } + + // ---- rename ------------------------------------------------------------ + + [Fact] + public void StartRename_prefills_the_edit_field_and_enters_edit_mode() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(TestDoubles.Profiles(a), session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + + Assert.True(row.IsEditing); + Assert.Equal("Alpha", row.EditText); + } + + [Fact] + public void StartRename_cancels_any_other_row_in_flight() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(TestDoubles.Profiles(a, b), session: session); + var rowA = Row(vm, "Alpha"); + var rowB = Row(vm, "Bravo"); + + vm.StartRenameCommand.Execute(rowA); + vm.StartRenameCommand.Execute(rowB); + + Assert.False(rowA.IsEditing); + Assert.True(rowB.IsEditing); + } + + [Fact] + public void CommitRename_renames_when_the_entry_is_non_empty_and_changed() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + row.EditText = "Alpha Renamed"; + vm.CommitRenameCommand.Execute(row); // Enter + + Assert.Equal((a.Id, "Alpha Renamed"), Assert.Single(profiles.Renames)); + Assert.False(row.IsEditing); + Assert.Equal("Alpha Renamed", row.Name); + } + + [Fact] + public void CommitRename_does_not_request_or_reconcile_active() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(TestDoubles.Profiles(a), session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + row.EditText = "Beta"; + vm.CommitRenameCommand.Execute(row); + + Assert.Equal(0, session.RequestActiveCalls); + Assert.Equal(0, session.ReconcileCalls); + } + + [Fact] + public void CommitRename_rejects_an_empty_entry_and_reverts() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + row.EditText = " "; + vm.CommitRenameCommand.Execute(row); + + Assert.Empty(profiles.Renames); + Assert.False(row.IsEditing); + Assert.Equal("Alpha", row.Name); // reverted + } + + [Fact] + public void CommitRename_is_a_noop_when_the_entry_equals_the_current_name() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + // EditText pre-filled with "Alpha", commit should not call RenameProfile. + vm.CommitRenameCommand.Execute(row); + + Assert.Empty(profiles.Renames); + Assert.False(row.IsEditing); + } + + [Fact] + public void CommitRename_is_idempotent_after_exit_so_blur_does_not_double_rename() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + row.EditText = "Bravo"; + vm.CommitRenameCommand.Execute(row); // Enter + vm.CommitRenameCommand.Execute(row); // LostFocus (no-op) + + Assert.Single(profiles.Renames); + } + + [Fact] + public void CancelRename_discards_the_entry_and_exits_edit_mode() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + var row = Row(vm, "Alpha"); + + vm.StartRenameCommand.Execute(row); + row.EditText = "Discarded"; + vm.CancelRenameCommand.Execute(row); // Esc + + Assert.Empty(profiles.Renames); + Assert.False(row.IsEditing); + Assert.Equal("Alpha", row.Name); // unchanged + } + + // ---- delete ------------------------------------------------------------ + + [Fact] + public async Task DeleteProfile_prompts_for_confirmation_with_the_name() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var dialogs = new FakeDialogService(); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + var row = Row(vm, "Alpha"); + + await vm.DeleteProfileCommand.ExecuteAsync(row); + + Assert.Equal(1, dialogs.ConfirmCalls); + Assert.Contains("Alpha", dialogs.LastConfirmMessage); + Assert.Contains("mods/", dialogs.LastConfirmMessage!); + } + + [Fact] + public async Task DeleteProfile_deletes_when_confirmed() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Alpha")); + + Assert.Equal(a.Id, Assert.Single(profiles.DeletedIds)); + Assert.Empty(vm.Items); + } + + [Fact] + public async Task DeleteProfile_aborts_when_not_confirmed() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var dialogs = new FakeDialogService { ConfirmResult = false }; + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Alpha")); + + Assert.Empty(profiles.DeletedIds); + Assert.Single(vm.Items); + } + + [Fact] + public async Task DeleteProfile_of_the_active_clears_the_active_id() + { + // Delete-of-active is blocked while the game runs (trash disabled), so this + // path runs when stopped. The active clears (null), not a switch to Bravo. + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession(() => profiles.ListProfiles()) { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Alpha")); + + Assert.Equal(a.Id, Assert.Single(profiles.DeletedIds)); + Assert.Equal(1, session.ReconcileCalls); + Assert.Null(session.ActiveProfileId); // cleared, not switched to Bravo + Assert.False(Row(vm, "Bravo").IsActive); // no row marked active + } + + [Fact] + public async Task DeleteProfile_of_the_last_active_clears_to_null() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession(() => profiles.ListProfiles()) { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Alpha")); + + Assert.Null(session.ActiveProfileId); + Assert.Empty(vm.Items); + } + + [Fact] + public async Task DeleteProfile_of_a_non_active_profile_leaves_the_active_unchanged() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession(() => profiles.ListProfiles()) { ActiveProfileId = a.Id }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Bravo")); + + Assert.Equal(b.Id, Assert.Single(profiles.DeletedIds)); + Assert.Equal(a.Id, session.ActiveProfileId); // active untouched + Assert.True(Row(vm, "Alpha").IsActive); + } + + // ---- delete gate: active locked while running ------------------------- + + [Fact] + public void Delete_of_the_active_profile_is_blocked_while_the_game_runs() + { + // The trash on the active row is disabled (session.CanDeleteProfile false), + // so the user cannot delete it. Non-active rows stay deletable. + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = true }; + var vm = Build(profiles, session: session); + + Assert.False(session.CanDeleteProfile(a.Id)); // the gate behind the binding + Assert.False(Row(vm, "Alpha").IsDeleteEnabled); // active row's trash disabled + Assert.True(Row(vm, "Bravo").IsDeleteEnabled); // non-active row's trash enabled + Assert.Empty(profiles.DeletedIds); // nothing deleted + Assert.Equal(a.Id, session.ActiveProfileId); // active unchanged + } + + [Fact] + public async Task DeleteProfile_command_on_the_active_profile_while_running_is_a_noop() + { + // Defense-in-depth: the trash button binds its enabled state to the session's + // gate, but the command is the real authority. Invoking the command directly + // on the active row while the game runs (bypassing the UI binding) must still + // no-op: no confirm prompt, no delete, no reconcile, active + list unchanged. + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession(() => profiles.ListProfiles()) + { + ActiveProfileId = a.Id, + IsRunning = true, + }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Alpha")); + + Assert.Empty(profiles.DeletedIds); // not deleted + Assert.Equal(0, dialogs.ConfirmCalls); // no confirm prompt (early bail) + Assert.Equal(0, session.ReconcileCalls); // no reconcile (early bail) + Assert.Equal(a.Id, session.ActiveProfileId); // active unchanged + Assert.Equal(2, vm.Items.Count); // list unchanged + } + + [Fact] + public void Trash_on_the_active_row_is_enabled_when_the_game_is_not_running() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = false }; + var vm = Build(TestDoubles.Profiles(a), session: session); + + Assert.True(Row(vm, "Alpha").IsDeleteEnabled); + } + + [Fact] + public void Trash_on_the_active_row_flips_with_the_live_running_state() + { + // The dialog subscribes to the session's live running-state: the trash + // disables when Darktide starts and re-enables when it stops (the polling + // timer drives IsRunning in production). + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = false }; + var vm = Build(TestDoubles.Profiles(a), session: session); + Assert.True(Row(vm, "Alpha").IsDeleteEnabled); + + session.IsRunning = true; + Assert.False(Row(vm, "Alpha").IsDeleteEnabled); + + session.IsRunning = false; + Assert.True(Row(vm, "Alpha").IsDeleteEnabled); + } + + [Fact] + public async Task DeleteProfile_of_a_non_active_profile_while_running_deletes_and_leaves_active_unchanged() + { + // Non-active profiles stay deletable anytime; deleting one never touches the active. + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var dialogs = new FakeDialogService { ConfirmResult = true }; + var session = new FakeProfileSession(() => profiles.ListProfiles()) + { + ActiveProfileId = a.Id, + IsRunning = true, + }; + var vm = Build(profiles, dialogs, session); + + await vm.DeleteProfileCommand.ExecuteAsync(Row(vm, "Bravo")); + + Assert.Equal(b.Id, Assert.Single(profiles.DeletedIds)); + Assert.Equal(a.Id, session.ActiveProfileId); // active untouched + } + + // ---- create ("+ New profile") ----------------------------------------- + + [Fact] + public void StartCreate_shows_the_add_row_and_clears_the_entry() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(TestDoubles.Profiles(a), session: session); + vm.NewProfileName = "stale"; + + vm.StartCreateCommand.Execute(null); + + Assert.True(vm.IsAddingNew); + Assert.Empty(vm.NewProfileName); + } + + [Fact] + public void StartCreate_cancels_any_inline_rename_in_flight() + { + var a = Profile("Alpha"); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(TestDoubles.Profiles(a), session: session); + var row = Row(vm, "Alpha"); + vm.StartRenameCommand.Execute(row); + + vm.StartCreateCommand.Execute(null); + + Assert.False(row.IsEditing); + } + + [Fact] + public void CommitCreate_adds_the_profile_and_requests_it_active_when_not_running() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = false }; + var vm = Build(profiles, session: session); + vm.StartCreateCommand.Execute(null); + vm.NewProfileName = "Bravo"; + + vm.CommitCreateCommand.Execute(null); // Enter + + Assert.Contains("Bravo", profiles.CreatedNames); + Assert.False(vm.IsAddingNew); + Assert.Empty(vm.NewProfileName); + Assert.Equal(1, session.RequestActiveCalls); // the dialog asked the session + Assert.Equal(Row(vm, "Bravo").Id, session.ActiveProfileId); // session applied it + Assert.True(Row(vm, "Bravo").IsActive); // marker follows the session + Assert.False(Row(vm, "Alpha").IsActive); + } + + [Fact] + public void CommitCreate_while_running_creates_the_profile_but_the_gate_blocks_active() + { + // Create while the game runs: the profile is created (it appears in the + // list), but the session's gate blocks making it active. The marker reads + // the session, so it stays on the current active. The dialog did not + // decide; it asked the session, which is the sole gate. + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = true }; + var vm = Build(profiles, session: session); + vm.StartCreateCommand.Execute(null); + vm.NewProfileName = "Bravo"; + + vm.CommitCreateCommand.Execute(null); // Enter + + Assert.Contains("Bravo", profiles.CreatedNames); // created + Assert.Equal(1, session.RequestActiveCalls); // the dialog asked + Assert.Equal(a.Id, session.ActiveProfileId); // but the gate held + Assert.True(Row(vm, "Alpha").IsActive); // marker held + Assert.False(Row(vm, "Bravo").IsActive); + } + + [Fact] + public void CommitCreate_rejects_an_empty_entry_and_cancels() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + vm.StartCreateCommand.Execute(null); + vm.NewProfileName = " "; + + vm.CommitCreateCommand.Execute(null); + + Assert.Empty(profiles.CreatedNames); + Assert.False(vm.IsAddingNew); + Assert.Empty(vm.NewProfileName); + Assert.Equal(0, session.RequestActiveCalls); // no create, no request + } + + [Fact] + public void CommitCreate_is_idempotent_after_exit_so_blur_does_not_double_create() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + vm.StartCreateCommand.Execute(null); + vm.NewProfileName = "Bravo"; + + vm.CommitCreateCommand.Execute(null); // Enter + vm.CommitCreateCommand.Execute(null); // LostFocus (no-op) + + Assert.Single(profiles.CreatedNames); + } + + [Fact] + public void CancelCreate_discards_the_entry_and_hides_the_add_row() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var vm = Build(profiles, session: session); + vm.StartCreateCommand.Execute(null); + vm.NewProfileName = "Discarded"; + + vm.CancelCreateCommand.Execute(null); // Esc + + Assert.Empty(profiles.CreatedNames); + Assert.False(vm.IsAddingNew); + Assert.Empty(vm.NewProfileName); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.UI.Tests/ProfileSessionTests.cs b/magos-modificus/tests/Magos.Modificus.UI.Tests/ProfileSessionTests.cs new file mode 100644 index 00000000..7a6d4752 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.UI.Tests/ProfileSessionTests.cs @@ -0,0 +1,279 @@ +using System.ComponentModel; +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.Session; + +namespace Magos.Modificus.UI.Tests; + +/// +/// unit tests: the can-change gate (RequestActive), +/// the delete gate (CanDeleteProfile), delete-of-active recovery (ReconcileActive +/// clears the active id), persistence, and the live running-state refresh. All +/// against in-memory fakes; the polling timer is injected as null and +/// is driven directly for deterministic +/// running-state changes. +/// +public sealed class ProfileSessionTests +{ + private static ProfileSession Build( + FakeSteamService? steam = null, + FakeProfileService? profiles = null, + FakeAppStateStore? appState = null) + { + steam ??= new FakeSteamService(); + profiles ??= TestDoubles.Profiles(); + appState ??= new FakeAppStateStore(); + // startTimer = null: no polling; tests drive Refresh() directly. + return new ProfileSession(steam, profiles, appState, startTimer: null); + } + + private static ProfileSummary Profile(string name) => new(Guid.NewGuid(), name); + + // ---- construction / restore ------------------------------------------- + + [Fact] + public void Constructor_restores_the_persisted_active_id() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + + Assert.Equal(a.Id, session.ActiveProfileId); + } + + [Fact] + public void Constructor_leaves_active_null_when_none_recorded() + { + var session = Build( + profiles: TestDoubles.Profiles(Profile("Alpha")), + appState: new FakeAppStateStore { ActiveProfileId = null }); + + Assert.Null(session.ActiveProfileId); + } + + [Fact] + public void Constructor_does_not_persist_when_restoring_the_active_id() + { + // Restore goes straight into the field (no write-back), even for a valid id. + var a = Profile("Alpha"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + + Assert.Equal(0, appState.SetCount); + } + + [Fact] + public void Constructor_snapshots_the_initial_running_state() + { + var steam = new FakeSteamService { Running = true }; + + var session = Build(steam: steam); + + Assert.True(session.IsRunning); + } + + // ---- RequestActive: the sole gate ------------------------------------- + + [Fact] + public void RequestActive_applies_and_persists_when_not_running() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore(); + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + session.IsRunning = false; + + session.RequestActive(a.Id); + + Assert.Equal(a.Id, session.ActiveProfileId); + Assert.Equal(a.Id, appState.ActiveProfileId); + } + + [Fact] + public void RequestActive_is_a_noop_when_running() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a, b), appState: appState); + session.IsRunning = true; + + session.RequestActive(b.Id); + + Assert.Equal(a.Id, session.ActiveProfileId); // unchanged + Assert.Equal(a.Id, appState.ActiveProfileId); // not persisted to b + } + + [Fact] + public void RequestActive_to_the_current_id_is_a_noop_persist() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + session.IsRunning = false; + + session.RequestActive(a.Id); + + Assert.Equal(0, appState.SetCount); // same value, no write + } + + // ---- CanDeleteProfile: the delete gate -------------------------------- + + [Fact] + public void CanDeleteProfile_locks_the_active_id_while_running() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a, b), appState: appState); + session.IsRunning = true; + + Assert.False(session.CanDeleteProfile(a.Id)); // active locked while running + } + + [Fact] + public void CanDeleteProfile_allows_a_non_active_profile_while_running() + { + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a, b), appState: appState); + session.IsRunning = true; + + Assert.True(session.CanDeleteProfile(b.Id)); // non-active deletable anytime + } + + [Fact] + public void CanDeleteProfile_allows_the_active_id_when_not_running() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + session.IsRunning = false; + + Assert.True(session.CanDeleteProfile(a.Id)); + } + + [Fact] + public void CanDeleteProfile_allows_every_profile_when_none_is_active() + { + var a = Profile("Alpha"); + var session = Build( + profiles: TestDoubles.Profiles(a), + appState: new FakeAppStateStore { ActiveProfileId = null }); + session.IsRunning = true; + + Assert.True(session.CanDeleteProfile(a.Id)); // nothing locked when none active + } + + // ---- ReconcileActive: delete-of-active recovery ----------------------- + + [Fact] + public void ReconcileActive_clears_active_when_the_active_profile_is_deleted() + { + // Delete-of-active is blocked while the game runs (CanDeleteProfile), so this + // path runs when stopped. The active clears (null): we never auto-select a + // remaining profile on someone's behalf; the user explicitly picks the next. + var a = Profile("Alpha"); + var b = Profile("Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: profiles, appState: appState); + session.IsRunning = false; + + profiles.DeleteProfile(a.Id); + session.ReconcileActive(); + + Assert.Null(session.ActiveProfileId); // not b.Id: cleared, not switched + Assert.Null(appState.ActiveProfileId); + } + + [Fact] + public void ReconcileActive_clears_active_when_no_profiles_remain() + { + var a = Profile("Alpha"); + var profiles = TestDoubles.Profiles(a); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: profiles, appState: appState); + + profiles.DeleteProfile(a.Id); + session.ReconcileActive(); + + Assert.Null(session.ActiveProfileId); + Assert.Null(appState.ActiveProfileId); + } + + [Fact] + public void ReconcileActive_is_a_noop_when_the_active_profile_still_exists() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore { ActiveProfileId = a.Id }; + var session = Build(profiles: TestDoubles.Profiles(a, Profile("Bravo")), appState: appState); + + session.ReconcileActive(); + + Assert.Equal(a.Id, session.ActiveProfileId); // still here, no change + } + + [Fact] + public void ReconcileActive_is_a_noop_when_no_active_is_set() + { + // First run / nothing chosen: never auto-select a profile on someone's behalf. + var a = Profile("Alpha"); + var session = Build( + profiles: TestDoubles.Profiles(a), + appState: new FakeAppStateStore { ActiveProfileId = null }); + + session.ReconcileActive(); + + Assert.Null(session.ActiveProfileId); + } + + // ---- live running-state refresh --------------------------------------- + + [Fact] + public void Refresh_updates_IsRunning_from_the_steam_service() + { + var steam = new FakeSteamService { Running = false }; + var session = Build(steam: steam); + Assert.False(session.IsRunning); + + steam.Running = true; + session.Refresh(); + + Assert.True(session.IsRunning); + + steam.Running = false; + session.Refresh(); + + Assert.False(session.IsRunning); + } + + [Fact] + public void IsRunning_change_raises_property_changed() + { + // The shell relies on this to mirror live running-state. + var session = Build(steam: new FakeSteamService { Running = false }); + var raised = new List(); + ((INotifyPropertyChanged)session).PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + session.IsRunning = true; + + Assert.Contains(nameof(IProfileSession.IsRunning), raised); + } + + [Fact] + public void ActiveProfileId_change_raises_property_changed_and_persists() + { + var a = Profile("Alpha"); + var appState = new FakeAppStateStore(); + var session = Build(profiles: TestDoubles.Profiles(a), appState: appState); + var raised = new List(); + ((INotifyPropertyChanged)session).PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + session.RequestActive(a.Id); + + Assert.Contains(nameof(IProfileSession.ActiveProfileId), raised); + Assert.Equal(a.Id, appState.ActiveProfileId); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.UI.Tests/ShellViewModelTests.cs b/magos-modificus/tests/Magos.Modificus.UI.Tests/ShellViewModelTests.cs new file mode 100644 index 00000000..8d2f9390 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.UI.Tests/ShellViewModelTests.cs @@ -0,0 +1,255 @@ +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.ViewModels; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Magos.Modificus.UI.Tests; + +/// +/// Shell-VM profile controls: mirroring the session's active id + running-state, +/// dropdown switch (routed through the session gate), switch-blocked-while-running, +/// and dialog-driven list refresh. All against in-memory fakes; the session is +/// behind the seam (a ). +/// +public sealed class ShellViewModelTests +{ + private static readonly ILogger Logger = NullLogger.Instance; + + private static ShellViewModel Build( + FakeProfileService? profiles = null, + FakeProfileSession? session = null, + FakeDialogService? dialogs = null) + { + profiles ??= TestDoubles.Profiles(); + session ??= new FakeProfileSession(() => profiles.ListProfiles()); + return new ShellViewModel( + profiles, + session, + new FakeLaunchService(), + dialogs ?? new FakeDialogService(), + Logger); + } + + // ---- active-profile restore on construction ---------------------------- + + [Fact] + public void Constructor_restores_the_persisted_active_profile() + { + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var b = new ProfileSummary(Guid.NewGuid(), "Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var session = new FakeProfileSession { ActiveProfileId = b.Id }; + + var vm = Build(profiles, session); + + Assert.NotNull(vm.SelectedProfile); + Assert.Equal(b.Id, vm.SelectedProfile!.Id); + } + + [Fact] + public void Constructor_leaves_selection_null_when_no_active_profile_recorded() + { + var profiles = TestDoubles.Profiles(new ProfileSummary(Guid.NewGuid(), "Alpha")); + var session = new FakeProfileSession { ActiveProfileId = null }; + + var vm = Build(profiles, session); + + Assert.Null(vm.SelectedProfile); + } + + [Fact] + public void Constructor_does_not_request_an_active_change_during_restore() + { + // Restoring the saved active reads it from the session; it never asks the + // session to change active (no voluntary gate invocation on startup). + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + + var vm = Build(profiles, session); + + Assert.Equal(0, session.RequestActiveCalls); + } + + [Fact] + public void Constructor_falls_back_to_null_when_the_recorded_active_profile_is_absent() + { + // Stale state pointing at a deleted profile resolves to no selection. + var profiles = TestDoubles.Profiles(new ProfileSummary(Guid.NewGuid(), "Alpha")); + var session = new FakeProfileSession { ActiveProfileId = Guid.NewGuid() }; + + var vm = Build(profiles, session); + + Assert.Null(vm.SelectedProfile); + } + + // ---- dropdown switch routes through the session ------------------------ + + [Fact] + public void Setting_SelectedProfile_requests_the_id_active_through_the_session() + { + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var b = new ProfileSummary(Guid.NewGuid(), "Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = false }; + var vm = Build(profiles, session); + + vm.SelectedProfile = b; + + Assert.Equal(b.Id, session.ActiveProfileId); // session applied it + Assert.Equal(1, session.RequestActiveCalls); + Assert.Equal(b.Id, vm.SelectedProfile?.Id); // selection follows the session + } + + [Fact] + public void Setting_SelectedProfile_reverts_when_the_session_gate_rejects() + { + // The session is the authority: when it blocks the change (game running), + // the dropdown snaps back to the real active instead of lying. + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var b = new ProfileSummary(Guid.NewGuid(), "Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = true }; + var vm = Build(profiles, session); + + vm.SelectedProfile = b; // programmatically (the dropdown is disabled while running) + + Assert.Equal(a.Id, vm.SelectedProfile?.Id); // reverted to the real active + Assert.Equal(a.Id, session.ActiveProfileId); // session never moved + Assert.Equal(1, session.RequestActiveCalls); // but it was asked + } + + // ---- switch-blocked-while-running -------------------------------------- + + [Fact] + public void CanSwitchProfile_is_true_when_not_running_and_profiles_exist() + { + var vm = Build(TestDoubles.Profiles(new ProfileSummary(Guid.NewGuid(), "Alpha")), + new FakeProfileSession { IsRunning = false }); + + Assert.True(vm.CanSwitchProfile); + } + + [Fact] + public void CanSwitchProfile_is_false_when_the_game_is_running() + { + var session = new FakeProfileSession { IsRunning = true }; + var vm = Build(TestDoubles.Profiles(new ProfileSummary(Guid.NewGuid(), "Alpha")), session); + + Assert.False(vm.CanSwitchProfile); + Assert.Contains("Darktide is running", vm.ProfileSwitchTooltip); + } + + [Fact] + public void CanSwitchProfile_is_false_when_no_profiles_exist() + { + var vm = Build(TestDoubles.Profiles()); + + Assert.False(vm.CanSwitchProfile); + Assert.NotEmpty(vm.ProfileSwitchTooltip); + } + + [Fact] + public void Live_IsRunning_change_flips_CanSwitchProfile_and_the_tooltip() + { + // The status strip + dropdown-enable react to the session's live running-state + // (the polling timer drives this in production). + var session = new FakeProfileSession { IsRunning = false }; + var vm = Build(TestDoubles.Profiles(new ProfileSummary(Guid.NewGuid(), "Alpha")), session); + Assert.True(vm.CanSwitchProfile); + + session.IsRunning = true; // the timer flipped it + + Assert.False(vm.CanSwitchProfile); + Assert.Contains("Darktide is running", vm.ProfileSwitchTooltip); + } + + // ---- manage dialog coordination ---------------------------------------- + + [Fact] + public async Task ManageProfiles_opens_the_dialog_once() + { + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var dialogs = new FakeDialogService(); + var vm = Build(profiles, session, dialogs); + + await vm.ManageProfilesCommand.ExecuteAsync(null); + + Assert.Equal(1, dialogs.ManageProfilesCalls); + } + + [Fact] + public async Task ManageProfiles_refreshes_the_profile_list_after_close() + { + // The dialog (simulated) creates a profile during its session; the shell + // reloads its list snapshot when the dialog closes. + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession { ActiveProfileId = a.Id }; + var dialogs = new FakeDialogService + { + OnManageProfiles = () => profiles.CreateProfile("Bravo"), + }; + var vm = Build(profiles, session, dialogs); + + await vm.ManageProfilesCommand.ExecuteAsync(null); + + Assert.Contains(vm.Profiles, p => p.Name == "Bravo"); + } + + [Fact] + public async Task ManageProfiles_re_syncs_selection_to_the_session_active_after_close() + { + // The dialog applies active changes live through the session; on close the + // shell follows the session's authoritative active id. + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var b = new ProfileSummary(Guid.NewGuid(), "Bravo"); + var profiles = TestDoubles.Profiles(a, b); + var session = new FakeProfileSession { ActiveProfileId = a.Id, IsRunning = false }; + var dialogs = new FakeDialogService + { + OnManageProfiles = () => session.RequestActive(b.Id), + }; + var vm = Build(profiles, session, dialogs); + + await vm.ManageProfilesCommand.ExecuteAsync(null); + + Assert.Equal(b.Id, session.ActiveProfileId); + Assert.Equal(b.Id, vm.SelectedProfile?.Id); + } + + [Fact] + public async Task ManageProfiles_deleting_the_active_clears_selection_and_blocks_launch() + { + // Belt-and-suspenders: delete-of-active (not running) clears the active id; + // the shell's selection mirrors it to null, so CanLaunch (unchanged) keeps + // Launch blocked because no profile is selected. + var a = new ProfileSummary(Guid.NewGuid(), "Alpha"); + var profiles = TestDoubles.Profiles(a); + var session = new FakeProfileSession(() => profiles.ListProfiles()) + { + ActiveProfileId = a.Id, + IsRunning = false, + }; + var dialogs = new FakeDialogService + { + ConfirmResult = true, + OnManageProfiles = () => + { + // Simulate the dialog deleting the active profile: profile gone, + // session reconciles (clears the active id, per the fix). + profiles.DeleteProfile(a.Id); + session.ReconcileActive(); + }, + }; + var vm = Build(profiles, session, dialogs); + Assert.NotNull(vm.SelectedProfile); + + await vm.ManageProfilesCommand.ExecuteAsync(null); + + Assert.Null(vm.SelectedProfile); // active cleared + Assert.False(vm.LaunchCommand.CanExecute(null)); // Launch blocked (no selection) + } +} diff --git a/magos-modificus/tests/Magos.Modificus.UI.Tests/TestDoubles.cs b/magos-modificus/tests/Magos.Modificus.UI.Tests/TestDoubles.cs new file mode 100644 index 00000000..60e93b27 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.UI.Tests/TestDoubles.cs @@ -0,0 +1,239 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Magos.Modificus.EnginseerClient; +using Magos.Modificus.General; +using Magos.Modificus.Profiles; +using Magos.Modificus.SharedMods; +using Magos.Modificus.Steam; +using Magos.Modificus.UI.Dialogs; +using Magos.Modificus.UI.Session; + +namespace Magos.Modificus.UI.Tests; + +/// +/// Hand-rolled test doubles for the shell/manage VMs' dependencies. No mock +/// library is used anywhere in the repo; these recording fakes match that style +/// and keep the test project dependency-free. +/// +internal static class TestDoubles +{ + public static FakeProfileService Profiles(params ProfileSummary[] seed) => new(seed); +} + +/// +/// In-memory for VM tests: backs only the CRUD + +/// listing surface the shell/manage VMs touch. Records calls so tests can assert +/// on them. ModList/PrepareModRoot-style members throw +/// (out of scope for milestone-2 VM tests). +/// +internal sealed class FakeProfileService : IProfileService +{ + private readonly List _profiles; + + public FakeProfileService(IEnumerable seed) => + _profiles = new List(seed); + + public IReadOnlyList CreatedNames { get; } = new List(); + public IReadOnlyList<(Guid Id, string Name)> Renames { get; } = new List<(Guid, string)>(); + public IReadOnlyList DeletedIds { get; } = new List(); + + public IReadOnlyList ListProfiles() => + _profiles.OrderBy(p => p.Name, StringComparer.Ordinal).ToArray(); + + public Profile GetProfile(Guid id) + { + var summary = _profiles.FirstOrDefault(p => p.Id == id) + ?? throw new KeyNotFoundException($"No profile {id}"); + return new Profile { Id = summary.Id, Name = summary.Name }; + } + + public Profile CreateProfile(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("name required", nameof(name)); + } + + var created = new ProfileSummary(Guid.NewGuid(), name); + _profiles.Add(created); + ((List)CreatedNames).Add(name); + return new Profile { Id = created.Id, Name = created.Name }; + } + + public void RenameProfile(Guid id, string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + { + throw new ArgumentException("name required", nameof(newName)); + } + + var idx = _profiles.FindIndex(p => p.Id == id); + if (idx < 0) + { + throw new KeyNotFoundException($"No profile {id}"); + } + + _profiles[idx] = _profiles[idx] with { Name = newName }; + ((List<(Guid, string)>)Renames).Add((id, newName)); + } + + public void DeleteProfile(Guid id) + { + var idx = _profiles.FindIndex(p => p.Id == id); + if (idx < 0) + { + throw new KeyNotFoundException($"No profile {id}"); + } + + _profiles.RemoveAt(idx); + ((List)DeletedIds).Add(id); + } + + // ---- Out of scope for milestone-2 VM tests (Tracks B/C) ----------------- + + public IReadOnlyList GetModList(Guid id) => throw new NotImplementedException(); + public void SetModOrder(Guid id, IReadOnlyList modNamesInOrder) => throw new NotImplementedException(); + public void SetModEnabled(Guid id, string modName, bool enabled) => throw new NotImplementedException(); + public void AddMod(Guid id, string modName) => throw new NotImplementedException(); + public void AddMod(Guid id, string modName, ModVersionPolicy policy) => throw new NotImplementedException(); + public void SetModPolicy(Guid id, string modName, ModVersionPolicy policy) => throw new NotImplementedException(); + public void RemoveMod(Guid id, string modName) => throw new NotImplementedException(); + public string PrepareModRoot(Guid id) => throw new NotImplementedException(); +} + +/// Records reads/writes for assertion. +internal sealed class FakeAppStateStore : IAppStateStore +{ + public int SetCount { get; private set; } + public Guid? ActiveProfileId { get; set; } = null; + + Guid? IAppStateStore.ActiveProfileId + { + get => ActiveProfileId; + set + { + ActiveProfileId = value; + SetCount++; + } + } +} + +/// +/// Configurable dialog fake. drives +/// ; runs when the +/// manage-profiles dialog is opened (lets a test simulate the dialog creating / +/// deleting profiles and routing active changes through the session). Records +/// calls so tests can assert the dialog was opened. +/// +internal sealed class FakeDialogService : IDialogService +{ + public bool ConfirmResult { get; set; } = true; + public Action? OnManageProfiles { get; set; } + public int ConfirmCalls { get; private set; } + public string? LastConfirmMessage { get; private set; } + public int ManageProfilesCalls { get; private set; } + + public Task ConfirmAsync(string title, string message) + { + ConfirmCalls++; + LastConfirmMessage = message; + return Task.FromResult(ConfirmResult); + } + + public Task ShowManageProfilesAsync() + { + ManageProfilesCalls++; + OnManageProfiles?.Invoke(); + return Task.CompletedTask; + } +} + +/// with a configurable running flag. +internal sealed class FakeSteamService : ISteamService +{ + public bool Running { get; set; } + public bool IsGameRunning() => Running; + public DiscoveryResult Discover() => throw new NotImplementedException(); +} + +/// +/// In-memory for shell / dialog tests. Mirrors the +/// real session's gate ( no-ops when running), delete +/// gate ( locks the active id while running), and +/// recovery ( clears the active id when it no longer +/// exists). Raises so the shell +/// + dialog react to live changes the way the real polling +/// timer drives. +/// +internal sealed class FakeProfileSession : ObservableObject, IProfileSession +{ + private readonly Func>? _listProfiles; + private Guid? _activeProfileId; + private bool _isRunning; + + public FakeProfileSession(Func>? listProfiles = null) + { + _listProfiles = listProfiles; + } + + public Guid? ActiveProfileId + { + get => _activeProfileId; + set => SetProperty(ref _activeProfileId, value); + } + + public bool IsRunning + { + get => _isRunning; + set => SetProperty(ref _isRunning, value); + } + + public int RequestActiveCalls { get; private set; } + public Guid? LastRequestedId { get; private set; } + + public void RequestActive(Guid id) + { + RequestActiveCalls++; + LastRequestedId = id; + if (IsRunning) + { + return; + } + + ActiveProfileId = id; + } + + public int CanDeleteProfileCalls { get; private set; } + + /// Mirrors the real session: the active id is locked while running. + public bool CanDeleteProfile(Guid id) + { + CanDeleteProfileCalls++; + return !(id == ActiveProfileId && IsRunning); + } + + public int ReconcileCalls { get; private set; } + + public void ReconcileActive() + { + ReconcileCalls++; + if (_listProfiles is null || _activeProfileId is not Guid id) + { + return; + } + + var existing = _listProfiles(); + if (existing.Any(p => p.Id == id)) + { + return; + } + + ActiveProfileId = null; + } +} + +/// No-op launch service (launch is Track C; not exercised here). +internal sealed class FakeLaunchService : IEnginseerLaunchService +{ + public LaunchResult Launch(Guid profileId) => + new(LaunchStatus.Launched, null, Array.Empty()); +} diff --git a/magos-modificus/ui/App.axaml.cs b/magos-modificus/ui/App.axaml.cs index 7b7422cf..c5137127 100644 --- a/magos-modificus/ui/App.axaml.cs +++ b/magos-modificus/ui/App.axaml.cs @@ -2,13 +2,8 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Magos.Modificus.Config; -using Magos.Modificus.Integrations; -using Magos.Modificus.Profiles; -using Magos.Modificus.Steam; using Magos.Modificus.UI.ViewModels; using Magos.Modificus.UI.Views; -using Magos.Modificus.EnginseerClient; -using Magos.Modificus.Launcher; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -16,7 +11,8 @@ namespace Magos.Modificus.UI; /// /// The Avalonia application. Also the startup log site: the composition root -/// runs here, and config + DI wiring are logged so the scaffold is observable. +/// runs here, and config loading is logged so startup is observable. The shell +/// view model resolves the backend services itself (no Phase-0 probe needed). /// public class App : Application { @@ -31,9 +27,9 @@ public override void OnFrameworkInitializationCompleted() var logger = services.GetRequiredService>(); var config = services.GetRequiredService(); - logger.LogInformation("Magos Modificus starting (Phase 0 scaffold)"); + logger.LogInformation("Magos Modificus starting"); logger.LogInformation( - "Config loaded — ProfilesBaseFolder={Profiles}; SharedModsFolder={Shared}; " + + "Config loaded: ProfilesBaseFolder={Profiles}; SharedModsFolder={Shared}; " + "EnginseerRuntimeDir={Runtime}; LogLevel={Level}; LogFile={LogFile}", config.ProfilesBaseFolder, config.SharedModsFolder, @@ -41,29 +37,13 @@ public override void OnFrameworkInitializationCompleted() config.Logging.Level, config.Logging.LogFile); - // Resolve each domain service to prove the library Add<>() registrations - // are wired and resolvable. - var wired = ResolveDomainServices(services); - logger.LogInformation("DI wired: {Count} domain services resolved", wired); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var mainWindow = services.GetRequiredService(); - mainWindow.DataContext = services.GetRequiredService(); + mainWindow.DataContext = services.GetRequiredService(); desktop.MainWindow = mainWindow; } base.OnFrameworkInitializationCompleted(); } - - private static int ResolveDomainServices(IServiceProvider services) - { - var count = 0; - if (services.GetService() is not null) count++; - if (services.GetService() is not null) count++; - if (services.GetService() is not null) count++; - if (services.GetService() is not null) count++; - if (services.GetService() is not null) count++; - return count; - } } diff --git a/magos-modificus/ui/Behaviors/FocusOnVisible.cs b/magos-modificus/ui/Behaviors/FocusOnVisible.cs new file mode 100644 index 00000000..fffee13d --- /dev/null +++ b/magos-modificus/ui/Behaviors/FocusOnVisible.cs @@ -0,0 +1,69 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace Magos.Modificus.UI.Behaviors; + +/// +/// Attached behavior: when a becomes visible, focus it +/// and select all its text. Used by the "Manage profiles" editable list so the +/// inline rename / "+ New profile" entry box grabs focus + selects its text the +/// moment it appears; without this the row would show a TextBox the user must +/// still click into, which defeats inline editing. +/// +/// +/// Observes via +/// (a guaranteed, stable API) rather +/// than a visibility-changed event, so the behavior is robust across Avalonia +/// revisions. View-only: not exercised by VM unit tests. +/// +public static class FocusOnVisible +{ + /// When true on a TextBox, focus + select-all when it shows. + public static readonly AttachedProperty IsEnabledProperty = + AvaloniaProperty.RegisterAttached("IsEnabled", typeof(FocusOnVisible)); + + static FocusOnVisible() + { + IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); + } + + public static bool GetIsEnabled(TextBox element) => element.GetValue(IsEnabledProperty); + public static void SetIsEnabled(TextBox element, bool value) => element.SetValue(IsEnabledProperty, value); + + private static void OnIsEnabledChanged(TextBox box, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is false) + { + return; + } + + box.PropertyChanged += OnPropertyChanged; + + // Already visible when attached (re-templated / shown before the + // property was set): grab focus now rather than waiting for a toggle. + if (box.IsVisible) + { + GrabFocus(box); + } + } + + private static void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (sender is not TextBox box) + { + return; + } + + if (e.Property == Visual.IsVisibleProperty && e.NewValue is true) + { + GrabFocus(box); + } + } + + private static void GrabFocus(TextBox box) + { + box.Focus(NavigationMethod.Pointer, KeyModifiers.None); + box.SelectAll(); + } +} diff --git a/magos-modificus/ui/Dialogs/DialogService.cs b/magos-modificus/ui/Dialogs/DialogService.cs new file mode 100644 index 00000000..90dd20d2 --- /dev/null +++ b/magos-modificus/ui/Dialogs/DialogService.cs @@ -0,0 +1,58 @@ +using Avalonia.Controls; +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.Session; +using Magos.Modificus.UI.ViewModels; +using Magos.Modificus.UI.Views; + +namespace Magos.Modificus.UI.Dialogs; + +/// +/// Production . Owns all real Avalonia +/// Window/ShowDialog wiring so view models never construct windows +/// directly. Dialogs are shown modally over the owning main window. This is the +/// only place the app news-up a dialog window, everything else flows through +/// the seam, which tests replace with a fake. +/// +public sealed class DialogService : IDialogService +{ + private readonly Window _owner; + private readonly IProfileService _profiles; + private readonly IProfileSession _session; + + /// The window dialog parents are shown over (the main window). + /// Resolved lazily to construct the manage-profiles VM. + /// The active-profile authority; handed to the manage-profiles + /// VM so its marker reads the live active id and its create/delete route active + /// changes through the session's gate. + public DialogService(Window owner, IProfileService profiles, IProfileSession session) + { + _owner = owner; + _profiles = profiles; + _session = session; + } + + /// + public async Task ConfirmAsync(string title, string message) + { + var dialog = new ConfirmDialog + { + Title = title, + }; + dialog.SetMessage(message); + + await dialog.ShowDialog(_owner); + return dialog.Result; + } + + /// + public async Task ShowManageProfilesAsync() + { + var viewModel = new ManageProfilesViewModel(_profiles, this, _session); + var window = new ManageProfilesWindow + { + DataContext = viewModel, + }; + + await window.ShowDialog(_owner); + } +} diff --git a/magos-modificus/ui/Dialogs/IDialogService.cs b/magos-modificus/ui/Dialogs/IDialogService.cs new file mode 100644 index 00000000..d36a3d12 --- /dev/null +++ b/magos-modificus/ui/Dialogs/IDialogService.cs @@ -0,0 +1,31 @@ +using Magos.Modificus.UI.Session; + +namespace Magos.Modificus.UI.Dialogs; + +/// +/// The application's UI-dialog abstraction. Keeps view models free of direct +/// Avalonia Window construction so their logic stays unit-testable: a VM +/// depends on this seam, and tests inject a recording fake instead of a real +/// window. The production implementation () owns all +/// real Window/ShowDialog wiring. +/// +public interface IDialogService +{ + /// + /// Shows a modal confirmation prompt. Returns true when the user + /// confirms, false otherwise (cancel / dismiss). Used to gate + /// destructive actions (e.g. profile delete: real data loss). + /// + Task ConfirmAsync(string title, string message); + + /// + /// Opens the "Manage profiles…" modal dialog (create / rename / delete). + /// Active changes are applied live through the + /// during the dialog's session (create requests the new id active, gated by + /// the session; delete-of-active reconciles), so by the time this completes + /// the session already reflects whatever the gate allowed. The caller just + /// refreshes its profile-list snapshot on completion; there is no + /// returned-active-id for the shell to gate. + /// + Task ShowManageProfilesAsync(); +} diff --git a/magos-modificus/ui/MagosComposition.cs b/magos-modificus/ui/MagosComposition.cs index 2c822647..c449261b 100644 --- a/magos-modificus/ui/MagosComposition.cs +++ b/magos-modificus/ui/MagosComposition.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Magos.Modificus.Config; @@ -6,6 +7,8 @@ using Magos.Modificus.Profiles; using Magos.Modificus.SharedMods; using Magos.Modificus.Steam; +using Magos.Modificus.UI.Dialogs; +using Magos.Modificus.UI.Session; using Magos.Modificus.UI.ViewModels; using Magos.Modificus.UI.Views; using Magos.Modificus.EnginseerClient; @@ -16,8 +19,9 @@ namespace Magos.Modificus.UI; /// /// The DI composition root. Loads config, builds the structured logger, wires /// every library's Add<Library>() extension, and registers the UI -/// surface (main window + view model). All data operations flow through the -/// registered library services — the UI never touches files or APIs directly. +/// surface (main window + view model + dialog service). All data operations flow +/// through the registered library services; the UI never touches files or APIs +/// directly. /// public static class MagosComposition { @@ -42,9 +46,42 @@ public static IServiceProvider Build() services.AddSteam(); services.AddEnginseerClient(); services.AddLauncher(); - services.AddTransient(); - services.AddSingleton(); + + // UI surface. MainWindow is a singleton: the desktop lifetime installs + // the resolved instance as desktop.MainWindow, and DialogService resolves + // the same one as the owner for modal dialogs. IProfileSession is the + // single active-profile + running-state authority shared by the shell and + // the manage-profiles dialog (its polling timer drives the live status). + services.AddSingleton(sp => new ProfileSession( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + StartRunningStatePolling)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + new DialogService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); return services.BuildServiceProvider(); } + + /// + /// The live running-state poll: a that pings + /// every few seconds so the status + /// strip + launch-availability + dropdown-enable react to the game starting or + /// stopping while Magos is open. Runs on the UI thread (composition happens + /// during app startup, also on the UI thread). + /// + private static void StartRunningStatePolling(Action onTick) + { + var timer = new DispatcherTimer + { + Interval = ProfileSession.PollInterval, + }; + timer.Tick += (_, _) => onTick(); + timer.Start(); + } } diff --git a/magos-modificus/ui/Session/IProfileSession.cs b/magos-modificus/ui/Session/IProfileSession.cs new file mode 100644 index 00000000..8b26afb2 --- /dev/null +++ b/magos-modificus/ui/Session/IProfileSession.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using Magos.Modificus.Profiles; + +namespace Magos.Modificus.UI.Session; + +/// +/// The single authority for "which profile is active, can it change, and is the +/// game running." Both the app shell and the Manage-profiles dialog consume this; +/// neither decides the can-change gate nor tracks its own running-state. Owns the +/// active id (observable + persisted), the can-change gate, and the LIVE +/// running-state. Does NOT own profile CRUD (create/rename/delete stay on +/// , driven by the dialog); it only owns active +/// state, the gate, and running-state. +/// +/// +/// The gate lives here, once: is the +/// sole place a voluntary active change is allowed or rejected (applied only +/// when the game isn't running). Both the dropdown switch and the dialog's +/// create-sets-active route through it, so the two can never diverge. +/// Delete-of-active: the active profile is locked while Darktide runs +/// ( gates the delete), so delete-of-active only +/// happens when the game is stopped. then clears the +/// active id (null) so the user explicitly picks the next; it never auto-selects a +/// remaining profile on someone's behalf. +/// Observable: implements so the +/// shell reacts to live changes driven by the session's +/// polling timer (status strip + launch-availability + dropdown-enable update +/// within a few seconds of the game starting or stopping). +/// +public interface IProfileSession : INotifyPropertyChanged +{ + /// The current active profile id, or null when none is active. + Guid? ActiveProfileId { get; } + + /// + /// Whether Darktide is currently running. LIVE, refreshed by a polling timer + /// (~3s, cheap process scan). The status strip, launch-availability, and the + /// switch-block gate all read this. + /// + bool IsRunning { get; } + + /// + /// The SOLE active-change gate. Requests as the active + /// profile: applied + persisted only when the game isn't running; otherwise a + /// no-op (the active stays put). Both the dropdown switch and the dialog's + /// create-sets-active call this. Rename and delete-of-active do not (rename + /// leaves the id stable; delete uses ). + /// + void RequestActive(Guid id); + + /// + /// Whether the profile may be deleted right now. The + /// single authority for the delete gate: the active profile is locked while + /// Darktide runs (false when is the active id and + /// the game is running); every other profile is deletable (true). The + /// Manage-profiles dialog binds each row's trash button to this so the active + /// row's trash disables while the game runs. + /// + bool CanDeleteProfile(Guid id); + + /// + /// Recovery after CRUD that may have removed the active profile: if the current + /// active id no longer exists in , + /// clears the active id (null) and persists. Delete-of-active is blocked while + /// the game runs (), so by the time this runs the + /// game is stopped and null is the correct outcome (the user explicitly picks + /// the next profile; we never auto-select a remaining one). A no-op when the + /// active id is still present, or when no active is set (first run / nothing + /// chosen). + /// + void ReconcileActive(); +} diff --git a/magos-modificus/ui/Session/ProfileSession.cs b/magos-modificus/ui/Session/ProfileSession.cs new file mode 100644 index 00000000..51658d3c --- /dev/null +++ b/magos-modificus/ui/Session/ProfileSession.cs @@ -0,0 +1,126 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Magos.Modificus.General; +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; + +namespace Magos.Modificus.UI.Session; + +/// +/// Production . Owns the active id (restored from +/// at startup, persisted on every change), the +/// can-change gate (), and the live running-state (a +/// polling +/// roughly every 3 seconds; a cheap process scan that catches external game +/// start/stop while Magos is open). +/// +/// +/// Testability: the polling timer is injected as a +/// delegate, so unit tests construct the session +/// without a UI dispatcher and drive directly for +/// deterministic running-state changes. The session's own logic (gate, +/// persistence, fallback) has no time dependency and no Avalonia dependency. +/// +public sealed partial class ProfileSession : ObservableObject, IProfileSession +{ + /// The polling interval for the live running-state. + public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3); + + private readonly ISteamService _steam; + private readonly IProfileService _profiles; + private readonly IAppStateStore _appState; + + /// The running-state source (). + /// Used by to detect delete-of-active. + /// Where the active id is persisted across restarts. + /// Starts the periodic running-state poll. Production + /// wires this to a (UI thread); tests pass null + /// and call directly for deterministic changes. + public ProfileSession( + ISteamService steam, + IProfileService profiles, + IAppStateStore appState, + Action? startTimer = null) + { + _steam = steam; + _profiles = profiles; + _appState = appState; + + // Restore the persisted active id straight into the field (no write-back, + // no subscribers yet). A stale id (deleted while Magos was closed) resolves + // to no selection in the shell; it is cleaned up lazily on the next + // delete-of-active reconcile rather than rewritten at startup. + _activeProfileId = appState.ActiveProfileId; + + // Snapshot the initial running-state, then start the live poll. + Refresh(); + startTimer?.Invoke(Refresh); + } + + /// The current active profile id. Persisted on every change. + [ObservableProperty] + private Guid? _activeProfileId; + + /// Persists the active id whenever it changes. + partial void OnActiveProfileIdChanged(Guid? value) + { + _appState.ActiveProfileId = value; + } + + /// LIVE running-state, refreshed by the polling timer. + [ObservableProperty] + private bool _isRunning; + + /// + /// The SOLE active-change gate. Applies + persists only when the game isn't + /// running; otherwise a no-op. See . + /// + public void RequestActive(Guid id) + { + if (IsRunning) + { + return; + } + + ActiveProfileId = id; + } + + /// + /// The delete gate. See . + /// + public bool CanDeleteProfile(Guid id) => !(id == ActiveProfileId && IsRunning); + + /// + /// Recovery after delete-of-active. See + /// . + /// + public void ReconcileActive() + { + // Only recover when an active id was set but no longer exists. Delete-of-active + // is blocked while the game runs (CanDeleteProfile), so reaching here means the + // game is stopped and the active should clear: the user explicitly picks the + // next profile, we never auto-select a remaining one on someone's behalf. + if (ActiveProfileId is not Guid id) + { + return; + } + + var existing = _profiles.ListProfiles(); + if (existing.Any(p => p.Id == id)) + { + return; + } + + ActiveProfileId = null; + } + + /// + /// Re-checks and updates + /// . Invoked by the polling timer; exposed so unit + /// tests drive running-state changes deterministically without real time. + /// + public void Refresh() + { + IsRunning = _steam.IsGameRunning(); + } +} diff --git a/magos-modificus/ui/ViewModels/MainViewModel.cs b/magos-modificus/ui/ViewModels/MainViewModel.cs deleted file mode 100644 index f96d8e7a..00000000 --- a/magos-modificus/ui/ViewModels/MainViewModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using Magos.Modificus.Config; - -namespace Magos.Modificus.UI.ViewModels; - -/// -/// Minimal view model for the Phase 0 window — exposes the loaded config so -/// the window proves config/logging/DI are wired (displays the resolved paths -/// and the log-file location). -/// -public partial class MainViewModel : ObservableObject -{ - public MainViewModel(MagosConfig config) - { - ConfigSummary = - $"Profiles base folder:{Environment.NewLine} {config.ProfilesBaseFolder}{Environment.NewLine}{Environment.NewLine}" + - $"Shared mods folder:{Environment.NewLine} {config.SharedModsFolder}{Environment.NewLine}{Environment.NewLine}" + - $"Enginseer runtime dir:{Environment.NewLine} {config.EnginseerRuntimeDir}{Environment.NewLine}{Environment.NewLine}" + - $"Log level: {config.Logging.Level}{Environment.NewLine}" + - $"Log file:{Environment.NewLine} {config.Logging.LogFile}{Environment.NewLine}{Environment.NewLine}" + - "Phase 0 scaffold — libraries are stubs; UI is minimal."; - } - - [ObservableProperty] - private string _configSummary = string.Empty; -} diff --git a/magos-modificus/ui/ViewModels/ManageProfilesViewModel.cs b/magos-modificus/ui/ViewModels/ManageProfilesViewModel.cs new file mode 100644 index 00000000..fe1f4213 --- /dev/null +++ b/magos-modificus/ui/ViewModels/ManageProfilesViewModel.cs @@ -0,0 +1,305 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.Dialogs; +using Magos.Modificus.UI.Session; + +namespace Magos.Modificus.UI.ViewModels; + +/// +/// The view model behind the "Manage profiles…" dialog, an editable list of +/// profiles with per-row inline rename + delete and an "+ New profile" add row. +/// It is deliberately CRUD-only: no mod-list and no launch behavior (those are +/// Tracks B/C). Mutations flow straight to ; the list +/// rebuilds after each. +/// +/// +/// Editable-list pattern: each row is a +/// (profile id + name + inline-edit state + active marker). The pencil flips a row +/// into inline edit; Enter / blur commits (rename), Esc cancels. The trash opens +/// the delete-confirm flow. The "+ New profile" row toggles an inline name entry; +/// Enter creates, Esc cancels. The active profile is marked so it is visible which +/// one a delete would remove. +/// Active state is the session's, not the dialog's: the dialog never +/// tracks its own active id. The active marker reads +/// (truthful by construction). Create calls +/// then (the session gates it; the +/// dialog does not decide). Delete calls +/// then (clears the active id when the +/// active itself was deleted). Rename leaves the active untouched (the id is stable +/// across renames). The per-row trash button binds its enabled state to +/// , so the active row's trash disables +/// while Darktide runs (delete-of-active is blocked); the dialog subscribes to the +/// session's live state so the trash flips the moment the game starts or stops. +/// Because active changes are applied live through the session, the dialog returns +/// nothing to the shell; the shell refreshes its list on close. +/// +public partial class ManageProfilesViewModel : ObservableObject +{ + private readonly IProfileService _profiles; + private readonly IDialogService _dialogs; + private readonly IProfileSession _session; + + /// The profile service (CRUD). + /// The dialog seam, used for the delete confirmation. + /// The active-profile authority. The marker reads its + /// active id; the trash enable-state reads ; + /// create + delete-of-active route active changes through it. + public ManageProfilesViewModel( + IProfileService profiles, + IDialogService dialogs, + IProfileSession session) + { + _profiles = profiles; + _dialogs = dialogs; + _session = session; + + // The trash enable-state + active marker depend on the session's live state + // (the polling timer can flip IsRunning while the dialog is open), so react to + // its changes. Detached on window close (the session outlives this dialog). + _session.PropertyChanged += OnSessionPropertyChanged; + + Refresh(); + } + + /// + /// Unsubscribes from the session so this short-lived dialog VM is collectable + /// after its window closes (the session is a singleton that outlives the dialog). + /// Called by the manage-profiles window on close. + /// + public void Detach() => _session.PropertyChanged -= OnSessionPropertyChanged; + + /// + /// Re-applies the session's active marker + delete-enable to the existing rows + /// when the session's live state changes while the dialog is open (the polling + /// timer flipping , or an active change). + /// No full rebuild, so any in-flight inline edit / scroll position is preserved. + /// + private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(IProfileSession.IsRunning) + or nameof(IProfileSession.ActiveProfileId)) + { + ApplySessionState(); + } + } + + /// The editable profile rows (name + active marker + inline actions). + [ObservableProperty] + private IReadOnlyList _items = Array.Empty(); + + /// + /// Whether the "+ New profile" add-row is showing its inline name-entry box + /// (false = the add button is shown, true = the entry box is shown). + /// + [ObservableProperty] + private bool _isAddingNew; + + /// The name typed into the add-row; Enter creates, Esc cancels. + [ObservableProperty] + private string _newProfileName = string.Empty; + + // ---- inline rename ----------------------------------------------------- + + /// + /// Flips a row into inline rename mode: any in-flight rename on another row + /// is cancelled first (only one edit at a time), and the row's edit text is + /// pre-filled with its current name. + /// + [RelayCommand] + private void StartRename(ProfileItemViewModel? item) + { + if (item is null) + { + return; + } + + ExitAllEdits(); + item.EditText = item.Name; + item.IsEditing = true; + } + + /// + /// Commits the inline rename: trims the entry, and if it is non-empty and + /// different from the current name, calls + /// and updates the row's name in place. An empty / unchanged entry is a silent + /// revert (no rename, no error), commit-on-blur semantics. Always exits edit + /// mode (no-op + safe if already exited, so Enter then blur does not double-rename). + /// + [RelayCommand] + private void CommitRename(ProfileItemViewModel? item) + { + if (item is null || !item.IsEditing) + { + return; + } + + var name = item.EditText.Trim(); + item.IsEditing = false; + + if (string.IsNullOrEmpty(name) || name == item.Name) + { + return; + } + + _profiles.RenameProfile(item.Id, name); + item.Name = name; + } + + /// Cancels the inline rename, discards the entry, exits edit mode. + [RelayCommand] + private void CancelRename(ProfileItemViewModel? item) + { + if (item is null) + { + return; + } + + item.IsEditing = false; + } + + // ---- delete ----------------------------------------------------------- + + /// + /// Deletes the row's profile after a confirmation (real data loss: the profile + /// config + its owned local mod copies). The active profile is locked while + /// Darktide runs: is the single + /// authority and is consulted here too (defense-in-depth, not only via the + /// trash button's binding), so delete-of-active only proceeds when the game is + /// stopped; then clears the active + /// id (null) so the user explicitly picks the next. A non-active profile is + /// deletable anytime; deleting it does not touch the active. + /// + [RelayCommand] + private async Task DeleteProfile(ProfileItemViewModel? item) + { + if (item is null) + { + return; + } + + // Defense-in-depth: the per-row trash button binds its IsEnabled to the + // session's gate, but a programmatic DeleteProfileCommand.Execute on the + // active row while the game runs would bypass that binding. Consult the + // single authority here too; bail before the confirm + delete flow. + if (!_session.CanDeleteProfile(item.Id)) + { + return; + } + + var name = item.Name; + var confirmed = await _dialogs.ConfirmAsync( + "Delete profile", + $"Delete profile {name}? This removes its mod list and any local mod copies in mods/."); + + if (!confirmed) + { + return; + } + + _profiles.DeleteProfile(item.Id); + _session.ReconcileActive(); + Refresh(); + } + + // ---- create ("+ New profile") ----------------------------------------- + + /// + /// Flips the add-row into inline name entry: cancels any in-flight rename + /// first, clears the entry, and shows the box. + /// + [RelayCommand] + private void StartCreate() + { + ExitAllEdits(); + NewProfileName = string.Empty; + IsAddingNew = true; + } + + /// + /// Commits the add-row: trims the entry, and if non-empty, creates the profile + /// then requests it active through the session. The session gates the active + /// change (only when the game isn't running), so the marker can only move to + /// the new profile when the change will actually take; when the game is running + /// the profile is still created (it appears in the list) but the marker stays + /// on the current active. An empty entry is a silent cancel (no create). + /// Always exits add mode (no-op + safe if already exited, so Enter then blur + /// does not double-create). + /// + [RelayCommand] + private void CommitCreate() + { + if (!IsAddingNew) + { + return; + } + + var name = NewProfileName.Trim(); + IsAddingNew = false; + NewProfileName = string.Empty; + + if (string.IsNullOrEmpty(name)) + { + return; + } + + var created = _profiles.CreateProfile(name); + _session.RequestActive(created.Id); + Refresh(); + } + + /// Cancels the add-row, discards the entry, hides the box. + [RelayCommand] + private void CancelCreate() + { + IsAddingNew = false; + NewProfileName = string.Empty; + } + + // ---- helpers ----------------------------------------------------------- + + /// Cancels any in-flight inline rename across all rows. + private void ExitAllEdits() + { + foreach (var item in Items) + { + item.IsEditing = false; + } + } + + /// + /// Re-applies the session's active marker + delete-enable to the existing rows + /// (see ). Kept separate from + /// so live running-state changes do not disturb the list. + /// + private void ApplySessionState() + { + var activeId = _session.ActiveProfileId; + foreach (var item in Items) + { + item.IsActive = item.Id == activeId; + item.IsDeleteEnabled = _session.CanDeleteProfile(item.Id); + } + } + + /// + /// Rebuilds the row list from the service, marking the active profile's row + /// (read from ) and setting each + /// row's delete-enable from , so + /// the marker renders on the session's authoritative active profile and the + /// active row's trash disables while Darktide runs. + /// + private void Refresh() + { + var activeId = _session.ActiveProfileId; + Items = _profiles.ListProfiles() + .Select(s => + { + var item = new ProfileItemViewModel(s.Id, s.Name) { IsActive = s.Id == activeId }; + item.IsDeleteEnabled = _session.CanDeleteProfile(s.Id); + return item; + }) + .ToArray(); + } +} diff --git a/magos-modificus/ui/ViewModels/ProfileItemViewModel.cs b/magos-modificus/ui/ViewModels/ProfileItemViewModel.cs new file mode 100644 index 00000000..54002cfe --- /dev/null +++ b/magos-modificus/ui/ViewModels/ProfileItemViewModel.cs @@ -0,0 +1,69 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Magos.Modificus.UI.ViewModels; + +/// +/// One row in the "Manage profiles" editable list. Wraps a profile's stable +/// identity + display name, plus the per-row inline-edit state (rename) and the +/// active marker. The parent owns all CRUD; +/// this row carries state only; it never talks to +/// directly. +/// +/// +/// Identity () is immutable; is updated +/// in place after a successful rename (avoids a full list rebuild, so focus / +/// scroll position are not disturbed). + +/// drive the inline rename TextBox; +/// drives the ● marker; drives the trash button's +/// enabled state (the active profile is locked while Darktide runs, read from the +/// session's authority by the parent dialog). +/// +public partial class ProfileItemViewModel : ObservableObject +{ + /// The profile's stable identity (unchanged across renames). + public Guid Id { get; } + + /// The display name; updated in place after a rename commits. + [ObservableProperty] + private string _name; + + /// Whether this row is the active profile (drives the ● marker). + [ObservableProperty] + private bool _isActive; + + /// Whether this row is currently showing its inline rename TextBox. + [ObservableProperty] + private bool _isEditing; + + /// + /// Whether this row's trash button is enabled: the active profile is locked + /// while Darktide runs (the parent dialog reads + /// ), so its trash + /// disables while the game runs. Defaults to true; the dialog sets it. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeleteTooltip))] + private bool _isDeleteEnabled = true; + + /// + /// The trash button tooltip: the normal "Delete", or the lock explanation when + /// this row is the active profile held while Darktide runs. Derived from + /// so it tracks the gate automatically. + /// + public string DeleteTooltip => IsDeleteEnabled + ? "Delete" + : "Can't delete the active profile while Darktide is running."; + + /// + /// The in-flight rename value. Pre-filled from on edit + /// start; committed (if non-empty + changed) on Enter / blur, discarded on Esc. + /// + [ObservableProperty] + private string _editText = string.Empty; + + public ProfileItemViewModel(Guid id, string name) + { + Id = id; + _name = name; + } +} diff --git a/magos-modificus/ui/ViewModels/ShellViewModel.cs b/magos-modificus/ui/ViewModels/ShellViewModel.cs new file mode 100644 index 00000000..ed790cf1 --- /dev/null +++ b/magos-modificus/ui/ViewModels/ShellViewModel.cs @@ -0,0 +1,229 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Magos.Modificus.EnginseerClient; +using Magos.Modificus.Profiles; +using Magos.Modificus.UI.Dialogs; +using Magos.Modificus.UI.Session; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.UI.ViewModels; + +/// +/// The view model behind the Magos Modificus main window, the Phase 3 Track A +/// app shell. Milestone 2 makes the profile controls work: the dropdown switches +/// the active profile (the request flows through , +/// which owns the active id + persistence), switching is blocked while Darktide +/// runs (the session gates it), and "Manage profiles…" opens a CRUD dialog. The +/// shell owns only the profile-list snapshot + the dropdown selection binding; +/// the session is the single source of truth for the active id, the +/// can-change gate, and the LIVE running-state. +/// +/// +/// Running-state is live: the shell mirrors +/// from , which a polling timer refreshes. +/// So the status strip, launch-availability, and dropdown-enable react within a +/// few seconds of Darktide starting or stopping while Magos is open. +/// Track C (Launch behavior) and Track B (mod-list contents) are not wired +/// here yet. stays a no-op placeholder whose guard is +/// real so it lights up once a profile is selected and the game is stopped. +/// +public partial class ShellViewModel : ObservableObject +{ + private readonly IProfileService _profileService; + private readonly IProfileSession _session; + private readonly IEnginseerLaunchService _launchService; + private readonly IDialogService _dialogs; + private readonly ILogger _logger; + + // Guards selection updates while the shell mirrors the session's authoritative + // active id back into the dropdown, so re-syncing the selection does not + // re-request an active change (avoids a feedback loop). + private bool _syncing; + + /// + /// Creates the shell VM, loads the profile list, mirrors the session's current + /// active id + running-state, and subscribes to live running-state changes. + /// + public ShellViewModel( + IProfileService profiles, + IProfileSession session, + IEnginseerLaunchService launchService, + IDialogService dialogs, + ILogger logger) + { + _profileService = profiles; + _session = session; + _launchService = launchService; + _dialogs = dialogs; + _logger = logger; + + // Set the backing fields directly: no subscribers yet, and setting + // SelectedProfile through the property would route through the selection + // handler (which requests an active change) during the initial restore. + _profiles = _profileService.ListProfiles(); + _isGameRunning = _session.IsRunning; + _selectedProfile = ResolveActive(); + + _session.PropertyChanged += OnSessionPropertyChanged; + + _logger.LogInformation( + "Shell initialized: {ProfileCount} profile(s) loaded; active={ActiveId}; " + + "Darktide running: {IsRunning}; launch facade: {LaunchFacade}", + Profiles.Count, + _session.ActiveProfileId?.ToString() ?? "(none)", + IsGameRunning, + _launchService.GetType().Name); + } + + /// + /// All known profiles, a snapshot of , + /// refreshed after the management dialog closes. Empty pre-release until a + /// profile is created. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasProfiles))] + [NotifyPropertyChangedFor(nameof(CanSwitchProfile))] + [NotifyPropertyChangedFor(nameof(ProfileSwitchTooltip))] + private IReadOnlyList _profiles = Array.Empty(); + + /// Whether at least one profile exists. + public bool HasProfiles => Profiles.Count > 0; + + /// + /// The currently-selected (active) profile, or null. Bound to the + /// top-bar dropdown; selecting requests the active change through the session + /// (which gates it). The shell then re-syncs to the session's authoritative + /// active id, so a blocked change snaps the dropdown back to the real active. + /// + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LaunchCommand))] + [NotifyPropertyChangedFor(nameof(HasSelectedProfile))] + private ProfileSummary? _selectedProfile; + + /// Whether a profile is currently selected (drives the mod-list empty state). + public bool HasSelectedProfile => SelectedProfile is not null; + + /// + /// Whether Darktide is currently running, mirrored LIVE from + /// (a polling timer refreshes it). + /// Gates profile switching () and launch. + /// + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LaunchCommand))] + [NotifyPropertyChangedFor(nameof(GameRunningText))] + [NotifyPropertyChangedFor(nameof(CanSwitchProfile))] + [NotifyPropertyChangedFor(nameof(ProfileSwitchTooltip))] + private bool _isGameRunning; + + /// Status-strip label for the game-running indicator. + public string GameRunningText => IsGameRunning ? "Darktide: running" : "Darktide: not running"; + + /// + /// Whether the profile dropdown is interactive: a profile must exist and the + /// game must not be running. The gate itself lives in the session; this just + /// exposes the running-state so the dropdown can disable while Darktide runs. + /// + public bool CanSwitchProfile => !IsGameRunning && HasProfiles; + + /// + /// Tooltip explaining the dropdown's current enabled state (the block reason + /// when the game is running, or a first-run hint when no profile exists). + /// + public string ProfileSwitchTooltip => + IsGameRunning ? "Darktide is running; stop it before switching profiles" + : HasProfiles ? string.Empty + : "Create a profile first"; + + /// + /// The dropdown (or a programmatic set) changed the selection. Asks the session + /// to make it active; the session gates it (only when the game isn't running). + /// Then re-syncs to the session's authoritative active id so a blocked or + /// cleared selection reverts, the dropdown never lies about the active profile. + /// + partial void OnSelectedProfileChanged(ProfileSummary? value) + { + if (_syncing) + { + return; + } + + _syncing = true; + try + { + if (value is { } profile) + { + _session.RequestActive(profile.Id); + } + + SelectedProfile = ResolveActive(); + } + finally + { + _syncing = false; + } + } + + /// + /// Mirrors the session's live running-state into + /// (the status strip, launch-availability, and dropdown-enable all cascade + /// from it). Active-id changes are handled at the known points they can occur + /// (dropdown request + after the dialog), not here. + /// + private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IProfileSession.IsRunning)) + { + IsGameRunning = _session.IsRunning; + } + } + + /// + /// Opens the "Manage profiles…" dialog, then reloads the profile list and + /// re-syncs the selection to the session's active id. The dialog applies + /// active changes live through the session during its session, so by the time + /// it closes the session already reflects whatever the gate allowed; the shell + /// just refreshes its list snapshot and follows the authoritative active id. + /// + [RelayCommand] + private async Task ManageProfiles() + { + await _dialogs.ShowManageProfilesAsync(); + + Profiles = _profileService.ListProfiles(); + + _syncing = true; + try + { + SelectedProfile = ResolveActive(); + } + finally + { + _syncing = false; + } + } + + /// + /// Resolves the session's active id to the matching profile in the current + /// list (null when the id is unknown or no profile exists). + /// + private ProfileSummary? ResolveActive() => + _session.ActiveProfileId is Guid id + ? Profiles.FirstOrDefault(p => p.Id == id) + : null; + + /// + /// Track C: launch is not wired yet. The command is present so the Launch + /// button binds cleanly; encodes the real guard (a + /// profile must be selected and the game must not already be running), so it + /// lights up once selection lands. + /// + [RelayCommand(CanExecute = nameof(CanLaunch))] + private void Launch() + { + // Track C: _launchService.Launch(SelectedProfile!.Id) + LaunchResult handling. + } + + /// A profile must be selected and the game must not be running. + private bool CanLaunch() => SelectedProfile is not null && !IsGameRunning; +} diff --git a/magos-modificus/ui/Views/ConfirmDialog.axaml b/magos-modificus/ui/Views/ConfirmDialog.axaml new file mode 100644 index 00000000..9553df86 --- /dev/null +++ b/magos-modificus/ui/Views/ConfirmDialog.axaml @@ -0,0 +1,29 @@ + + + + + + + - + + + + + + + + + + + + +