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