Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/knowledgebase/cli-selection-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
116 changes: 116 additions & 0 deletions scripts/agents/k2_ingested_roundtrip_smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 5 additions & 1 deletion src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConvertOptions, MergeOptions, ValidateOptions, InstallOptions, SetNexusApiKeyOptions, InstallPythonDepsOptions, HolopatcherOptions>(args)
.MapResult(
Expand Down
24 changes: 24 additions & 0 deletions src/ModSync.Tests/CliSelectionFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModComponent>
{
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()
{
Expand Down
135 changes: 112 additions & 23 deletions src/ModSync.Tests/GuideIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,39 +591,30 @@ 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<ModComponent> { silentSion },
"TOML"));
StripDependenciesForInstallSmoke(ingestedToml, "Silent Sion Restoration");

int installExit = ModBuildConverter.Run(new[]
{
"install",
"--input", ingestedToml,
"--game-dir", kotorDir,
"--source-dir", modDir,
"--use-file-selection",
"--select", "mod:Silent Sion Restoration",
"--skip-validation",
"--best-effort",
"-y",
});

Expand All @@ -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()
{
Expand Down Expand Up @@ -1131,5 +1179,46 @@ private static string ResolveRepoRoot()

throw new DirectoryNotFoundException("Could not locate repository root containing ModSync.sln");
}

/// <summary>
/// Install-smoke tests select individual mods via <c>mod:</c> filters; strip dependency edges
/// on those mods so the test exercises draft execution without staging the full build graph.
/// </summary>
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<ModComponent> 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);
}
}
}
}