diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/FallacyLinkFallbackTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/FallacyLinkFallbackTests.cs new file mode 100644 index 00000000..aee3c3cf --- /dev/null +++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/FallacyLinkFallbackTests.cs @@ -0,0 +1,167 @@ +using Argumentum.AssetConverter.Entities; +using FluentAssertions; +using Xunit; + +namespace Argumentum.AssetConverter.Tests.Parsing +{ + /// + /// Contract pin for the 8 LinkXxFallback cascades on — the + /// per-language mind-map link resolution that feeds LinkExpression + /// ({item.LinkFrFallback}) in FallacyMindMapDocumentConfig, and which is + /// referenced (but NOT behavior-pinned) by MmGeneratorTests and + /// MindMapLocalizationRegressionTests. + /// + /// French is the source language. The cascade design is intentionally ASYMMETRIC — a subtle + /// fragility that has had ZERO unit coverage: + /// + /// / + /// are 2-deep (Fr↔En: the two source-ish languages). + /// / Pt / Es / Ar / Fa / Zh are 3-deep: + /// target → En → Fr (a non-source language falls back to En, then to the French source). + /// + /// + /// The silent-wrong-output risk this guards against: if a refactor "symmetrizes" the cascade + /// (e.g. makes every language a 2-deep target→Fr, or reorders En/Fr), a fallacy that ships with + /// ONLY a French link would resolve to string.Empty in non-EN exports — producing a + /// mind-map node with an EMPTY link, no exception, no log. The regression is invisible except + /// by opening every generated mind-map and checking each node. These tests pin the cascade + /// order additively so such a regression fails loud. + /// + public class FallacyLinkFallbackTests + { + // ───────────────────────────────────────────────────────────────────────────── + // (1) LinkFrFallback — 2-deep: Fr → En. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void LinkFrFallback_PrimaryPresent_ReturnsLinkFr() + { + var f = new Fallacy { LinkFr = "fr-link", LinkEn = "en-link" }; + f.LinkFrFallback.Should().Be("fr-link"); + } + + [Fact] + public void LinkFrFallback_PrimaryNull_FallsBackToEn() + { + // The fragile bit: a missing French link silently uses the English one. + var f = new Fallacy { LinkFr = null, LinkEn = "en-link" }; + f.LinkFrFallback.Should().Be("en-link"); + } + + [Fact] + public void LinkFrFallback_BothNull_ReturnsNull() + { + // Edge of the cascade: no Fr AND no En. The expression is + // `IsNullOrEmpty(LinkFr) ? LinkEn : LinkFr` — with BOTH null it returns LinkEn, which is + // null. NOT string.Empty. This is the actual (current) contract: a fallacy with no Fr/En + // link resolves to NULL, which propagates into the Mustache template as empty text but is + // NULL to any C# consumer — a silent-wrong-output hazard this test pins explicitly. + var f = new Fallacy { LinkFr = null, LinkEn = null }; + f.LinkFrFallback.Should().BeNull(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (2) LinkEnFallback — 2-deep, INVERSE direction: En → Fr. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void LinkEnFallback_PrimaryPresent_ReturnsLinkEn() + { + var f = new Fallacy { LinkFr = "fr-link", LinkEn = "en-link" }; + f.LinkEnFallback.Should().Be("en-link"); + } + + [Fact] + public void LinkEnFallback_PrimaryNull_FallsBackToFr() + { + // Inverse of LinkFrFallback: missing En silently uses French. + var f = new Fallacy { LinkFr = "fr-link", LinkEn = null }; + f.LinkEnFallback.Should().Be("fr-link"); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (3) Non-source languages (Ru/Pt/Es/Ar/Fa/Zh) — 3-deep cascade: target → En → Fr. + // Pinned via Theory across all 6 languages × the 3 meaningful branches. + // ───────────────────────────────────────────────────────────────────────────── + + // (3a) Target present → returns target (cascade short-circuits). + [Theory] + [InlineData(nameof(Fallacy.LinkRu), nameof(Fallacy.LinkRuFallback))] + [InlineData(nameof(Fallacy.LinkPt), nameof(Fallacy.LinkPtFallback))] + [InlineData(nameof(Fallacy.LinkEs), nameof(Fallacy.LinkEsFallback))] + [InlineData(nameof(Fallacy.LinkAr), nameof(Fallacy.LinkArFallback))] + [InlineData(nameof(Fallacy.LinkFa), nameof(Fallacy.LinkFaFallback))] + [InlineData(nameof(Fallacy.LinkZh), nameof(Fallacy.LinkZhFallback))] + public void NonSource_PrimaryPresent_ReturnsPrimary(string linkProp, string fallbackProp) + { + var f = new Fallacy { LinkFr = "fr-link", LinkEn = "en-link" }; + SetLink(f, linkProp, "xx-link"); + + GetFallback(f, fallbackProp).Should().Be("xx-link", + "the cascade must short-circuit when the target language has its own link"); + } + + // (3b) Target null, En present → returns En (second tier). + [Theory] + [InlineData(nameof(Fallacy.LinkRu), nameof(Fallacy.LinkRuFallback))] + [InlineData(nameof(Fallacy.LinkPt), nameof(Fallacy.LinkPtFallback))] + [InlineData(nameof(Fallacy.LinkEs), nameof(Fallacy.LinkEsFallback))] + [InlineData(nameof(Fallacy.LinkAr), nameof(Fallacy.LinkArFallback))] + [InlineData(nameof(Fallacy.LinkFa), nameof(Fallacy.LinkFaFallback))] + [InlineData(nameof(Fallacy.LinkZh), nameof(Fallacy.LinkZhFallback))] + public void NonSource_PrimaryNull_EnPresent_ReturnsEn(string linkProp, string fallbackProp) + { + var f = new Fallacy { LinkFr = "fr-link", LinkEn = "en-link" }; + SetLink(f, linkProp, null); + + GetFallback(f, fallbackProp).Should().Be("en-link", + "a non-source language with no own link must fall back to English, NOT French"); + } + + // (3c) Target null, En null, Fr present → returns Fr (source-language floor). + [Theory] + [InlineData(nameof(Fallacy.LinkRu), nameof(Fallacy.LinkRuFallback))] + [InlineData(nameof(Fallacy.LinkPt), nameof(Fallacy.LinkPtFallback))] + [InlineData(nameof(Fallacy.LinkEs), nameof(Fallacy.LinkEsFallback))] + [InlineData(nameof(Fallacy.LinkAr), nameof(Fallacy.LinkArFallback))] + [InlineData(nameof(Fallacy.LinkFa), nameof(Fallacy.LinkFaFallback))] + [InlineData(nameof(Fallacy.LinkZh), nameof(Fallacy.LinkZhFallback))] + public void NonSource_TargetAndEnNull_FrPresent_ReturnsFr(string linkProp, string fallbackProp) + { + // THE most fragile branch: a fallacy shipping with ONLY a French link must still resolve + // to French in every non-source export. A "symmetrized" 2-deep cascade would drop this + // floor and return empty here → silent empty mind-map link. + var f = new Fallacy { LinkFr = "fr-link", LinkEn = null }; + SetLink(f, linkProp, null); + + GetFallback(f, fallbackProp).Should().Be("fr-link", + "French is the source language — it is the floor of the cascade, not just a peer"); + } + + // (3d) All three null → null (NOT empty — see LinkFrFallback_BothNull_ReturnsNull note). + [Theory] + [InlineData(nameof(Fallacy.LinkRu), nameof(Fallacy.LinkRuFallback))] + [InlineData(nameof(Fallacy.LinkPt), nameof(Fallacy.LinkPtFallback))] + [InlineData(nameof(Fallacy.LinkEs), nameof(Fallacy.LinkEsFallback))] + [InlineData(nameof(Fallacy.LinkAr), nameof(Fallacy.LinkArFallback))] + [InlineData(nameof(Fallacy.LinkFa), nameof(Fallacy.LinkFaFallback))] + [InlineData(nameof(Fallacy.LinkZh), nameof(Fallacy.LinkZhFallback))] + public void NonSource_AllNull_ReturnsNull(string linkProp, string fallbackProp) + { + var f = new Fallacy { LinkFr = null, LinkEn = null }; + SetLink(f, linkProp, null); + + GetFallback(f, fallbackProp).Should().BeNull(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Reflection helpers — let one Theory cover all 6 non-source languages uniformly. + // ───────────────────────────────────────────────────────────────────────────── + + private static void SetLink(Fallacy f, string propName, string value) + => typeof(Fallacy).GetProperty(propName)!.SetValue(f, value); + + private static string GetFallback(Fallacy f, string propName) + => (string)typeof(Fallacy).GetProperty(propName)!.GetValue(f)!; + } +} diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/VirtueClassMapRegressionTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/VirtueClassMapRegressionTests.cs new file mode 100644 index 00000000..8c1cf299 --- /dev/null +++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Parsing/VirtueClassMapRegressionTests.cs @@ -0,0 +1,163 @@ +using System.Linq; +using Argumentum.AssetConverter.Entities; +using CsvHelper; +using FluentAssertions; +using Xunit; + +namespace Argumentum.AssetConverter.Tests.Parsing +{ + /// + /// Regression tests for via the real load path + /// . Virtue is the taxonomy entity for the + /// Virtues lane (#499 scale-up, 223 nodes / 194 leaves) and its ClassMap declares 16 required + /// FR columns (lines 75-92 : 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) plus 28 localized columns + /// (en/ru/pt/es × 7) all .Optional(). + /// + /// The critical guards these tests pin: + /// + /// The 16 required FR columns must all resolve (a header drift here = silent + /// data loss on Virtue generation, same fragility class as the #216/#477 localization + /// regression). + /// The 28 optional localized columns may be absent without throwing + /// (.Optional() contract — a column losing its Optional flag would make + /// HeaderValidated throw when the harvest loads a FR-only header set). + /// The double map of pkId AND Pk (both Name "pk"): + /// GetId() returns Id, which must resolve from the pk column. + /// + /// + /// Additive only: no existing test or production code is modified. Inline CSV keeps each + /// test self-contained (no external fixture files). Companion to + /// FallacyClassMapRegressionTests and EntityClassMapRegressionTests — Virtue + /// was the remaining uncovered entity ClassMap (alongside TestFallacyCard, which is + /// visual-test-only and lower-stakes). + /// + public class VirtueClassMapRegressionTests + { + // Every non-Optional VirtueClassMap column (Virtue.cs:75-92). HeaderValidated requires all + // of these in the header; every localized column (en/ru/pt/es × 7 = 28) is .Optional(). + private const string VirtueRequiredHeader = + "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"; + + // A representative FR-only data row matching VirtueRequiredHeader (16 fields). + // depth (3rd field) is an int property → the cell must be int-parseable. + private const string VirtueRequiredRow = + "1.1,1.1,1,1.1,Arguments vertueux,Argument pertinent,,Présentation claire," + + "Desc FR,Remarque FR,https://fr,argumentsVertueux,1,1,2024-01-01,false"; + + /// + /// Loads a representative Virtue row (required FR columns + a couple of localized columns) + /// and asserts each required column maps to its property. + /// + [Fact] + public void Virtue_LoadFromContent_MapsRequiredColumns() + { + var csv = + VirtueRequiredHeader + ",title_en,title_pt\n" + + VirtueRequiredRow + ",Clear presentation,Apresentação clara\n"; + + var virtues = Virtue.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); // depth is an int property, parsed from the "depth" column + v.DecimalPathPadded.Should().Be("1.1"); + v.FamilyFr.Should().Be("Arguments vertueux"); + v.SubfamilyFr.Should().Be("Argument pertinent"); + v.SubsubfamilyFr.Should().Be(""); // empty cell in the inline row + v.TitleFr.Should().Be("Présentation claire"); + v.DescriptionFr.Should().Be("Desc FR"); + v.RemarkFr.Should().Be("Remarque FR"); + v.LinkFr.Should().Be("https://fr"); + v.FamilyFrCamelcase.Should().Be("argumentsVertueux"); + v.DepthMax4.Should().Be("1"); + v.Card.Should().Be("1"); + v.Update.Should().Be("2024-01-01"); + v.Locked.Should().Be("false"); + } + + /// + /// The single "pk" column populates BOTH the Pk property (Name "pk") and the Id property + /// (Name "pk"), so GetId() (which returns Id) resolves. This double-map is a silent-failure + /// source if either binding is dropped: GetId() would return null, breaking any keyed + /// generation pass. + /// + [Fact] + public void Virtue_LoadFromContent_PkPopulatesBothIdAndPk() + { + var csv = VirtueRequiredHeader + "\n" + VirtueRequiredRow + "\n"; + + var virtues = Virtue.LoadFromContent(csv); + + virtues.Should().ContainSingle(); + virtues[0].Pk.Should().Be("1.1"); + virtues[0].Id.Should().Be("1.1", "the pk column is mapped to both Id and Pk"); + virtues[0].GetId().Should().Be("1.1"); + } + + /// + /// The localized columns (en/ru/pt/es) are all .Optional() — a FR-only header set must + /// load without throwing. A column losing its .Optional() flag would make HeaderValidated + /// throw, silently breaking the multilingual harvest. + /// + [Fact] + public void Virtue_LoadFromContent_OptionalLocalizedColumnsAbsent_DoesNotThrow() + { + // Only the 16 required FR columns — no en/ru/pt/es at all. + var csv = VirtueRequiredHeader + "\n" + VirtueRequiredRow + "\n"; + + var act = () => Virtue.LoadFromContent(csv); + + act.Should().NotThrow(); + var virtues = Virtue.LoadFromContent(csv); + virtues.Should().ContainSingle(); + virtues[0].TitleEn.Should().BeNull("title_en is Optional and absent → null, not throw"); + virtues[0].TitlePt.Should().BeNull(); + virtues[0].FamilyEs.Should().BeNull(); + } + + /// + /// When localized columns ARE present, they map to their properties (Optional just means + /// "may be absent", not "ignored when present"). + /// + [Fact] + public void Virtue_LoadFromContent_OptionalLocalizedColumnsPresent_MapsThem() + { + var csv = + VirtueRequiredHeader + ",title_en,title_pt,family_es\n" + + VirtueRequiredRow + ",Clear presentation,Apresentação clara,Virtudes\n"; + + var virtues = Virtue.LoadFromContent(csv); + + virtues.Should().ContainSingle(); + virtues[0].TitleEn.Should().Be("Clear presentation"); + virtues[0].TitlePt.Should().Be("Apresentação clara"); + virtues[0].FamilyEs.Should().Be("Virtudes"); + } + + /// + /// A required FR column absent from the header must throw HeaderValidationException — + /// CsvHelper's default (VirtueClassMap does not set HeaderValidated, so the strict default + /// applies). This is the contract that prevents silent data loss: a required column drift + /// fails loud, not silent. + /// + [Fact] + public void Virtue_LoadFromContent_RequiredColumnAbsent_Throws() + { + // Drop "title_fr" (a required FR column) from the header AND the row. + var header = VirtueRequiredHeader.Replace(",title_fr", ""); + var row = VirtueRequiredRow; // row has one extra field, but MissingFieldFound logs-not-throws + var csv = header + "\n" + row + "\n"; + + var act = () => Virtue.LoadFromContent(csv); + + act.Should().Throw( + "title_fr is a non-Optional column; its absence must fail loud, not silently null the property"); + } + } +}