diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/RulesLocalizationTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/RulesLocalizationTests.cs
new file mode 100644
index 00000000..66af3d8e
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/RulesLocalizationTests.cs
@@ -0,0 +1,115 @@
+using System;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.Localization
+{
+ ///
+ /// Regression tests for the Rules localization mapping — issue #204 (coverage) and the
+ /// class of bug documented as #216 (FrontFieldConversions referencing field names that do
+ /// not exist in the template, silently leaving FR content in non-FR PDFs).
+ ///
+ /// NEW file (dispatch #204 amend): the Rules front substitution is tested here, separately
+ /// from FallaciesLocalizationTests (which is owned by PR #444). No existing file is
+ /// modified.
+ ///
+ /// The Rules template binds body text through {{markdown Text}}. The Rules
+ /// FrontFieldConversions swap Text -> Text_en/_ru/_pt/_es/_ar/_fa/_zh. This
+ /// test applies the real substitution chain against the template on disk for every target
+ /// language and asserts the FR binding is gone and the localized binding is present.
+ ///
+ public class RulesLocalizationTests
+ {
+ private const string RulesTemplateRelPath = "Cards/Rules/Argumentum_Rules_fr.json";
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Cards", "Fallacies")))
+ {
+ dir = dir.Parent;
+ }
+ return dir?.FullName ?? throw new DirectoryNotFoundException("Could not locate repository root (Cards/Fallacies not found).");
+ }
+
+ private static CardSetLocalization GetRulesLocalization()
+ {
+ var config = new AssetConverterConfig();
+ var loc = config.LocalizationConfig.CardSetLocalizations
+ .FirstOrDefault(l => l.CardSetNames.Contains(KnownCardSets.Rules));
+ loc.Should().NotBeNull("the default LocalizationConfig must carry a Rules mapping");
+ return loc!;
+ }
+
+ // Mirrors the Front branch of CardSetLocalization.TranslateCardSetInfo (front:true).
+ private static string ApplyFrontSubstitution(CardSetLocalization loc, string template, string destLang)
+ {
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ var conv = fieldConversion.fieldConversions.FirstOrDefault(c => c.Language == destLang);
+ if (string.IsNullOrEmpty(conv.destFieldName)) continue;
+ var destPattern = loc.FormatField(conv.destFieldName);
+ template = template.Replace(sourcePattern, destPattern);
+ }
+ return template;
+ }
+
+ [Theory]
+ [InlineData("en", "Text_en")]
+ [InlineData("ru", "Text_ru")]
+ [InlineData("pt", "Text_pt")]
+ [InlineData("es", "Text_es")]
+ [InlineData("ar", "Text_ar")]
+ [InlineData("fa", "Text_fa")]
+ [InlineData("zh", "Text_zh")]
+ public void Rules_Template_Binds_Localized_Text_Column_After_Substitution(string destLang, string expectedColumn)
+ {
+ var path = Path.Combine(FindRepoRoot(), RulesTemplateRelPath);
+ File.Exists(path).Should().BeTrue($"Rules template must exist at {RulesTemplateRelPath}");
+ var original = File.ReadAllText(path);
+ original.Should().Contain("{{markdown Text}}",
+ "the Rules template must bind body text through {{markdown Text}} (Golden Master contract)");
+
+ var loc = GetRulesLocalization();
+ var translated = ApplyFrontSubstitution(loc, original, destLang);
+
+ // The localized binding must be present.
+ translated.Should().Contain($"{{markdown {expectedColumn}}}",
+ $"{destLang} Rules template must bind body text to CSV column '{expectedColumn}'");
+ }
+
+ [Fact]
+ public void Rules_Front_Conversions_Reference_Only_Template_Existing_Tokens()
+ {
+ // Root-cause guard for the #216 class of bug: every sourceFieldName in the Rules
+ // FrontFieldConversions must correspond to a token that actually appears in the
+ // template. If a conversion references a non-existent field, template.Replace() is a
+ // silent no-op and the FR content ships unchanged.
+ var path = Path.Combine(FindRepoRoot(), RulesTemplateRelPath);
+ var template = File.ReadAllText(path);
+ var loc = GetRulesLocalization();
+
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ template.Should().Contain(sourcePattern,
+ $"Rules FrontFieldConversions source '{fieldConversion.sourceFieldName}' must exist in the template " +
+ $"(otherwise the conversion is a silent no-op — #216 root cause). Pattern looked for: '{sourcePattern}'.");
+ }
+ }
+
+ [Fact]
+ public void Rules_Front_Conversions_Cover_All_Eight_Languages()
+ {
+ var loc = GetRulesLocalization();
+ var textConv = loc.FrontFieldConversions.FirstOrDefault(c => c.sourceFieldName == "Text");
+ textConv.fieldConversions.Should().NotBeNull("Rules must map the Text field");
+ var languages = textConv.fieldConversions.Select(c => c.Language).ToList();
+ languages.Should().Contain(new[] { "en", "ru", "pt", "es", "ar", "fa", "zh" },
+ "Rules must localize Text into all 7 non-FR release languages");
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/ScenariiLocalizationTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/ScenariiLocalizationTests.cs
new file mode 100644
index 00000000..16c50086
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/ScenariiLocalizationTests.cs
@@ -0,0 +1,124 @@
+using System;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.Localization
+{
+ ///
+ /// Regression tests for the Scenarii localization mapping — issue #204 (coverage) and the
+ /// #216 class of bug (FrontFieldConversions referencing field names absent from the template).
+ ///
+ /// NEW file (dispatch #204 amend): the Scenarii front substitution is tested here, separately
+ /// from FallaciesLocalizationTests (owned by PR #444). No existing file is modified.
+ ///
+ /// The Scenarii template binds through FR tokens {{titre}}, {{catégorie}},
+ /// {{contexte}}, {{enjeu}}, {{baratineur}}, {{piocheur}}. The
+ /// Scenarii FrontFieldConversions swap each to the localized CSV column. These tests apply
+ /// the real substitution chain against the template on disk and assert each FR binding is
+ /// replaced by its localized counterpart for every release language.
+ ///
+ public class ScenariiLocalizationTests
+ {
+ private const string ScenariiFaceTemplateRelPath = "Cards/Scenarii/Argumentum_Scenarii_Face_fr.json";
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Cards", "Fallacies")))
+ {
+ dir = dir.Parent;
+ }
+ return dir?.FullName ?? throw new DirectoryNotFoundException("Could not locate repository root (Cards/Fallacies not found).");
+ }
+
+ private static CardSetLocalization GetScenariiLocalization()
+ {
+ var config = new AssetConverterConfig();
+ var loc = config.LocalizationConfig.CardSetLocalizations
+ .FirstOrDefault(l => l.CardSetNames.Contains(KnownCardSets.Scenarii));
+ loc.Should().NotBeNull("the default LocalizationConfig must carry a Scenarii mapping");
+ return loc!;
+ }
+
+ // Mirrors the Front branch of CardSetLocalization.TranslateCardSetInfo (front:true).
+ private static string ApplyFrontSubstitution(CardSetLocalization loc, string template, string destLang)
+ {
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ var conv = fieldConversion.fieldConversions.FirstOrDefault(c => c.Language == destLang);
+ if (string.IsNullOrEmpty(conv.destFieldName)) continue;
+ var destPattern = loc.FormatField(conv.destFieldName);
+ template = template.Replace(sourcePattern, destPattern);
+ }
+ return template;
+ }
+
+ [Theory]
+ [InlineData("en", "title", "category", "context", "issue", "smoothTalker", "drawer")]
+ [InlineData("ru", "title_ru", "category_ru", "context_ru", "issue_ru", "smoothTalker_ru", "drawer_ru")]
+ [InlineData("pt", "title_pt", "category_pt", "context_pt", "issue_pt", "smoothTalker_pt", "drawer_pt")]
+ public void Scenarii_Face_Template_Translates_Core_FR_Tokens_To_Target_Language(
+ string destLang, string title, string category, string context, string issue, string smoothTalker, string drawer)
+ {
+ var path = Path.Combine(FindRepoRoot(), ScenariiFaceTemplateRelPath);
+ File.Exists(path).Should().BeTrue($"Scenarii Face template must exist at {ScenariiFaceTemplateRelPath}");
+ var original = File.ReadAllText(path);
+
+ // Golden-Master contract: the FR template binds these exact tokens.
+ original.Should().Contain("{{titre}}", "Scenarii Face must bind titre");
+ original.Should().Contain("{{catégorie}}", "Scenarii Face must bind catégorie (accented FR header)");
+
+ var loc = GetScenariiLocalization();
+ var translated = ApplyFrontSubstitution(loc, original, destLang);
+
+ translated.Should().Contain($"{{{{{title}}}}}", $"{destLang} Scenarii must bind titre → {title}");
+ translated.Should().Contain($"{{{{{category}}}}}", $"{destLang} Scenarii must bind catégorie → {category}");
+ translated.Should().Contain($"{{{{{context}}}}}", $"{destLang} Scenarii must bind contexte → {context}");
+ translated.Should().Contain($"{{{{{issue}}}}}", $"{destLang} Scenarii must bind enjeu → {issue}");
+ translated.Should().Contain($"{{{{{smoothTalker}}}}}", $"{destLang} Scenarii must bind baratineur → {smoothTalker}");
+ translated.Should().Contain($"{{{{{drawer}}}}}", $"{destLang} Scenarii must bind piocheur → {drawer}");
+
+ // The FR tokens must no longer be present as bindings.
+ translated.Should().NotContain("{{titre}}", $"{destLang} Scenarii must no longer bind FR titre");
+ translated.Should().NotContain("{{catégorie}}", $"{destLang} Scenarii must no longer bind FR catégorie");
+ }
+
+ [Fact]
+ public void Scenarii_Front_Conversions_Reference_Only_Template_Existing_Tokens()
+ {
+ // Root-cause guard for the #216 class of bug: every sourceFieldName in the Scenarii
+ // FrontFieldConversions must correspond to a token actually present in the template.
+ // A conversion referencing a non-existent field is a silent no-op (template.Replace
+ // finds nothing) and the FR content ships unchanged.
+ var path = Path.Combine(FindRepoRoot(), ScenariiFaceTemplateRelPath);
+ var template = File.ReadAllText(path);
+ var loc = GetScenariiLocalization();
+
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ template.Should().Contain(sourcePattern,
+ $"Scenarii FrontFieldConversions source '{fieldConversion.sourceFieldName}' must exist in the template " +
+ $"(otherwise the conversion is a silent no-op — #216 root cause). Pattern looked for: '{sourcePattern}'.");
+ }
+ }
+
+ [Fact]
+ public void Scenarii_Has_ExceptionPatterns_For_Category_Asset_Filenames()
+ {
+ // Scenarii templates reference image assets by the FR category name
+ // ({{rowset.[0].catégorie}}.jpg). When catégorie is localized, the asset path would
+ // break — ExceptionPatterns backtrack restores the FR category inside asset refs.
+ // This guard ensures the ExceptionPatterns are still declared (regression from
+ // Golden Master would silently break image resolution in non-FR Scenarii).
+ var loc = GetScenariiLocalization();
+ loc.ExceptionPatterns.Should().NotBeEmpty(
+ "Scenarii must declare ExceptionPatterns so category-based asset filenames keep resolving after localization");
+ loc.ExceptionPatterns.Should().Contain(p => p.Contains("catégorie"),
+ "at least one ExceptionPattern must reference the FR catégorie token used in asset filenames");
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/VirtuesLocalizationTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/VirtuesLocalizationTests.cs
new file mode 100644
index 00000000..c408f85f
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/VirtuesLocalizationTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.Localization
+{
+ ///
+ /// Regression tests for the Virtues localization mapping — issue #204 (coverage) and the
+ /// #216 class of bug (FrontFieldConversions referencing field names absent from the template).
+ ///
+ /// NEW file (dispatch #204 amend): the Virtues front substitution is tested here, separately
+ /// from FallaciesLocalizationTests (owned by PR #444). No existing file is modified.
+ ///
+ /// The Virtues template (located under Cards/Fallacies/Argumentum_Virtues_Face_fr.json)
+ /// binds through {{title_fr}}, {{description_fr}}, {{remark_fr}} (via
+ /// {{breaklines remark_fr}}), {{family_fr}}, {{subfamily_fr}},
+ /// {{subsubfamily_fr}}. The Virtues FrontFieldConversions swap each _fr
+ /// suffix to the target language. These tests apply the real substitution chain against the
+ /// template on disk and assert each FR binding is replaced by its localized counterpart.
+ ///
+ public class VirtuesLocalizationTests
+ {
+ private const string VirtuesTemplateRelPath = "Cards/Fallacies/Argumentum_Virtues_Face_fr.json";
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Cards", "Fallacies")))
+ {
+ dir = dir.Parent;
+ }
+ return dir?.FullName ?? throw new DirectoryNotFoundException("Could not locate repository root (Cards/Fallacies not found).");
+ }
+
+ private static CardSetLocalization GetVirtuesLocalization()
+ {
+ var config = new AssetConverterConfig();
+ var loc = config.LocalizationConfig.CardSetLocalizations
+ .FirstOrDefault(l => l.CardSetNames.Contains(KnownCardSets.Virtues));
+ loc.Should().NotBeNull("the default LocalizationConfig must carry a Virtues mapping");
+ return loc!;
+ }
+
+ // Mirrors the Front branch of CardSetLocalization.TranslateCardSetInfo (front:true).
+ private static string ApplyFrontSubstitution(CardSetLocalization loc, string template, string destLang)
+ {
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ var conv = fieldConversion.fieldConversions.FirstOrDefault(c => c.Language == destLang);
+ if (string.IsNullOrEmpty(conv.destFieldName)) continue;
+ var destPattern = loc.FormatField(conv.destFieldName);
+ template = template.Replace(sourcePattern, destPattern);
+ }
+ return template;
+ }
+
+ [Theory]
+ [InlineData("en")]
+ [InlineData("ru")]
+ [InlineData("pt")]
+ [InlineData("es")]
+ [InlineData("ar")]
+ [InlineData("fa")]
+ [InlineData("zh")]
+ public void Virtues_Template_Translates_All_FR_Placeholders_To_Target_Language(string destLang)
+ {
+ var path = Path.Combine(FindRepoRoot(), VirtuesTemplateRelPath);
+ File.Exists(path).Should().BeTrue($"Virtues template must exist at {VirtuesTemplateRelPath}");
+ var original = File.ReadAllText(path);
+
+ var loc = GetVirtuesLocalization();
+ var translated = ApplyFrontSubstitution(loc, original, destLang);
+
+ var suffix = $"_{destLang}";
+
+ // Every FR field that carries localized CSV content must be swapped to the target language.
+ foreach (var frField in new[] { "title_fr", "description_fr", "remark_fr", "family_fr", "subfamily_fr", "subsubfamily_fr" })
+ {
+ var destField = frField.Replace("_fr", suffix);
+ translated.Should().Contain(destField,
+ $"{destLang} Virtues template must bind {frField} → {destField}");
+ }
+ }
+
+ [Fact]
+ public void Virtues_Front_Conversions_Reference_Only_Template_Existing_Tokens()
+ {
+ // Root-cause guard for the #216 class of bug: every sourceFieldName in the Virtues
+ // FrontFieldConversions must correspond to a token actually present in the template.
+ // A conversion referencing a non-existent field is a silent no-op (template.Replace
+ // finds nothing) and the FR content ships unchanged.
+ var path = Path.Combine(FindRepoRoot(), VirtuesTemplateRelPath);
+ var template = File.ReadAllText(path);
+ var loc = GetVirtuesLocalization();
+
+ foreach (var fieldConversion in loc.FrontFieldConversions)
+ {
+ var sourcePattern = loc.FormatField(fieldConversion.sourceFieldName);
+ template.Should().Contain(sourcePattern,
+ $"Virtues FrontFieldConversions source '{fieldConversion.sourceFieldName}' must exist in the template " +
+ $"(otherwise the conversion is a silent no-op — #216 root cause). Pattern looked for: '{sourcePattern}'.");
+ }
+ }
+
+ [Fact]
+ public void Virtues_Family_Subtokens_Resist_Suffix_Overlap_Under_Config_Order()
+ {
+ // Note on ordering: unlike the Fallacies Memo Back (where Famille/Sous-Famille/
+ // Soussousfamille MUST be ordered most-specific-first because a shorter suffix can
+ // clobber a longer token), the Virtues config is NOT ordered most-specific-first
+ // (family_fr before subfamily_fr before subsubfamily_fr). This is still correct
+ // because: FormatField appends "}}", and although "family_fr}}" IS a suffix-substring
+ // of "subfamily_fr}}", replacing it first yields "{{subfamily_en}}" — and since the
+ // destination also carries the "_en" suffix, the subsequent subfamily/subsubfamily
+ // steps become harmless no-ops (they no longer match). The end bindings are correct.
+ // This test pins that invariant empirically: under the actual config order, every
+ // FR family token ends up bound to its localized column with NO residual FR suffix.
+ var loc = GetVirtuesLocalization();
+ var template = "{{family_fr}} {{subfamily_fr}} {{subsubfamily_fr}}";
+
+ foreach (var destLang in new[] { "en", "ru", "pt", "es", "ar", "fa", "zh" })
+ {
+ var translated = ApplyFrontSubstitution(loc, template, destLang);
+ var suffix = $"_{destLang}";
+ translated.Should().Contain($"family{suffix}", $"{destLang}: family must be localized");
+ translated.Should().Contain($"subfamily{suffix}", $"{destLang}: subfamily must be localized");
+ translated.Should().Contain($"subsubfamily{suffix}", $"{destLang}: subsubfamily must be localized");
+ translated.Should().NotContain("family_fr", $"{destLang}: no residual FR family token after conversion");
+ }
+ }
+ }
+}