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);