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 pk → Id 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");
+ }
+ }
+}