From aebffc16308c2eeb62fa4935169c3f6ecd60e585 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 08:09:30 -0500 Subject: [PATCH] test(cli): add K2 ingested install smoke and fix repeated --select Enable CommandLineParser AllowMultiInstance so install/validate accept multiple --select flags. Add multi-mod install regression, dependency-strip helper for smoke tests, and k2_ingested_roundtrip_smoke.sh agent script. --- docs/knowledgebase/cli-selection-semantics.md | 2 +- scripts/agents/k2_ingested_roundtrip_smoke.sh | 116 +++++++++++++++ src/ModSync.Core/CLI/ModBuildConverter.cs | 6 +- src/ModSync.Tests/CliSelectionFilterTests.cs | 24 ++++ src/ModSync.Tests/GuideIngestionTests.cs | 135 +++++++++++++++--- 5 files changed, 258 insertions(+), 25 deletions(-) create mode 100755 scripts/agents/k2_ingested_roundtrip_smoke.sh diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index ba27f4e0..07ec87d1 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -16,7 +16,7 @@ Only components already marked `IsSelected = true` in the file are installed. Us ## `install` with `--select` -Repeatable filters: `category:Name`, `tier:Name`, `mod:Name` (case-insensitive exact or substring match on component `Name`). Only matching components stay selected. Filters combine with AND semantics when multiple types are used together. +Repeatable filters: `category:Name`, `tier:Name`, `mod:Name` (case-insensitive exact or substring match on component `Name`). Only matching components stay selected. Filters combine with AND semantics when multiple types are used together. Repeat `--select` for multiple mod names (e.g. `--select mod:A --select mod:B`). ## `validate` without `--select` (default) diff --git a/scripts/agents/k2_ingested_roundtrip_smoke.sh b/scripts/agents/k2_ingested_roundtrip_smoke.sh new file mode 100755 index 00000000..98ee0de2 --- /dev/null +++ b/scripts/agents/k2_ingested_roundtrip_smoke.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Smoke-test neocities K2 Full guide round-trip: convert → validate dry-run → optional install. +# +# The site-scraped fixture has no download URLs; this script validates structure and can +# install mods when you pre-stage archives under the mod workspace (see --install-mod). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +FIXTURE="${FIXTURE:-$ROOT/src/ModSync.Tests/Fixtures/k2_full_guide.md}" +OUT_DIR="${OUT_DIR:-$ROOT/tmp/k2_ingested_smoke}" +KOTOR_DIR="${KOTOR_DIR:-$OUT_DIR/kotor}" +MOD_DIR="${MOD_DIR:-$OUT_DIR/mods}" +TOML="${TOML:-$OUT_DIR/k2_full_ingested.toml}" +INSTALL_MOD="${INSTALL_MOD:-}" +SKIP_INSTALL=false + +usage() { + cat <<'EOF' +Usage: k2_ingested_roundtrip_smoke.sh [options] + +Runs convert (--parse-directions) on the neocities K2 Full fixture, validate --dry-run-only, +and optionally install one mod when files are staged in the mod workspace. + +Options: + --fixture PATH Markdown input (default: site-scraped k2_full_guide.md fixture) + --out-dir PATH Working directory (default: ./tmp/k2_ingested_smoke) + --install-mod NAME After validate, run install --select mod:NAME (requires staged files) + --skip-install Do not run install even if INSTALL_MOD is set + -h, --help Show this help + +Environment: + FIXTURE, OUT_DIR, KOTOR_DIR, MOD_DIR, TOML, INSTALL_MOD + +Example (Silent Sion loose-file smoke): + mkdir -p tmp/k2_ingested_smoke/mods + printf 'dlg\n' > tmp/k2_ingested_smoke/mods/153sion.dlg + ./scripts/agents/k2_ingested_roundtrip_smoke.sh --install-mod "Silent Sion Restoration" +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --fixture) FIXTURE="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; KOTOR_DIR="$OUT_DIR/kotor"; MOD_DIR="$OUT_DIR/mods"; TOML="$OUT_DIR/k2_full_ingested.toml"; shift 2 ;; + --install-mod) INSTALL_MOD="$2"; shift 2 ;; + --skip-install) SKIP_INSTALL=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ ! -f "$FIXTURE" ]]; then + echo "Fixture not found: $FIXTURE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" "$MOD_DIR" +"$ROOT/scripts/agents/create_template_kotor_install.sh" "$KOTOR_DIR" "$MOD_DIR" >/dev/null + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +ensure_core_resources_symlink "$ROOT" +if [[ -x "$ROOT/scripts/agents/ensure_linux_holopatcher.sh" ]]; then + "$ROOT/scripts/agents/ensure_linux_holopatcher.sh" || true +fi + +strip_dependencies_for_mods() { + local toml_path="$1" + shift + python3 - "$toml_path" "$@" <<'PY' +import re, sys +path = sys.argv[1] +names = sys.argv[2:] +text = open(path, encoding="utf-8").read() +for name in names: + pattern = rf'(\[\[thisMod\]\]\nGuid = "[^"]+"\nName = "{re.escape(name)}"[\s\S]*?)(?=\n\[\[thisMod\]\]|\Z)' + match = re.search(pattern, text) + if not match: + print(f"warning: mod block not found for {name!r}", file=sys.stderr) + continue + block = match.group(1) + block = re.sub(r'\nDependencies = \[.*?\]\n', '\n', block) + block = re.sub(r'\nRestrictions = \[.*?\]\n', '\n', block) + text = text.replace(match.group(1), block) +open(path, "w", encoding="utf-8").write(text) +PY +} + +dotnet build "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -c Debug -f net9.0 -v q + +echo "==> convert --parse-directions" +dotnet run --project "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -f net9.0 --no-build -- \ + convert --input "$FIXTURE" -f toml --parse-directions -o "$TOML" --plaintext + +echo "==> validate --dry-run-only" +dotnet run --project "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -f net9.0 --no-build -- \ + validate --input "$TOML" --game-dir "$KOTOR_DIR" --source-dir "$MOD_DIR" \ + --dry-run-only --errors-only + +if [[ "$SKIP_INSTALL" == true || -z "$INSTALL_MOD" ]]; then + echo "Round-trip smoke complete (convert + validate). Skipping install." + echo " TOML: $TOML" + exit 0 +fi + +echo "==> install --select mod:$INSTALL_MOD" +strip_dependencies_for_mods "$TOML" "$INSTALL_MOD" +dotnet run --project "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -f net9.0 --no-build -- \ + install --input "$TOML" --game-dir "$KOTOR_DIR" --source-dir "$MOD_DIR" \ + --select "mod:$INSTALL_MOD" --skip-validation --best-effort -y + +echo "Install complete for mod:$INSTALL_MOD" +echo " KOTOR Override: $KOTOR_DIR/Override" +echo " TOML: $TOML" diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index f02905ae..1e3939c0 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -698,7 +698,11 @@ public static int Run(string[] args) Logger.Initialize(); - var parser = new Parser(with => with.HelpWriter = Console.Out); + var parser = new Parser(with => + { + with.HelpWriter = Console.Out; + with.AllowMultiInstance = true; + }); return parser.ParseArguments(args) .MapResult( diff --git a/src/ModSync.Tests/CliSelectionFilterTests.cs b/src/ModSync.Tests/CliSelectionFilterTests.cs index e88126fa..a74e6b6c 100644 --- a/src/ModSync.Tests/CliSelectionFilterTests.cs +++ b/src/ModSync.Tests/CliSelectionFilterTests.cs @@ -48,6 +48,30 @@ public void ApplySelectionFilters_ModNamePartialMatch_SelectsSubstringHit() Assert.That(components.Count(c => c.IsSelected), Is.EqualTo(2)); } + [Test] + public void ApplySelectionFilters_TwoModNameFilters_SelectsBothComponents() + { + var components = new List + { + CreateComponent("Silent Sion Restoration", "Immersion", "2 - Recommended"), + CreateComponent("Prestige Class Saving Throw Fixes", "Mechanics Change", "2 - Recommended"), + CreateComponent("K2 Community Patch", "Bugfix", "1 - Essential"), + }; + + ModBuildConverter.ApplySelectionFilters(components, new[] + { + "mod:Silent Sion Restoration", + "mod:Prestige Class Saving Throw Fixes", + }); + + Assert.Multiple(() => + { + Assert.That(components.Count(c => c.IsSelected), Is.EqualTo(2)); + Assert.That(components.Where(c => c.IsSelected).Select(c => c.Name), + Is.EquivalentTo(new[] { "Silent Sion Restoration", "Prestige Class Saving Throw Fixes" })); + }); + } + [Test] public void ApplySelectionFilters_CategoryStillWorksAlongsideModName() { diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index 1bd89b8c..d0405acd 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -591,30 +591,20 @@ public void K2FullGuideFixture_IngestedSilentSion_InstallsViaCli() Directory.CreateDirectory(modDir); File.WriteAllText(Path.Combine(modDir, "153sion.dlg"), "silent sion dlg"); - GuideIngestResult ingested = GuideIngestService.Instance.IngestFromText( - File.ReadAllText(fixturePath), - formatHint: "markdown", - parseDirections: true); - - ModComponent silentSion = ingested.Components.FirstOrDefault(c => - c.Name.IndexOf("Silent Sion", StringComparison.OrdinalIgnoreCase) >= 0); - Assert.That(silentSion, Is.Not.Null, "Fixture should contain Silent Sion Restoration"); - Assert.That(silentSion.Instructions, Is.Not.Empty, "Silent Sion should have drafted Move instructions"); - Assert.That( - silentSion.Instructions.Any(i => i.Action == Instruction.ActionType.Move), - Is.True, - "Silent Sion draft should include a Move instruction"); + string ingestedToml = Path.Combine(_testDirectory, "k2_full_ingested.toml"); + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); - // Install a single ingested component (dependency auto-select would pull the full build otherwise). - silentSion.IsSelected = true; - silentSion.Dependencies.Clear(); + Assert.That(convertExit, Is.EqualTo(0), "convert --parse-directions should succeed for K2 Full fixture"); - string ingestedToml = Path.Combine(_testDirectory, "k2_silent_sion.toml"); - File.WriteAllText( - ingestedToml, - ModComponentSerializationService.SerializeModComponentAsString( - new List { silentSion }, - "TOML")); + StripDependenciesForInstallSmoke(ingestedToml, "Silent Sion Restoration"); int installExit = ModBuildConverter.Run(new[] { @@ -622,8 +612,9 @@ public void K2FullGuideFixture_IngestedSilentSion_InstallsViaCli() "--input", ingestedToml, "--game-dir", kotorDir, "--source-dir", modDir, - "--use-file-selection", + "--select", "mod:Silent Sion Restoration", "--skip-validation", + "--best-effort", "-y", }); @@ -637,6 +628,63 @@ public void K2FullGuideFixture_IngestedSilentSion_InstallsViaCli() }); } + [Test] + public void K2FullGuideFixture_IngestedMultiModInstallSmoke_InstallsViaModSelect() + { + string fixturePath = Path.Combine(ResolveRepoRoot(), "src", "ModSync.Tests", "Fixtures", "k2_full_guide.md"); + Assert.That(File.Exists(fixturePath), Is.True); + + string kotorDir = Path.Combine(_testDirectory, "KOTOR"); + string modDir = Path.Combine(_testDirectory, "Mods"); + Directory.CreateDirectory(Path.Combine(kotorDir, "Override")); + File.WriteAllText(Path.Combine(kotorDir, "swkotor.exe"), "fake exe"); + File.WriteAllText(Path.Combine(kotorDir, "dialog.tlk"), "fake dialog"); + Directory.CreateDirectory(modDir); + File.WriteAllText(Path.Combine(modDir, "153sion.dlg"), "silent sion dlg"); + + string prestigeFolder = Path.Combine(modDir, "Jedi Master", "Sith Lord fixes"); + Directory.CreateDirectory(prestigeFolder); + File.WriteAllText(Path.Combine(prestigeFolder, "prestige_fix.2da"), "2DA V2.0\n"); + + string ingestedToml = Path.Combine(_testDirectory, "k2_full_ingested.toml"); + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); + + Assert.That(convertExit, Is.EqualTo(0)); + + StripDependenciesForInstallSmoke( + ingestedToml, + "Silent Sion Restoration", + "Prestige Class Saving Throw Fixes"); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", ingestedToml, + "--game-dir", kotorDir, + "--source-dir", modDir, + "--select", "mod:Silent Sion Restoration", + "--select", "mod:Prestige Class Saving Throw Fixes", + "--skip-validation", + "--best-effort", + "-y", + }); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0)); + Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "153sion.dlg")), Is.True); + Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "prestige_fix.2da")), Is.True); + }); + } + [Test] public void DraftInstructions_EmptyDirections_ProducesNoDrafts() { @@ -1131,5 +1179,46 @@ private static string ResolveRepoRoot() throw new DirectoryNotFoundException("Could not locate repository root containing ModSync.sln"); } + + /// + /// Install-smoke tests select individual mods via mod: filters; strip dependency edges + /// on those mods so the test exercises draft execution without staging the full build graph. + /// + private static void StripDependenciesForInstallSmoke(string tomlPath, params string[] modNameFragments) + { + if (modNameFragments is null || modNameFragments.Length == 0) + { + throw new ArgumentException("At least one mod name fragment is required.", nameof(modNameFragments)); + } + + List components = FileLoadingService.LoadFromFile(tomlPath).ToList(); + bool changed = false; + + foreach (ModComponent component in components) + { + if (!modNameFragments.Any(fragment => + component.Name.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0)) + { + continue; + } + + if (component.Dependencies.Count > 0) + { + component.Dependencies.Clear(); + changed = true; + } + + if (component.Restrictions.Count > 0) + { + component.Restrictions.Clear(); + changed = true; + } + } + + if (changed) + { + FileLoadingService.SaveToFile(components, tomlPath); + } + } } }