diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/EntityClassMapRegressionTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/EntityClassMapRegressionTests.cs new file mode 100644 index 000000000..92e641b50 --- /dev/null +++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/EntityClassMapRegressionTests.cs @@ -0,0 +1,247 @@ +using Argumentum.AssetConverter.Entities; +using FluentAssertions; +using Xunit; + +namespace Argumentum.AssetConverter.Tests.Parsing +{ + /// + /// Regression tests for the real CsvHelper ClassMaps of the entity types that had + /// historical mapping bugs (Rule.GetId() empty, ArgumentVirtue.Pk unmapped, Scenario + /// accented FR headers, Virtue pk→Id+Pk). Fallacy already has coverage elsewhere. + /// + /// These exercise the REAL load path — — + /// which registers the ClassMap, applies PrepareHeaderForMatch (diacritics/underscore/ + /// hyphen/space stripping + lowercasing) so accented French headers resolve, treats + /// MissingFieldFound as a non-fatal log, and assigns 0-based RowIndex post-load. + /// + /// Additive only: no existing test or production code is modified. Inline CSV keeps + /// each test self-contained (no external fixture files). + /// + public class EntityClassMapRegressionTests + { + // ───────────────────────────── Rule ───────────────────────────── + + [Fact] + public void Rule_LoadFromContent_MapsEveryColumnToItsProperty() + { + var csv = + "pk,Text,Text_en,Text_ru,Text_pt,Text_es,Text_ar,Text_fa,Text_zh,print_and_play\n" + + "Rules_01,Règle One,Rule One,Правило,Regra One,Regla,قاعدة,قانون,规则,yes\n"; + + var rules = Rule.LoadFromContent(csv); + + rules.Should().ContainSingle(); + var r = rules[0]; + r.Pk.Should().Be("Rules_01"); + r.Text.Should().Be("Règle One"); + r.Text_en.Should().Be("Rule One"); + r.Text_ru.Should().Be("Правило"); + r.Text_pt.Should().Be("Regra One"); + r.Text_es.Should().Be("Regla"); + r.Text_ar.Should().Be("قاعدة"); + r.Text_fa.Should().Be("قانون"); + r.Text_zh.Should().Be("规则"); + r.PrintAndPlay.Should().Be("yes"); + } + + /// + /// Historical bug (CLAUDE.md): Rule.GetId() used to return string.Empty. + /// It now returns Pk when present. + /// + [Fact] + public void Rule_GetId_ReturnsPkWhenPresent() + { + var csv = "pk,Text,Text_en,Text_ru,Text_pt\nRules_42,T,T,T,T\n"; + + var rules = Rule.LoadFromContent(csv); + + rules[0].GetId().Should().Be("Rules_42"); + } + + /// + /// Historical bug (CLAUDE.md): Rule.GetId() used to return string.Empty when Pk + /// was absent. It now generates a sequential id from RowIndex: Rules_01, Rules_02… + /// + [Fact] + public void Rule_GetId_ReturnsSequentialIdFromRowIndex_WhenPkAbsent() + { + // Pk column omitted entirely (it is Optional in RuleClassMap). + var csv = + "Text,Text_en,Text_ru,Text_pt\n" + + "A,A,A,A\n" + + "B,B,B,B\n" + + "C,C,C,C\n"; + + var rules = Rule.LoadFromContent(csv); + + rules.Should().HaveCount(3); + rules[0].Pk.Should().BeNull(); + rules[0].GetId().Should().Be("Rules_01"); + rules[1].GetId().Should().Be("Rules_02"); + rules[2].GetId().Should().Be("Rules_03"); + } + + /// + /// The localized Rule columns (es/ar/fa/zh) and print_and_play are Optional in the + /// ClassMap. A CSV header set that omits them must parse without throwing and leave + /// those properties null (MissingFieldFound only logs). + /// + [Fact] + public void Rule_OptionalLocalizedColumns_AbsentDoesNotThrow() + { + var csv = "pk,Text,Text_en,Text_ru,Text_pt\nRules_01,T,T,T,T\n"; + + var act = () => Rule.LoadFromContent(csv); + + act.Should().NotThrow(); + var r = act().Should().Subject.First(); + r.Pk.Should().Be("Rules_01"); + r.Text_es.Should().BeNull(); + r.Text_ar.Should().BeNull(); + r.Text_fa.Should().BeNull(); + r.Text_zh.Should().BeNull(); + r.PrintAndPlay.Should().BeNull(); + } + + // All non-Optional ArgumentVirtue/Virtue columns (FR + meta). The localized + // en/ru/pt/es members are .Optional() so they may be omitted from the header. + private const string ArgumentVirtueFrMetaHeader = + "pk,path,depth,decimal_path_padded,family_fr,subfamily_fr,subsubfamily_fr," + + "title_fr,description_fr,remark_fr,link_fr,family_fr_camelcase,depth_max4,card,update,locked"; + + // ──────────────────────── ArgumentVirtue ──────────────────────── + + /// + /// Historical bug (CLAUDE.md): ArgumentVirtueClassMap did not map the Id/pk column. + /// It now maps pk → Pk, and GetId() returns Pk. + /// + [Fact] + public void ArgumentVirtue_LoadFromContent_MapsPkColumnToPkProperty() + { + var csv = + ArgumentVirtueFrMetaHeader + "\n" + + "1.1,1.1,1,1.1,Famille,Sub,Subsub,Titre,Description,Remark,Link,FamilleCc,1,1,,\n"; + + var virtues = ArgumentVirtue.LoadFromContent(csv); + + virtues.Should().ContainSingle(); + var v = virtues[0]; + v.Pk.Should().Be("1.1"); + v.Path.Should().Be("1.1"); + v.Depth.Should().Be(1); + v.FamilyFr.Should().Be("Famille"); + v.TitleFr.Should().Be("Titre"); + v.DescriptionFr.Should().Be("Description"); + v.GetId().Should().Be("1.1"); + } + + [Fact] + public void ArgumentVirtue_OptionalLocalizedColumns_AbsentDoesNotThrow() + { + // Only FR + meta columns provided; en/ru/pt/es (all Optional) omitted from the + // header. HeaderValidated passes (Optional members are not required), and the + // localized properties stay null. + var csv = + ArgumentVirtueFrMetaHeader + "\n" + + "1.1,1.1,1,1.1,Famille,Sub,Subsub,Titre,Description,Remark,Link,FamilleCc,1,1,,\n"; + + var act = () => ArgumentVirtue.LoadFromContent(csv); + + act.Should().NotThrow(); + var v = act().Should().Subject.First(); + v.Pk.Should().Be("1.1"); + v.FamilyEn.Should().BeNull(); + v.FamilyRu.Should().BeNull(); + v.TitleEs.Should().BeNull(); + } + + // ─────────────────────────── Scenario ─────────────────────────── + + // Every ScenarioClassMap column except print_and_play (the only .Optional() one). + // HeaderValidated requires all non-Optional members to be present in the header. + private static readonly string[] ScenarioColumns = + { + "path", "coordonnées", "catégorie", "sous-catégorie", "titre", "baratineur", + "piocheur", "contexte", "enjeu", "suggestion", "CCby", "category", "subcategory", + "title", "smoothTalker", "drawer", "context", "issue", "suggestion_en", + "category_ru", "subcategory_ru", "title_ru", "smoothTalker_ru", "drawer_ru", + "context_ru", "issue_ru", "suggestion_ru", "category_pt", "subcategory_pt", + "title_pt", "smoothTalker_pt", "drawer_pt", "context_pt", "issue_pt", + "suggestion_pt", "category_es", "subcategory_es", "title_es", "smoothTalker_es", + "drawer_es", "context_es", "issue_es", "suggestion_es", "édition février 2022", + "print_&_play_fevrier_2022", + }; + + /// + /// Scenario headers contain accented French and embedded spaces/hyphens + /// (catégorie, sous-catégorie, coordonnées, édition février 2022). These only resolve + /// because CsvBase.LoadFromContent applies PrepareHeaderForMatch (diacritics/underscore/ + /// hyphen/space stripping + lowercasing). A regression here would silently zero-out the + /// FR fields. + /// + [Fact] + public void Scenario_LoadFromContent_MapsAccentedFrenchHeaders() + { + var header = string.Join(",", ScenarioColumns); + var row = new string[ScenarioColumns.Length]; + row[0] = "1"; // path + row[1] = "coord"; // coordonnées + row[2] = "cat1"; // catégorie + row[3] = "sub1"; // sous-catégorie + row[4] = "Titre"; // titre + row[43] = "2022"; // édition février 2022 + var csv = header + "\n" + string.Join(",", row) + "\n"; + + var scenarii = Scenario.LoadFromContent(csv); + + scenarii.Should().ContainSingle(); + var s = scenarii[0]; + s.path.Should().Be("1"); + s.coordonnees.Should().Be("coord"); + s.categorie.Should().Be("cat1"); + s.sous_categorie.Should().Be("sub1"); + s.titre.Should().Be("Titre"); + s.edition_fevrier_2022.Should().Be("2022"); + } + + /// + /// Scenario.GetId() returns the path column (the canonical id), or empty if null. + /// + [Fact] + public void Scenario_GetId_ReturnsPathColumn() + { + var header = string.Join(",", ScenarioColumns); + var row = new string[ScenarioColumns.Length]; + row[0] = "1.2.3"; // path + var csv = header + "\n" + string.Join(",", row) + "\n"; + + var scenarii = Scenario.LoadFromContent(csv); + + scenarii[0].path.Should().Be("1.2.3"); + scenarii[0].GetId().Should().Be("1.2.3"); + } + + // ───────────────────────────── Virtue ─────────────────────────── + + /// + /// VirtueClassMap maps BOTH Id (CsvBase base property) and Pk to the same "pk" + /// column. The base GetId() returns Id, so both must be populated for GetId() to + /// resolve correctly. + /// + [Fact] + public void Virtue_LoadFromContent_MapsPkColumnToBothPkAndId() + { + var csv = + ArgumentVirtueFrMetaHeader + "\n" + + "2.1,2.1,2,2.1,Famille,Sub,Subsub,Titre,Description,Remark,Link,FamilleCc,2,1,,\n"; + + var virtues = Virtue.LoadFromContent(csv); + + virtues.Should().ContainSingle(); + var v = virtues[0]; + v.Pk.Should().Be("2.1"); + v.Id.Should().Be("2.1"); + v.GetId().Should().Be("2.1"); + } + } +}