Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using System.IO;
using System.Linq;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Localization
{
/// <summary>
/// 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 <c>FallaciesLocalizationTests</c> (which is owned by PR #444). No existing file is
/// modified.
///
/// The Rules template binds body text through <c>{{markdown Text}}</c>. The Rules
/// FrontFieldConversions swap <c>Text</c> -> <c>Text_en/_ru/_pt/_es/_ar/_fa/_zh</c>. 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.
/// </summary>
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");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Linq;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Localization
{
/// <summary>
/// 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 <c>FallaciesLocalizationTests</c> (owned by PR #444). No existing file is modified.
///
/// The Scenarii template binds through FR tokens <c>{{titre}}</c>, <c>{{catégorie}}</c>,
/// <c>{{contexte}}</c>, <c>{{enjeu}}</c>, <c>{{baratineur}}</c>, <c>{{piocheur}}</c>. 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.
/// </summary>
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");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System;
using System.IO;
using System.Linq;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Localization
{
/// <summary>
/// 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 <c>FallaciesLocalizationTests</c> (owned by PR #444). No existing file is modified.
///
/// The Virtues template (located under Cards/Fallacies/Argumentum_Virtues_Face_fr.json)
/// binds through <c>{{title_fr}}</c>, <c>{{description_fr}}</c>, <c>{{remark_fr}}</c> (via
/// <c>{{breaklines remark_fr}}</c>), <c>{{family_fr}}</c>, <c>{{subfamily_fr}}</c>,
/// <c>{{subsubfamily_fr}}</c>. The Virtues FrontFieldConversions swap each <c>_fr</c>
/// 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.
/// </summary>
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");
}
}
}
}
Loading