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,167 @@
using Argumentum.AssetConverter.Entities;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Parsing
{
/// <summary>
/// Contract pin for the 8 <c>LinkXxFallback</c> cascades on <see cref="Fallacy"/> — the
/// per-language mind-map link resolution that feeds <c>LinkExpression</c>
/// (<c>{item.LinkFrFallback}</c>) in <c>FallacyMindMapDocumentConfig</c>, and which is
/// referenced (but NOT behavior-pinned) by <c>MmGeneratorTests</c> and
/// <c>MindMapLocalizationRegressionTests</c>.
///
/// French is the source language. The cascade design is intentionally ASYMMETRIC — a subtle
/// fragility that has had ZERO unit coverage:
/// <list type="bullet">
/// <item><description><see cref="Fallacy.LinkFrFallback"/> / <see cref="Fallacy.LinkEnFallback"/>
/// are 2-deep (Fr↔En: the two source-ish languages).</description></item>
/// <item><description><see cref="Fallacy.LinkRuFallback"/> / Pt / Es / Ar / Fa / Zh are 3-deep:
/// target → En → Fr (a non-source language falls back to En, then to the French source).</description></item>
/// </list>
///
/// 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 <c>string.Empty</c> 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.
/// </summary>
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)!;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System.Linq;
using Argumentum.AssetConverter.Entities;
using CsvHelper;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Parsing
{
/// <summary>
/// Regression tests for <see cref="VirtueClassMap"/> via the real load path
/// <see cref="CsvBase{T,TMap}.LoadFromContent"/>. 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 <c>.Optional()</c>.
///
/// The critical guards these tests pin:
/// <list type="bullet">
/// <item><description>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).</description></item>
/// <item><description>The 28 optional localized columns may be absent without throwing
/// (<c>.Optional()</c> contract — a column losing its Optional flag would make
/// <c>HeaderValidated</c> throw when the harvest loads a FR-only header set).</description></item>
/// <item><description>The double map of <c>pk</c> → <c>Id</c> AND <c>Pk</c> (both Name "pk"):
/// <c>GetId()</c> returns <c>Id</c>, which must resolve from the <c>pk</c> column.</description></item>
/// </list>
///
/// Additive only: no existing test or production code is modified. Inline CSV keeps each
/// test self-contained (no external fixture files). Companion to
/// <c>FallacyClassMapRegressionTests</c> and <c>EntityClassMapRegressionTests</c> — Virtue
/// was the remaining uncovered entity ClassMap (alongside <c>TestFallacyCard</c>, which is
/// visual-test-only and lower-stakes).
/// </summary>
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";

/// <summary>
/// Loads a representative Virtue row (required FR columns + a couple of localized columns)
/// and asserts each required column maps to its property.
/// </summary>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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();
}

/// <summary>
/// When localized columns ARE present, they map to their properties (Optional just means
/// "may be absent", not "ignored when present").
/// </summary>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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<HeaderValidationException>(
"title_fr is a non-Optional column; its absence must fail loud, not silently null the property");
}
}
}
Loading