diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/FaceToBackMatchingContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/FaceToBackMatchingContractTests.cs
new file mode 100644
index 00000000..e974600e
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/FaceToBackMatchingContractTests.cs
@@ -0,0 +1,163 @@
+using System.Collections.Generic;
+using System.Linq;
+using Argumentum.AssetConverter;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.WebBasedGenerator
+{
+ ///
+ /// Contract pin for — #204 secondary
+ /// (cont. po-2024), the Golden Master (commit 0087f0ec) face→back name-matching contract.
+ ///
+ /// When a card set ships several distinct back arts, the harvest must pair EACH face with the
+ /// correct back by NAME, not randomly: the back whose (lower-cased) key is contained in the
+ /// (lower-cased) face key wins (e.g. face "Argumentum_Scenarii_1.1.1..histoire_titre" picks the
+ /// "histoire" back). With a single back, every face shares it; with no backs, the face ships
+ /// alone; if no name matches, it falls back to the first available back.
+ ///
+ /// This is a fragile, silently-wrong-output contract: a regression here pairs the WRONG back
+ /// art behind a face while leaving page count, geometry, and ordering all correct — the defect
+ /// surfaces only by inspecting which back sits behind which printed card. It was previously
+ /// inlined inside AssembleCurrentCardImages with ZERO unit coverage. It has been
+ /// extracted (output-neutral — the call site assembles the exact same Front/Back pair) into the
+ /// pure, deterministic so the contract is
+ /// unit-testable. These tests pin it additively.
+ ///
+ /// Conceptual complement to (#119): that
+ /// contract ORDERS fronts/backs back-then-front; THIS contract CHOOSES which back each face
+ /// gets.
+ ///
+ public class FaceToBackMatchingContractTests
+ {
+ // Helper: build a back dictionary from (name, path) tuples.
+ private static Dictionary Backs(params (string name, string path)[] specs)
+ => specs.ToDictionary(s => s.name, s => s.path);
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (1) No-back / single-back branches.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NoBacks_ReturnsNull_AndFlagsNoAvailableBack()
+ {
+ // A card set with no harvested backs ships faces alone — Back stays null.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Argumentum_Scenarii_1.1_histoire_titre", Backs(),
+ out var hadNoAvailableBack, out var usedFallback);
+
+ back.Should().BeNull();
+ hadNoAvailableBack.Should().BeTrue();
+ usedFallback.Should().BeFalse();
+ }
+
+ [Fact]
+ public void SingleBack_IsSharedByEveryFace()
+ {
+ // One back art → it pairs behind every face, regardless of the face name.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Anything_unrelated_name", Backs(("-histoire", "/back/histoire.png")),
+ out var hadNoAvailableBack, out var usedFallback);
+
+ back.Should().Be("/back/histoire.png");
+ hadNoAvailableBack.Should().BeFalse();
+ usedFallback.Should().BeFalse();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (2) Name-matching — the Golden Master substring rule.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MultipleBacks_PicksBackWhoseKeyIsContainedInFaceKey()
+ {
+ // Back keys are normalized by GenerateBacks to a leading-hyphen suffix (e.g. harvested
+ // "scenarii-01-histoire" → key "-histoire"). The face key produced by CardPen for that
+ // scenario contains the SAME "-histoire" substring, so Contains("-histoire") matches.
+ // (NB: the raw token "histoire" alone would NOT match — the leading hyphen is part of
+ // the contract. See LongestMatchingKeyWins for the tie-break over shared suffixes.)
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Argumentum_Scenarii_1.1.1.-histoire.titre",
+ Backs(("-histoire", "/back/histoire.png"), ("-logique", "/back/logique.png")),
+ out _, out var usedFallback);
+
+ back.Should().Be("/back/histoire.png");
+ usedFallback.Should().BeFalse();
+ }
+
+ [Fact]
+ public void NameMatching_IsCaseInsensitive()
+ {
+ // The contract lower-cases both sides before Contains — "HISTOIRE" in the face matches
+ // a "-histoire" back key (the hyphen prefix is still required).
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Face_with_-HISTOIRE_token",
+ Backs(("-histoire", "/back/histoire.png")),
+ out _, out _);
+
+ back.Should().Be("/back/histoire.png");
+ }
+
+ [Fact]
+ public void MultipleBacks_PicksUnrelatedBackForUnrelatedFace()
+ {
+ // A face matching a DIFFERENT token gets that token's back, not the first one.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Argumentum_Scenarii_2.3.-logique.suite",
+ Backs(("-histoire", "/back/histoire.png"), ("-logique", "/back/logique.png")),
+ out _, out _);
+
+ back.Should().Be("/back/logique.png");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (3) THE fragile bit — longest-key-first tie-break.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void LongestMatchingKeyWins_WhenMultipleKeysMatch()
+ {
+ // Both "-hist" and "-histoire" are contained in "Face_-histoire.titre". The contract
+ // picks the LONGEST match first (OrderByDescending(Length)) so the more specific back
+ // wins. Dropping the tie-break would let "-hist" (or insertion order) steal the match.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Face_-histoire.titre",
+ Backs(("-hist", "/back/short.png"), ("-histoire", "/back/specific.png")),
+ out _, out _);
+
+ back.Should().Be("/back/specific.png");
+ }
+
+ [Fact]
+ public void ShorterOnlyMatch_StillPicked_WhenNoLongerMatch()
+ {
+ // When only the short key matches (face contains "-hist" but not "-histoire"), the short
+ // key is chosen — longest-first does not prevent a match, only breaks ties.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Face_-hist.something",
+ Backs(("-hist", "/back/short.png"), ("-histoire", "/back/specific.png")),
+ out _, out _);
+
+ back.Should().Be("/back/short.png");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (4) Fallback — no name matches → first available back, flagged.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NoNameMatch_FallsBackToFirstBack_AndFlagsFallback()
+ {
+ // A face whose key contains NEITHER back token gets the first available back, and the
+ // caller is told it used the fallback so it can warn.
+ var back = ImageFileGenerator.ResolveCardBack(
+ "Face_with_no_matching_token",
+ Backs(("-histoire", "/back/histoire.png"), ("-logique", "/back/logique.png")),
+ out var hadNoAvailableBack, out var usedFallback);
+
+ back.Should().Be("/back/histoire.png");
+ hadNoAvailableBack.Should().BeFalse();
+ usedFallback.Should().BeTrue();
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/ImageFileGenerator.cs b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/ImageFileGenerator.cs
index a5376da1..a44a3195 100644
--- a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/ImageFileGenerator.cs
+++ b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/ImageFileGenerator.cs
@@ -182,38 +182,65 @@ private static void AssembleCurrentCardImages(CardSetDocumentConfig configDocume
return;
}
- if (backImages.Count == 0)
+ currentCard.Back = ResolveCardBack(faceKey, backImages, out var hadNoAvailableBack, out var usedFallback);
+ if (hadNoAvailableBack)
{
Logger.LogWarning($"No back images available for face '{faceKey}'. Adding face only.");
- targetList.Add(currentCard);
- return;
+ }
+ else if (usedFallback)
+ {
+ Logger.LogWarning($"No matching back found for face '{faceKey}'. Using first available back.");
}
- if (backImages.Count == 1)
+ targetList.Add(currentCard);
+ }
+
+ ///
+ /// Pure, deterministic face→back matching contract (Golden Master, commit 0087f0ec).
+ /// Given a face key and the available back images (key = normalized back name, value = back
+ /// image path), returns the back image path to pair behind the face, or null when no back is
+ /// available. Branches:
+ /// - No backs at all → null (face only).
+ /// - Exactly one back → that back for every face.
+ /// - Multiple backs → the back whose (lower-cased) key is contained in the (lower-cased) face
+ /// key, choosing the LONGEST matching key first to avoid partial-match collisions; if none
+ /// matches, falls back to the first available back.
+ /// The / out flags let
+ /// the caller emit its existing diagnostic warnings without duplicating the branch logic.
+ /// Extracted output-neutral from (#204) — the assembled
+ /// gets the exact same Front/Back pair as before.
+ ///
+ public static string ResolveCardBack(string faceKey, IReadOnlyDictionary backImages,
+ out bool hadNoAvailableBack, out bool usedFallback)
+ {
+ hadNoAvailableBack = false;
+ usedFallback = false;
+
+ if (backImages.Count == 0)
{
- currentCard.Back = backImages.Values.First();
+ hadNoAvailableBack = true;
+ return null;
}
- else
+
+ if (backImages.Count == 1)
{
- // Golden Master matching: find back whose name is contained in the face key
- var faceKeyLower = faceKey.ToLowerInvariant();
- var targetBackName = backImages.Keys
- .OrderByDescending(bn => bn.Length) // Match longest name first to avoid partial matches
- .FirstOrDefault(bn => faceKeyLower.Contains(bn.ToLowerInvariant()));
+ return backImages.Values.First();
+ }
- if (targetBackName != null)
- {
- currentCard.Back = backImages[targetBackName];
- }
- else
- {
- // Fallback: use first back
- currentCard.Back = backImages.Values.First();
- Logger.LogWarning($"No matching back found for face '{faceKey}'. Using first available back.");
- }
+ // Golden Master matching: find back whose name is contained in the face key
+ var faceKeyLower = faceKey.ToLowerInvariant();
+ var targetBackName = backImages.Keys
+ .OrderByDescending(bn => bn.Length) // Match longest name first to avoid partial matches
+ .FirstOrDefault(bn => faceKeyLower.Contains(bn.ToLowerInvariant()));
+
+ if (targetBackName != null)
+ {
+ return backImages[targetBackName];
}
- targetList.Add(currentCard);
+ // Fallback: use first back
+ usedFallback = true;
+ return backImages.Values.First();
}