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);
+ }
+ }
+}