diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/FieldMappingContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/FieldMappingContractTests.cs new file mode 100644 index 000000000..aeed5ca40 --- /dev/null +++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Localization/FieldMappingContractTests.cs @@ -0,0 +1,203 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Xunit; + +namespace Argumentum.AssetConverter.Tests.Localization +{ + /// + /// Contract tests for the #216 root cause, pinned via the pure extraction + /// / + /// . + /// + /// #216 in one line: LocalizationConfig.FrontFieldConversions did + /// template.Replace(sourceField, destField) on field names that were ABSENT from the + /// Mustache template. string.Replace is silent when the pattern is missing (returns the + /// string unchanged), so every non-FR PDF silently kept its French content with no error — a + /// silent false-pass. These tests pin the two halves of the contract: + /// (a) a mapping whose source field EXISTS in the template → it is replaced; + /// (b) a mapping whose source field is ABSENT → it is silently skipped by ApplyFieldConversions + /// (the hazard) BUT surfaced by FindAbsentSourceFields (the fail-loud companion); + /// (c) Fallacies/Rules/Virtues/Scenarii lockstep — every mapped source field must be present + /// in a real template (the regression guard that would have caught #216). + /// + /// The four sibling test files (Fallacies/Rules/Virtues/Scenarii LocalizationTests) each + /// re-implement the apply loop inline; this file drives the shared pure methods directly. + /// Migrating those helpers onto is a + /// follow-up (single source of truth), out of scope for this gate-safe extraction. + /// + public class FieldMappingContractTests + { + private static readonly string RepoRoot = FindRepoRoot(); + + 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 string ReadTemplate(string relPath) => File.ReadAllText(Path.Combine(RepoRoot, relPath)); + + private static CardSetLocalization GetLocalization(string cardSet) + { + var config = new AssetConverterConfig(); + var loc = config.LocalizationConfig.CardSetLocalizations + .FirstOrDefault(l => l.CardSetNames.Contains(cardSet)); + loc.Should().NotBeNull($"the default LocalizationConfig must carry a {cardSet} mapping"); + return loc!; + } + + // Synthetic field-conversion list matching the production tuple shape. + private static List<(string sourceFieldName, List<(string Language, string destFieldName)> fieldConversions)> Conversions( + params (string source, string dest)[] enMappings) + => enMappings + .Select(m => (m.source, new List<(string Language, string destFieldName)> { ("en", m.dest) })) + .ToList(); + + // ---- (a) source field PRESENT in template → replaced -------------------------------- + + [Fact] + public void ApplyFieldConversions_PresentField_IsReplaced() + { + const string template = "
{{Titre}}
"; + + var result = CardSetLocalization.ApplyFieldConversions(template, Conversions(("Titre", "Title")), null, "en"); + + result.Should().Be("
{{Title}}
", "the present source token Titre}} must be rewritten to Title}}"); + } + + [Fact] + public void ApplyFieldConversions_ReplacesAllOccurrences_NotJustFirst() + { + // template.Replace replaces every occurrence — the contract is global, not first-match. + const string template = "{{Titre}} and again {{Titre}}"; + + var result = CardSetLocalization.ApplyFieldConversions(template, Conversions(("Titre", "Title")), exceptionPatterns: null, "en"); + + result.Should().Be("{{Title}} and again {{Title}}"); + } + + // ---- (b) source field ABSENT → silent skip (hazard) + surfaced by fail-loud -------- + + [Fact] + public void ApplyFieldConversions_AbsentField_IsSilentlySkipped_NoThrow() + { + // This IS the #216 footgun, pinned explicitly: a field absent from the template is + // silently skipped by template.Replace (no error, no log) — so a wrong mapping name + // leaves content untranslated with zero signal. FindAbsentSourceFields is the remedy. + const string template = "
{{Titre}}
"; + + var act = () => CardSetLocalization.ApplyFieldConversions( + template, Conversions(("Titre", "Title"), ("GhostField", "RealField")), null, "en"); + + act.Should().NotThrow("an absent source pattern must never raise"); + act().Should().Be("
{{Title}}
", + "Titre is replaced and the absent GhostField is a silent no-op (the #216 hazard)"); + } + + [Fact] + public void FindAbsentSourceFields_ReportsAbsentField_AndOmitsPresentOne() + { + const string template = "
{{Titre}}
"; + + var absent = CardSetLocalization.FindAbsentSourceFields( + template, Conversions(("Titre", "Title"), ("GhostField", "RealField")), "en"); + + absent.Should().ContainSingle(f => f == "GhostField", + "the fail-loud companion must surface exactly the absent source field"); + absent.Should().NotContain("Titre", "a present source field must not be flagged absent"); + } + + [Fact] + public void FindAbsentSourceFields_ReturnsEmpty_WhenEveryFieldIsPresent() + { + const string template = "
{{Titre}}{{Desc}}
"; + + var absent = CardSetLocalization.FindAbsentSourceFields( + template, Conversions(("Titre", "Title"), ("Desc", "Description")), "en"); + + absent.Should().BeEmpty("no source field is absent when all tokens are present"); + } + + [Fact] + public void ApplyFieldConversions_NoConversionForDestLang_SkipsSilently() + { + // A field with no dest-lang entry is skipped — only fields carrying the requested + // language are substituted (mirrors TranslateCardSetInfo's FirstOrDefault(destLang) guard). + const string template = "
{{Titre}}
"; + var conversions = new List<(string, List<(string, string)>)> + { + ("Titre", new List<(string, string)> { ("ru", "Zagolovok") }), // no "en" entry + }; + + var result = CardSetLocalization.ApplyFieldConversions(template, conversions, null, "en"); + + result.Should().Be(template, "a field without an 'en' conversion must be left untouched"); + } + + [Fact] + public void FindAbsentSourceFields_NoConversionForDestLang_IsNotReportedAbsent() + { + // Symmetric to the above: a field with no dest-lang entry is neither replaced nor + // reported absent — it is simply out of scope for this language. + const string template = "
{{Titre}}
"; + var conversions = new List<(string, List<(string, string)>)> + { + ("Titre", new List<(string, string)> { ("ru", "Zagolovok") }), + }; + + var absent = CardSetLocalization.FindAbsentSourceFields(template, conversions, "en"); + + absent.Should().BeEmpty("a field with no 'en' conversion is out of scope, not absent"); + } + + // ---- (c) CardSet ↔ template lockstep (the #216 regression guard) ------------------- + + // InlineData requires compile-time constants; KnownCardSets.* are static readonly, so the + // string literals mirror their values ("Fallacies"/"Virtues"/"Rules"/"Scenarii"). + [Theory] + [InlineData("Fallacies", "Cards/Fallacies/Argumentum_Fallacies_Face_fr.json")] + [InlineData("Virtues", "Cards/Fallacies/Argumentum_Virtues_Face_fr.json")] + [InlineData("Rules", "Cards/Rules/Argumentum_Rules_fr.json")] + [InlineData("Scenarii", "Cards/Scenarii/Argumentum_Scenarii_Face_fr.json")] + public void EveryMappedFrontField_IsPresentInRealTemplate_NoSilentFalsePass(string cardSet, string templateRelPath) + { + // THE #216 REGRESSION GUARD. For each CardSet, every source field mapped for 'en' must + // actually appear in the representative template. If a mapping references a field that + // is not in the template, FindAbsentSourceFields returns it here and the test fails loud + // — exactly the signal #216 lacked. (This would have caught the original bug: the + // Fallacies mapping named "Titre"/"Definition"/"Exemple" which the templates never had.) + var loc = GetLocalization(cardSet); + loc.FrontFieldConversions.Should().NotBeEmpty($"{cardSet} must carry front field conversions"); + var template = ReadTemplate(templateRelPath); + + var absent = CardSetLocalization.FindAbsentSourceFields(template, loc.FrontFieldConversions, "en"); + + absent.Should().BeEmpty( + $"every 'en'-mapped front field of {cardSet} must exist in {templateRelPath}; " + + $"absent fields = [{string.Join(", ", absent)}] would be silently skipped → untranslated content (#216)"); + } + + [Fact] + public void ApplyFieldConversions_OnRealFallaciesTemplate_ReplacesFrenchPlaceholders() + { + // Grounding: the pure method, fed the real Fallacies config + template, performs the + // same #216 fix as the production path (text_fr → text_en etc.) — i.e. the extraction + // is behaviour-preserving, not just structurally pure. + var loc = GetLocalization(KnownCardSets.Fallacies); + var template = ReadTemplate("Cards/Fallacies/Argumentum_Fallacies_Face_fr.json"); + + var result = CardSetLocalization.ApplyFieldConversions(template, loc.FrontFieldConversions, loc.ExceptionPatterns, "en"); + + result.Should().NotContain("{{text_fr}}", "the pure method must replace text_fr (the #216 token) on the real template"); + result.Should().NotContain("{{desc_fr}}", "the pure method must replace desc_fr on the real template"); + result.Should().Contain("text_en", "the 'en' replacement token must be present"); + result.Should().Contain("desc_en", "the 'en' replacement token must be present"); + } + } +} diff --git a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/Localization/CardSetLocalization.cs b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/Localization/CardSetLocalization.cs index 48a490122..7cab280d0 100644 --- a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/Localization/CardSetLocalization.cs +++ b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/Localization/CardSetLocalization.cs @@ -21,9 +21,97 @@ public class CardSetLocalization:DocumentLocalization public List ExceptionPatterns { get; set; } = new List(); - public string FormatField(string fieldName) + public string FormatField(string fieldName) => FormatFieldToken(fieldName); + + /// + /// Formats a mustache field token's closing-brace pattern (e.g. "Famille" → "Famille}}"). + /// Centralised so the pure static conversion helpers below share a single source of truth + /// with the instance used by callers and the localization tests. + /// + private static string FormatFieldToken(string fieldName) => $"{fieldName}}}"; + + /// + /// Pure, output-neutral extraction of the field-conversion transform performed by + /// : for each source field whose dest-lang conversion is + /// present, its mustache token is replaced in the template, and any exception patterns that + /// absorbed the replacement are back-tracked afterwards. Byte-identical to the previous + /// inline logic. Extracted so the #216 contract (field present → replaced; field absent → + /// reported by ) can be unit-tested directly. + /// + public static string ApplyFieldConversions( + string template, + IEnumerable<(string sourceFieldName, List<(string Language, string destFieldName)> fieldConversions)> fieldConversions, + IEnumerable exceptionPatterns, + string destLanguage) { - return $"{fieldName}}}"; + var exceptionsBacktrack = new List<(string sourcePattern, string destPattern)>(); + foreach (var fieldConversion in fieldConversions) + { + var sourceFieldPattern = FormatFieldToken(fieldConversion.sourceFieldName); + if (fieldConversion.fieldConversions == null || !fieldConversion.fieldConversions.Any()) + { + continue; + } + var conversion = fieldConversion.fieldConversions.FirstOrDefault(convertedField => convertedField.Language == destLanguage); + if (string.IsNullOrEmpty(conversion.destFieldName)) + { + continue; + } + var destFieldPattern = FormatFieldToken(conversion.destFieldName); + template = template.Replace(sourceFieldPattern, destFieldPattern); + foreach (var exception in exceptionPatterns ?? Enumerable.Empty()) + { + var exceptionPattern = exception.Replace(sourceFieldPattern, destFieldPattern); + if (exceptionPattern != exception) + { + exceptionsBacktrack.Add((exceptionPattern, exception)); + } + } + } + + foreach (var exceptionBacktrackTransform in exceptionsBacktrack) + { + template = template.Replace(exceptionBacktrackTransform.sourcePattern, + exceptionBacktrackTransform.destPattern); + } + + return template; + } + + /// + /// Pure capability that surfaces the #216 footgun: the source field names whose mustache + /// token is NOT present in the template — so would + /// silently no-op on them (template.Replace returns the string unchanged when the pattern is + /// absent), leaving untranslated content with no error. This silent false-pass was the root + /// cause of #216 (Fallacies mapping referenced "Titre"/"Definition"/"Exemple" which never + /// existed in the templates). NOT yet wired to Logger in production: that one-line wiring is + /// a deliberate post-gate follow-up to keep this extraction output-neutral during the release + /// gate (the gate holds even output-neutral pipeline behaviour changes). + /// + public static IReadOnlyList FindAbsentSourceFields( + string template, + IEnumerable<(string sourceFieldName, List<(string Language, string destFieldName)> fieldConversions)> fieldConversions, + string destLanguage) + { + var absent = new List(); + foreach (var fieldConversion in fieldConversions) + { + if (fieldConversion.fieldConversions == null || !fieldConversion.fieldConversions.Any()) + { + continue; + } + var conversion = fieldConversion.fieldConversions.FirstOrDefault(convertedField => convertedField.Language == destLanguage); + if (string.IsNullOrEmpty(conversion.destFieldName)) + { + continue; + } + var sourceFieldPattern = FormatFieldToken(fieldConversion.sourceFieldName); + if (!template.Contains(sourceFieldPattern)) + { + absent.Add(fieldConversion.sourceFieldName); + } + } + return absent; } @@ -72,37 +160,11 @@ public async Task TranslateCardSetInfo(CardSetInfo source, return null; } var template = sourceCardSetPayload.CardSetDocument.mustache; - var exceptionsBacktrack = new List<(string sourcePattern, string destPattern)>(); - foreach (var fieldConversion in fieldConversions) - { - var sourceFieldPattern = FormatField(fieldConversion.sourceFieldName); - if (fieldConversion.fieldConversions == null || !fieldConversion.fieldConversions.Any()) - { - continue; - } - var conversion = fieldConversion.fieldConversions.FirstOrDefault(convertedField => convertedField.Language == languages.destLang); - if (string.IsNullOrEmpty(conversion.destFieldName)) - { - continue; - } - var destFieldPattern = FormatField(conversion.destFieldName); - template = template.Replace(sourceFieldPattern, destFieldPattern); - foreach (var exception in this.ExceptionPatterns) - { - var exceptionPattern = exception.Replace(sourceFieldPattern, destFieldPattern); - if (exceptionPattern != exception) - { - exceptionsBacktrack.Add((exceptionPattern, exception)); - } - } - } - - foreach (var exceptionBacktrackTransform in exceptionsBacktrack) - { - template = template.Replace(exceptionBacktrackTransform.sourcePattern, - exceptionBacktrackTransform.destPattern); - } + // Output-neutral pure extraction (#216 root-cause contract). Field tokens absent from the + // template are silently skipped by template.Replace; FindAbsentSourceFields is the + // fail-loud companion that surfaces them (wiring to Logger is a post-gate follow-up). + template = ApplyFieldConversions(template, fieldConversions, this.ExceptionPatterns, languages.destLang); template = DoStaticConversions(template, languages.destLang);