From 7b27a582a11d02e1578ec758e27a3a47cfae0874 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 13 Jun 2026 07:46:06 -0500 Subject: [PATCH] feat: block validate/install until FOMOD archives are configured Add a configured-only gate across the shared validation pipeline, CLI/GUI install paths, and wizard install start so dismissed, warned, or skipped post-download states cannot proceed without completing the FOMOD wizard. --- ...fomod-cli-download-prompts-requirements.md | 16 +- docs/knowledgebase/fomod-support.md | 3 +- src/ModSync.Core/CLI/ModBuildConverter.cs | 22 +++ .../Services/Fomod/FomodConfigurationGate.cs | 159 +++++++++++++++ .../Services/InstallationService.cs | 25 +++ .../InstallationValidationPipeline.cs | 70 +++++++ .../Validation/ValidationPipelineOptions.cs | 3 + .../Validation/ValidationPipelineResult.cs | 1 + .../WizardPages/InstallStartPage.axaml.cs | 22 +++ .../ValidationPipelineDialogMapper.cs | 33 ++++ .../WizardValidationStagePresenter.cs | 27 +++ .../FomodConfigurationGateTests.cs | 182 ++++++++++++++++++ 12 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs create mode 100644 src/ModSync.Tests/FomodConfigurationGateTests.cs diff --git a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md index 941b8123..3a0d0925 100644 --- a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md +++ b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md @@ -31,7 +31,7 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but **Non-interactive behavior** -- R6: When stdin is not an interactive TTY (CI, agents, redirected I/O), default mode is **warn and continue**: emit a structured `WARN:` line per detected FOMOD archive and proceed without persisting dismiss state. +- R6: When stdin is not an interactive TTY (CI, agents, redirected I/O), default mode is **warn and continue**: emit a structured `WARN:` line per detected FOMOD archive, persist `MarkWarned` so the warning is not repeated, and proceed without configuring. **Warned does not satisfy the validation/install gate** (see R16–R21). - R7: Global `--fomod-skip` (and equivalent env/settings) persists `MarkDismissed` for archives handled under that skip policy in the session. - R8: Non-interactive mode is configurable globally via environment variable, CLI startup/global flags, and ModSync settings dialog; v1 ships the `warn-continue` mode but the resolution pipeline must accept future modes without redesign. @@ -54,6 +54,15 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but - R14: Applying wizard selections must not merge plugins from steps that are no longer visible after condition-flag changes (hidden-step parity with visible validation). - R15: Re-configuring the same archive updates existing option selections and replaces superseded FOMOD-generated instructions instead of silently skipping duplicate option GUIDs. +**FOMOD validation gate (configured-only)** + +- R16: On all validate and install entry points (GUI wizard Validate, Getting Started Validate, GUI install start, CLI `validate`, CLI `install`), scan **selected** components plus transitive **hard dependencies** for downloaded archives where `FomodArchiveProbe` detects FOMOD. +- R17: If a detected FOMOD archive's `fomodPromptStatus` is not `configured`, emit a **validation error** that blocks proceed — not a log-only warning. +- R18: `dismissed`, `warned`, and missing status all fail the gate; only `configured` passes. +- R19: Post-download dismiss (`MarkDismissed`), warn-continue (`MarkWarned`), and `--fomod-skip` may still affect download-time prompting but **do not** clear the gate. +- R20: Error text names the mod and archive and points users to Fetch Downloads / post-download FOMOD wizard (no in-validate configure button in this slice). +- R21: Gate runs inside `InstallationValidationPipeline` (shared CLI/GUI) and `InstallationService.InstallAllSelectedComponentsAsync` (install safety net); wizard `InstallStartPage` blocks Next when gate fails. + ## Success Criteria - Headless `install -d` against a fixture with a FOMOD archive prompts on TTY, applies choices, and proceeds to validation with merged options. @@ -62,6 +71,8 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but - `--fomod-choices` configures a FOMOD mod without TTY and marks configured state. - GUI **Fetch Downloads** behavior unchanged after GUI delegates to the Core orchestrator. - `docs/knowledgebase/agent-action-parity.md` documents post-download FOMOD configure for CLI. +- Validate/install fail with a clear error when a selected mod has a downloaded FOMOD archive that is not `configured`; passing after full FOMOD wizard merge. +- Dismissed or warned archives still block validate/install until configured. ## Scope Boundaries @@ -73,10 +84,10 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but **Deferred for later** -- Validation blocking when FOMOD choices are unset. - Plugin images in terminal wizard. - Download scope alignment so CLI only downloads `IsSelected` components (separate download-system change). - Additional non-TTY modes beyond `warn-continue` (fail-fast, auto-dismiss-all) once v1 config plumbing exists. +- In-validate **Configure FOMOD** action and `fomod configure` CLI verb (recovery hints in error text only for now). **Outside this product's identity** @@ -89,6 +100,7 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but - Config precedence: CLI flags > environment variables > `settings.json` > TTY-derived default. - Selected-only FOMOD scan even when download fetched more components. - Convert/merge output persistence uses existing `-o` / output path; no silent in-place overwrite of input without documented flag. +- Validation/install gate is **configured-only**; skip/dismiss paths are download-time only. ## Dependencies / Assumptions diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index 6fb42bb5..63f0ee0e 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -64,7 +64,8 @@ Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../pla - `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. - `FomodPostDownloadPromptService` runs after GUI **Fetch Downloads** completes; optional prompt per archive. -- `FomodDownloadPromptState` stores dismissed/configured outcomes in resource handler metadata. +- `FomodDownloadPromptState` stores dismissed/configured/warned outcomes in resource handler metadata. +- `FomodConfigurationGate` blocks validate and install unless every detected FOMOD archive on selected mods (plus hard dependencies) is `configured`; dismiss/skip/warned do not pass the gate. - `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. ## Verification diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index 7fe25c6d..cbbd64e4 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -2760,6 +2760,28 @@ private static async Task LogValidationPipelineOutputAsync( await Logger.LogAsync("Validation Summary:").ConfigureAwait(false); await Logger.LogAsync($" Total components validated: {componentCount}").ConfigureAwait(false); break; + case ValidationPipelineStage.FomodConfiguration: + await Logger.LogAsync("Checking FOMOD configuration...").ConfigureAwait(false); + await Logger.LogAsync(new string('-', 50)).ConfigureAwait(false); + if (stage.Passed) + { + await Logger.LogAsync("✓ All detected FOMOD archives are configured").ConfigureAwait(false); + } + else + { + await Logger.LogErrorAsync(stage.Summary ?? "Unconfigured FOMOD archives detected").ConfigureAwait(false); + foreach (string message in stage.Messages) + { + if (message.StartsWith("ERROR:", StringComparison.Ordinal)) + { + await Logger.LogErrorAsync(message.Substring(6).Trim()).ConfigureAwait(false); + } + } + } + + await Logger.LogAsync(new string('-', 50)).ConfigureAwait(false); + await Logger.LogAsync().ConfigureAwait(false); + break; case ValidationPipelineStage.DryRun: await Logger.LogAsync("\nRunning dry-run validation...").ConfigureAwait(false); if (pipelineResult.DryRunResult != null) diff --git a/src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs b/src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs new file mode 100644 index 00000000..030a2a9d --- /dev/null +++ b/src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs @@ -0,0 +1,159 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Fomod +{ + /// + /// Blocks validate/install when a downloaded FOMOD archive is not fully configured. + /// Only satisfies the gate. + /// + public static class FomodConfigurationGate + { + public const string IssueCategory = "FOMOD"; + + public const string RecoveryHint = + "Run Fetch Downloads and complete the FOMOD installer wizard for this archive, " + + "or use CLI post-download configure on an interactive terminal."; + + public sealed class GateIssue + { + [NotNull] + public ModComponent Component { get; set; } + + [NotNull] + public string ArchiveFileName { get; set; } + + [CanBeNull] + public string PromptStatus { get; set; } + } + + public sealed class GateResult + { + public bool Passed => Issues.Count == 0; + + [NotNull] + public List Issues { get; } = new List(); + } + + [NotNull] + public static GateResult Validate( + [NotNull][ItemNotNull] IReadOnlyList allComponents, + [NotNull][ItemNotNull] IEnumerable seedComponents, + [NotNull] string modDirectory) + { + if (allComponents is null) + { + throw new ArgumentNullException(nameof(allComponents)); + } + + if (seedComponents is null) + { + throw new ArgumentNullException(nameof(seedComponents)); + } + + if (string.IsNullOrWhiteSpace(modDirectory)) + { + throw new ArgumentException("Mod directory is required.", nameof(modDirectory)); + } + + var result = new GateResult(); + List scope = ExpandWithHardDependencies(seedComponents, allComponents); + + foreach (ModComponent component in scope) + { + foreach (string archivePath in FomodDownloadedArchivePaths.GetPaths(component, modDirectory)) + { + if (!FomodArchiveProbe.TryDetectInArchive(archivePath, out _)) + { + continue; + } + + string archiveFileName = System.IO.Path.GetFileName(archivePath); + string status = FomodDownloadPromptState.GetStatus(component, archiveFileName); + if (string.Equals(status, FomodDownloadPromptState.StatusConfigured, StringComparison.Ordinal)) + { + continue; + } + + result.Issues.Add(new GateIssue + { + Component = component, + ArchiveFileName = archiveFileName, + PromptStatus = status, + }); + } + } + + return result; + } + + [NotNull] + public static List ExpandWithHardDependencies( + [NotNull][ItemNotNull] IEnumerable seedComponents, + [NotNull][ItemNotNull] IReadOnlyList allComponents) + { + var byGuid = allComponents.ToDictionary(c => c.Guid); + var visited = new HashSet(); + var ordered = new List(); + var queue = new Queue(); + + foreach (ModComponent seed in seedComponents) + { + if (seed is null) + { + continue; + } + + queue.Enqueue(seed); + } + + while (queue.Count > 0) + { + ModComponent component = queue.Dequeue(); + if (!visited.Add(component.Guid)) + { + continue; + } + + ordered.Add(component); + + if (component.Dependencies is null) + { + continue; + } + + foreach (Guid dependencyGuid in component.Dependencies) + { + if (byGuid.TryGetValue(dependencyGuid, out ModComponent dependency)) + { + queue.Enqueue(dependency); + } + } + } + + return ordered; + } + + [NotNull] + public static string FormatIssueMessage([NotNull] GateIssue issue) + { + if (issue is null) + { + throw new ArgumentNullException(nameof(issue)); + } + + string statusLabel = string.IsNullOrEmpty(issue.PromptStatus) + ? "not configured" + : $"status '{issue.PromptStatus}'"; + + return $"FOMOD archive '{issue.ArchiveFileName}' is {statusLabel}. {RecoveryHint}"; + } + } +} diff --git a/src/ModSync.Core/Services/InstallationService.cs b/src/ModSync.Core/Services/InstallationService.cs index d7b97855..22b1ee78 100644 --- a/src/ModSync.Core/Services/InstallationService.cs +++ b/src/ModSync.Core/Services/InstallationService.cs @@ -16,6 +16,7 @@ using ModSync.Core.FileSystemUtils; using ModSync.Core.Installation; using ModSync.Core.Services.Checkpoints; +using ModSync.Core.Services.Fomod; using ModSync.Core.Utility; using Python.Included; @@ -955,6 +956,30 @@ private static string FormatHolopatcherError(Exception ex, string errorType) throw new ArgumentNullException(nameof(allComponents)); } + List selectedComponents = allComponents.Where(component => component.IsSelected).ToList(); + string modDirectory = MainConfig.Instance?.sourcePath?.FullName; + if (!string.IsNullOrWhiteSpace(modDirectory) && System.IO.Directory.Exists(modDirectory)) + { + FomodConfigurationGate.GateResult fomodGate = FomodConfigurationGate.Validate( + allComponents, + selectedComponents, + modDirectory); + if (!fomodGate.Passed) + { + await Logger.LogErrorAsync( + "Installation blocked: one or more FOMOD archives are not configured." + ).ConfigureAwait(false); + foreach (FomodConfigurationGate.GateIssue issue in fomodGate.Issues) + { + await Logger.LogErrorAsync( + $"[{issue.Component.Name}] {FomodConfigurationGate.FormatIssueMessage(issue)}" + ).ConfigureAwait(false); + } + + return ModComponent.InstallExitCode.InvalidOperation; + } + } + var coordinator = new InstallCoordinator(); try { diff --git a/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs b/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs index b4b5de35..5e18dac6 100644 --- a/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs +++ b/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs @@ -11,6 +11,7 @@ using JetBrains.Annotations; using ModSync.Core.Services.FileSystem; +using ModSync.Core.Services.Fomod; namespace ModSync.Core.Services.Validation { @@ -149,6 +150,28 @@ public static async Task RunAsync( } } + if (!options.SkipFomodConfigurationGate) + { + cancellationToken.ThrowIfCancellationRequested(); + step++; + progress?.Invoke(ValidationPipelineStage.FomodConfiguration, step, totalSteps, "Checking FOMOD configuration..."); + ValidationPipelineStageResult fomodStage = RunFomodConfigurationStage( + componentsToValidate, + allComponents, + options); + result.Stages.Add(fomodStage); + if (!fomodStage.Passed) + { + result.HasCriticalErrors = true; + result.ErrorCount += fomodStage.Messages.Count; + result.IsSuccess = false; + } + else + { + result.PassedCount++; + } + } + bool runDryRun = options.DryRun || options.DryRunOnly; if (runDryRun) { @@ -224,6 +247,11 @@ private static int CountStages(ValidationPipelineOptions options) count++; } + if (!options.SkipFomodConfigurationGate) + { + count++; + } + if (options.DryRun || options.DryRunOnly) { count++; @@ -391,6 +419,48 @@ private static (ValidationPipelineStageResult stage, int errors, int warnings) R return (stage, errors, warnings); } + [NotNull] + private static ValidationPipelineStageResult RunFomodConfigurationStage( + [NotNull][ItemNotNull] List componentsToValidate, + [NotNull][ItemNotNull] IReadOnlyList allComponents, + [NotNull] ValidationPipelineOptions options) + { + var stage = new ValidationPipelineStageResult + { + Stage = ValidationPipelineStage.FomodConfiguration, + Passed = true, + }; + + MainConfig config = options.MainConfig ?? MainConfig.Instance; + string modDirectory = config?.sourcePath?.FullName; + if (string.IsNullOrWhiteSpace(modDirectory) || !System.IO.Directory.Exists(modDirectory)) + { + stage.Summary = "Skipped FOMOD configuration check (mod directory not set)."; + return stage; + } + + FomodConfigurationGate.GateResult gateResult = FomodConfigurationGate.Validate( + allComponents, + componentsToValidate, + modDirectory); + + if (gateResult.Passed) + { + stage.Summary = "All detected FOMOD archives are configured."; + return stage; + } + + stage.Passed = false; + stage.Summary = $"{gateResult.Issues.Count} unconfigured FOMOD archive(s)."; + foreach (FomodConfigurationGate.GateIssue issue in gateResult.Issues) + { + stage.Messages.Add( + $"ERROR: {issue.Component.Name}: {FomodConfigurationGate.FormatIssueMessage(issue)}"); + } + + return stage; + } + [NotNull] private static async Task<(ValidationPipelineStageResult stage, DryRunValidationResult dryRunResult)> RunDryRunStageAsync( [NotNull][ItemNotNull] IReadOnlyList allComponents, diff --git a/src/ModSync.Core/Services/Validation/ValidationPipelineOptions.cs b/src/ModSync.Core/Services/Validation/ValidationPipelineOptions.cs index 65a3de8d..abe17f6c 100644 --- a/src/ModSync.Core/Services/Validation/ValidationPipelineOptions.cs +++ b/src/ModSync.Core/Services/Validation/ValidationPipelineOptions.cs @@ -38,6 +38,9 @@ public sealed class ValidationPipelineOptions /// Skip per-component archive validation (tests that only need graph checks). public bool SkipComponentArchiveValidation { get; set; } + /// Skip FOMOD configured-only gate (tests without FOMOD fixtures). + public bool SkipFomodConfigurationGate { get; set; } + [CanBeNull] public MainConfig MainConfig { get; set; } diff --git a/src/ModSync.Core/Services/Validation/ValidationPipelineResult.cs b/src/ModSync.Core/Services/Validation/ValidationPipelineResult.cs index dcb2ce18..e85bb21c 100644 --- a/src/ModSync.Core/Services/Validation/ValidationPipelineResult.cs +++ b/src/ModSync.Core/Services/Validation/ValidationPipelineResult.cs @@ -16,6 +16,7 @@ public enum ValidationPipelineStage Conflicts, InstallOrder, ComponentValidation, + FomodConfiguration, DryRun, } diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs index 6261d0f3..4bbef0cd 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs @@ -12,6 +12,7 @@ using Avalonia.Media; using JetBrains.Annotations; using ModSync.Core; +using ModSync.Core.Services.Fomod; namespace ModSync.Dialogs.WizardPages { @@ -52,6 +53,27 @@ public override Task OnNavigatedToAsync(CancellationToken cancellationToken) return Task.FromResult((false, "No mods are selected. Go back to Mod Selection and choose mods to install.")); } + string modDirectory = MainConfig.Instance?.sourcePath?.FullName; + if (!string.IsNullOrWhiteSpace(modDirectory) && System.IO.Directory.Exists(modDirectory)) + { + var selected = _allComponents.Where(c => c.IsSelected && !c.WidescreenOnly).ToList(); + FomodConfigurationGate.GateResult gateResult = FomodConfigurationGate.Validate( + _allComponents, + selected, + modDirectory); + if (!gateResult.Passed) + { + FomodConfigurationGate.GateIssue first = gateResult.Issues[0]; + string message = $"{first.Component.Name}: {FomodConfigurationGate.FormatIssueMessage(first)}"; + if (gateResult.Issues.Count > 1) + { + message += $" (+{gateResult.Issues.Count - 1} more unconfigured FOMOD archive(s))"; + } + + return Task.FromResult((false, message)); + } + } + return Task.FromResult((true, (string)null)); } diff --git a/src/ModSync.GUI/Services/ValidationPipelineDialogMapper.cs b/src/ModSync.GUI/Services/ValidationPipelineDialogMapper.cs index b4c007db..5f2e628e 100644 --- a/src/ModSync.GUI/Services/ValidationPipelineDialogMapper.cs +++ b/src/ModSync.GUI/Services/ValidationPipelineDialogMapper.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using ModSync.Core.Services.FileSystem; +using ModSync.Core.Services.Fomod; using ModSync.Core.Services.Validation; using ModSync.Dialogs; @@ -240,6 +241,38 @@ public static void AddPipelineStageIssues( appendLog?.Invoke($"⚠ [ArchiveValidation] {summary}"); } + break; + case ValidationPipelineStage.FomodConfiguration: + foreach (string message in stage.Messages) + { + if (TryParsePrefixedStageMessage(message, "ERROR:", out string modName, out string description, out string detail)) + { + modIssues.Add(new Dialogs.ValidationIssue + { + Icon = "✗", + ModName = modName, + IssueType = FomodConfigurationGate.IssueCategory, + Description = description, + Solution = FomodConfigurationGate.RecoveryHint, + }); + appendLog?.Invoke($"✗ [{FomodConfigurationGate.IssueCategory}] {detail}"); + } + } + + if (!stage.Passed && stage.Messages.Count == 0) + { + string summary = stage.Summary ?? "Unconfigured FOMOD archives detected"; + modIssues.Add(new Dialogs.ValidationIssue + { + Icon = "✗", + ModName = "FOMOD", + IssueType = FomodConfigurationGate.IssueCategory, + Description = summary, + Solution = FomodConfigurationGate.RecoveryHint, + }); + appendLog?.Invoke($"✗ [{FomodConfigurationGate.IssueCategory}] {summary}"); + } + break; } } diff --git a/src/ModSync.GUI/Services/WizardValidationStagePresenter.cs b/src/ModSync.GUI/Services/WizardValidationStagePresenter.cs index b62d0d39..ffa32556 100644 --- a/src/ModSync.GUI/Services/WizardValidationStagePresenter.cs +++ b/src/ModSync.GUI/Services/WizardValidationStagePresenter.cs @@ -63,6 +63,9 @@ public static void ApplyStages( case ValidationPipelineStage.ComponentValidation: ApplyComponentValidationStage(stage, stepIndex, appendLog, addResult); break; + case ValidationPipelineStage.FomodConfiguration: + ApplyFomodConfigurationStage(stage, stepIndex, appendLog, addResult); + break; case ValidationPipelineStage.DryRun: ApplyDryRunStage(pipelineResult, stepIndex, appendLog, addResult); break; @@ -212,6 +215,30 @@ private static void ApplyComponentValidationStage( } } + private static void ApplyFomodConfigurationStage( + ValidationPipelineStageResult stage, + int stepIndex, + AppendLogDelegate appendLog, + AddResultDelegate addResult) + { + appendLog($"Step {stepIndex}: Checking FOMOD configuration"); + foreach (string message in stage.Messages) + { + appendLog($" {message}"); + } + + ApplyPrefixedStageMessageCards(stage.Messages, addResult); + + if (!stage.Passed) + { + addResult("❌ FOMOD Configuration", stage.Summary ?? "Unconfigured FOMOD archives detected"); + } + else + { + addResult("✅ FOMOD Configuration", stage.Summary ?? "All detected FOMOD archives are configured."); + } + } + private static void ApplyDryRunStage( ValidationPipelineResult pipelineResult, int stepIndex, diff --git a/src/ModSync.Tests/FomodConfigurationGateTests.cs b/src/ModSync.Tests/FomodConfigurationGateTests.cs new file mode 100644 index 00000000..9fa9662e --- /dev/null +++ b/src/ModSync.Tests/FomodConfigurationGateTests.cs @@ -0,0 +1,182 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; + +using ModSync.Core; +using ModSync.Core.Services.Fomod; +using ModSync.Core.Services.Validation; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class FomodConfigurationGateTests + { + private string _modDir; + + [SetUp] + public void SetUp() + { + _modDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_modDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_modDir)) + { + Directory.Delete(_modDir, recursive: true); + } + } + + [Test] + public void Validate_UnconfiguredFomodArchive_Fails() + { + string archiveName = "needs-config.zip"; + CreateZipWithFomod(Path.Combine(_modDir, archiveName)); + ModComponent component = BuildComponentWithArchive(archiveName); + + FomodConfigurationGate.GateResult result = FomodConfigurationGate.Validate( + new[] { component }, + new[] { component }, + _modDir); + + Assert.That(result.Passed, Is.False); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + Assert.That(result.Issues[0].ArchiveFileName, Is.EqualTo(archiveName)); + } + + [Test] + public void Validate_ConfiguredArchive_Passes() + { + string archiveName = "configured.zip"; + CreateZipWithFomod(Path.Combine(_modDir, archiveName)); + ModComponent component = BuildComponentWithArchive(archiveName); + FomodDownloadPromptState.MarkConfigured(component, archiveName); + + FomodConfigurationGate.GateResult result = FomodConfigurationGate.Validate( + new[] { component }, + new[] { component }, + _modDir); + + Assert.That(result.Passed, Is.True); + } + + [Test] + public void Validate_DismissedArchive_StillFails() + { + string archiveName = "dismissed.zip"; + CreateZipWithFomod(Path.Combine(_modDir, archiveName)); + ModComponent component = BuildComponentWithArchive(archiveName); + FomodDownloadPromptState.MarkDismissed(component, archiveName); + + FomodConfigurationGate.GateResult result = FomodConfigurationGate.Validate( + new[] { component }, + new[] { component }, + _modDir); + + Assert.That(result.Passed, Is.False); + Assert.That(result.Issues[0].PromptStatus, Is.EqualTo(FomodDownloadPromptState.StatusDismissed)); + } + + [Test] + public void Validate_WarnedArchive_StillFails() + { + string archiveName = "warned.zip"; + CreateZipWithFomod(Path.Combine(_modDir, archiveName)); + ModComponent component = BuildComponentWithArchive(archiveName); + FomodDownloadPromptState.MarkWarned(component, archiveName); + + FomodConfigurationGate.GateResult result = FomodConfigurationGate.Validate( + new[] { component }, + new[] { component }, + _modDir); + + Assert.That(result.Passed, Is.False); + Assert.That(result.Issues[0].PromptStatus, Is.EqualTo(FomodDownloadPromptState.StatusWarned)); + } + + [Test] + public void ExpandWithHardDependencies_IncludesDependencyChain() + { + var dependency = new ModComponent { Guid = Guid.NewGuid(), Name = "Dep" }; + var selected = new ModComponent + { + Guid = Guid.NewGuid(), + Name = "Selected", + IsSelected = true, + Dependencies = new List { dependency.Guid }, + }; + + List expanded = FomodConfigurationGate.ExpandWithHardDependencies( + new[] { selected }, + new[] { dependency, selected }); + + Assert.That(expanded, Has.Count.EqualTo(2)); + Assert.That(expanded, Does.Contain(dependency)); + Assert.That(expanded, Does.Contain(selected)); + } + + [Test] + public async Task Pipeline_FomodGate_BlocksWhenArchiveUnconfigured() + { + string archiveName = "pipeline-gate.zip"; + CreateZipWithFomod(Path.Combine(_modDir, archiveName)); + ModComponent component = BuildComponentWithArchive(archiveName); + + MainConfig.Instance = new MainConfig + { + sourcePath = new DirectoryInfo(_modDir), + destinationPath = new DirectoryInfo(_modDir), + }; + + var options = ValidationPipelineOptions.WizardFull; + options.SkipEnvironmentValidation = true; + options.SkipComponentArchiveValidation = true; + options.DryRun = false; + + ValidationPipelineResult result = await InstallationValidationPipeline.RunAsync( + new[] { component }, + options).ConfigureAwait(false); + + Assert.That(result.IsSuccess, Is.False); + Assert.That( + result.Stages.Exists(s => s.Stage == ValidationPipelineStage.FomodConfiguration && !s.Passed), + Is.True); + } + + private static ModComponent BuildComponentWithArchive(string archiveFileName) + { + var component = new ModComponent { Name = "Test Mod", IsSelected = true }; + component.ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary { [archiveFileName] = true }, + HandlerMetadata = new Dictionary(), + }, + }; + return component; + } + + private static void CreateZipWithFomod(string archivePath) + { + string staging = Path.Combine(Path.GetDirectoryName(archivePath), "staging"); + Directory.CreateDirectory(Path.Combine(staging, "fomod")); + File.WriteAllText( + Path.Combine(staging, "fomod", "ModuleConfig.xml"), + ""); + + ZipFile.CreateFromDirectory(staging, archivePath); + Directory.Delete(staging, recursive: true); + } + } +}