diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PdfAuditorExpectedOrderContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PdfAuditorExpectedOrderContractTests.cs
new file mode 100644
index 000000000..9e7f74d8c
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PdfAuditorExpectedOrderContractTests.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Argumentum.AssetConverter;
+// PdfAuditor is a static class whose namespace shares its name (Argumentum.AssetConverter.PdfAuditor),
+// so a plain PdfAuditor.X call resolves X against the NAMESPACE, not the class. `using static` imports
+// the class's static members directly, letting us call BuildExpectedImageOrder unqualified.
+using static Argumentum.AssetConverter.PdfAuditor.PdfAuditor;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.WebBasedGenerator
+{
+ ///
+ /// Contract pin for — #204 secondary
+ /// (cont. po-2024): the PDF-audit expected-image-order contract.
+ ///
+ /// The hashes every image embedded in a rendered recto-verso PDF and
+ /// compares them, IN ORDER, against the sequence of expected image paths built from the deck. That
+ /// expected sequence must mirror exactly what the renderer ()
+ /// placed on the sheet: for each page-sized chunk of cards, the BACKS come first — per grid ROW
+ /// reversed, so they line up behind their fronts on a horizontal flip — then the FRONTS in natural
+ /// order. If the audit's expected order ever drifts from the renderer's actual order, the audit
+ /// reports false mismatches (or false passes) with no signal beyond the PDF render — a silent
+ /// corruption of the only automated correctness check on the printed sheets.
+ ///
+ /// The per-row back reversal now calls the SAME method the renderer uses
+ /// (, pinned by
+ /// ). Previously
+ /// re-implemented that reversal inline (ToJaggedArray/Reverse/Flatten) with only a code
+ /// comment ("must match PdfManager exactly") guarding the duplication — a change to the renderer's
+ /// reversal would have silently desynchronized the audit. Extracted output-neutral into
+ /// (the File.Exists filter stays at the
+ /// call site) so the ordering contract is unit-testable in isolation, without a PDF render.
+ ///
+ public class PdfAuditorExpectedOrderContractTests
+ {
+ ///
+ /// Builds N cards C0..C(N-1) where card Ci has Front = "F{i}" and Back = "B{i}". Fronts and
+ /// backs share the index so the expected interleaving is readable in assertions.
+ ///
+ private static List Cards(int count) =>
+ Enumerable.Range(0, count)
+ .Select(i => new CardImages { Front = $"F{i}", Back = $"B{i}" })
+ .ToList();
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (1) THE HEADLINE — a single full page with backs. The 6 cards fit one 3-column page
+ // (2 rows × 3 cols). Backs come first, per-row reversed ([B0,B1,B2]→[B2,B1,B0] and
+ // [B3,B4,B5]→[B5,B4,B3] → [B2,B1,B0,B5,B4,B3]), then the fronts in natural order.
+ // A regression that emitted backs UN-reversed would yield [B0..B5] here; one that
+ // mirrored the whole back array would yield [B5,B4,B3,B2,B1,B0]. Both rejected.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SingleFullPage_BacksRowReversed_ThenFronts()
+ {
+ var cards = Cards(6); // one page: 2 rows × 3 cols, nbCardsPerPage = 6
+
+ var result = BuildExpectedImageOrder(cards, nbCardsPerPage: 6, nbColumns: 3, noBack: false)
+ .ToList();
+
+ result.Should().Equal(
+ new[] { "B2", "B1", "B0", "B5", "B4", "B3", // backs, each 3-wide row reversed
+ "F0", "F1", "F2", "F3", "F4", "F5" }, // fronts, natural order
+ "backs precede fronts, and each grid row of backs is reversed so they align with " +
+ "their fronts on a horizontal flip — the same contract the renderer applies.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (2) Trailing short page — 8 cards over two 6-card pages. Page 0 holds C0..C5 (full),
+ // page 1 holds C6,C7 (short). Each page's backs are reversed WITHIN their rows; the
+ // short page's lone row [B6,B7] reverses to [B7,B6]. Pins that the per-page chunking
+ // interleaves backs-then-fronts correctly across page boundaries.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TwoPages_EachPageBacksReversedThenFronts_ShortLastPage()
+ {
+ var cards = Cards(8); // page 0: C0..C5 (full 6), page 1: C6,C7 (short)
+
+ var result = BuildExpectedImageOrder(cards, nbCardsPerPage: 6, nbColumns: 3, noBack: false)
+ .ToList();
+
+ result.Should().Equal(
+ new[] { "B2", "B1", "B0", "B5", "B4", "B3", // page 0 backs (full)
+ "F0", "F1", "F2", "F3", "F4", "F5", // page 0 fronts
+ "B7", "B6", // page 1 backs (short row [B6,B7]→[B7,B6])
+ "F6", "F7" }, // page 1 fronts
+ "each page-sized chunk emits its backs (per-row reversed) then its fronts, so the " +
+ "page boundary is respected and the short last page reverses within its lone row.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (3) noBack = true — faces ship alone (no backs at all). The sequence is just the fronts
+ // in natural order, regardless of column count. Pins the NoBack branch so a regression
+ // that still emitted backs (or emitted them for the wrong pages) is caught.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NoBack_FrontsOnly_NaturalOrder()
+ {
+ var cards = Cards(6);
+
+ var result = BuildExpectedImageOrder(cards, nbCardsPerPage: 6, nbColumns: 3, noBack: true)
+ .ToList();
+
+ result.Should().Equal(
+ new[] { "F0", "F1", "F2", "F3", "F4", "F5" },
+ "when the deck has no backs, every page emits only its fronts in natural order — no " +
+ "reversal, no interleaving, the column count is irrelevant.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (4) Single column — a 1-wide grid reverses each single-element row to itself, so the
+ // backs come out in natural order (B0,B1,...) followed by fronts. Pins that the audit
+ // degrades correctly for single-column sheets rather than doing something surprising.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SingleColumn_BacksUnchangedThenFronts()
+ {
+ var cards = Cards(3);
+
+ var result = BuildExpectedImageOrder(cards, nbCardsPerPage: 3, nbColumns: 1, noBack: false)
+ .ToList();
+
+ result.Should().Equal(
+ new[] { "B0", "B1", "B2", "F0", "F1", "F2" },
+ "a 1-wide grid has no within-row ordering to swap, so each single-element back row " +
+ "reverses to itself and the backs come out in natural order before the fronts.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (5) SYNC WITH THE RENDERER — the per-row back reversal the audit builds must equal the
+ // EXACT sequence produces
+ // for the same page (the method both sides now call). This is the anti-drift guarantee:
+ // if someone changes the renderer's reversal, this test fails unless the audit changes
+ // with it. Asserted page-by-page rather than across the whole deck.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void BacksOrder_EqualsRendererReorderBacksForRectoVerso_PerPage()
+ {
+ const int cols = 3;
+ const int perPage = 6;
+ var cards = Cards(perPage * 2); // two full pages
+
+ var all = BuildExpectedImageOrder(cards, perPage, cols, noBack: false).ToList();
+
+ // Each page contributes `perPage` backs then `perPage` fronts.
+ for (int page = 0; page < 2; page++)
+ {
+ var pageCards = cards.Skip(page * perPage).Take(perPage).ToList();
+ var rendererBacks = PrintAndPlayDocument.ReorderBacksForRectoVerso(pageCards, cols)
+ .Select(c => c?.Back);
+ var auditBacks = all.Skip(page * (perPage * 2)).Take(perPage);
+
+ auditBacks.Should().Equal(rendererBacks,
+ $"page {page}: the audit's back sequence must equal the renderer's " +
+ $"ReorderBacksForRectoVerso output for the same page — they share the same method, " +
+ $"so any drift is a contract violation, not a coincidence.");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (6) Degenerate input — an empty deck must produce an empty sequence, not throw. Guards
+ // against a NullReference or index error in the chunk/reorder chain when the audit is
+ // handed a deck with no cards.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void EmptyDeck_ReturnsEmpty_NoCrash()
+ {
+ var result = BuildExpectedImageOrder(
+ Array.Empty(), nbCardsPerPage: 6, nbColumns: 3, noBack: false).ToList();
+
+ result.Should().BeEmpty("an empty deck has nothing to order and the audit must not throw.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (7) Null backs are carried through as null — the real GetExpectedImageOrder filters them
+ // (with !string.IsNullOrEmpty) at the boundary, but BuildExpectedImageOrder itself is
+ // agnostic: a card with no back yields null in the back slot, preserving position so the
+ // front still lands at the right offset. Pins the c?.Back null-propagation contract.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NullBack_YieldsNullInBackSlot_PreservesPosition()
+ {
+ // 3 cards in 1 page, 3 cols: the middle card C1 has NO back (Back = null).
+ var cards = new List
+ {
+ new() { Front = "F0", Back = "B0" },
+ new() { Front = "F1", Back = null },
+ new() { Front = "F2", Back = "B2" },
+ };
+
+ var result = BuildExpectedImageOrder(cards, nbCardsPerPage: 3, nbColumns: 3, noBack: false)
+ .ToList();
+
+ // Row [B0,null,B2] reverses to [B2,null,B0]; then fronts F0,F1,F2.
+ result.Should().Equal(
+ new[] { "B2", null, "B0", "F0", "F1", "F2" },
+ "a null back propagates through the reversal at its row position (the card has no back " +
+ "art) and the front still lands at the correct offset — the boundary filter later " +
+ "drops the null, but the ordering contract must preserve position.");
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter/PdfAuditor/PdfAuditor.cs b/Generation/Converters/Argumentum.AssetConverter/PdfAuditor/PdfAuditor.cs
index d45e65dd3..2da23b365 100644
--- a/Generation/Converters/Argumentum.AssetConverter/PdfAuditor/PdfAuditor.cs
+++ b/Generation/Converters/Argumentum.AssetConverter/PdfAuditor/PdfAuditor.cs
@@ -90,22 +90,46 @@ private static List GetExpectedImageOrder(CardSetDocumentConfig docConfi
var nbRows = (int)(pageSize.Height / cardHeightPoints);
var nbCardsPerPage = nbRows * nbColumns;
- var pages = images.Chunk(nbCardsPerPage);
- var orderedPaths = new List();
+ // Pure sequence (backs-row-reversed-then-fronts, page by page), then the File.Exists filter
+ // is applied at the boundary only — so the ordering contract is unit-testable in isolation.
+ return BuildExpectedImageOrder(images, nbCardsPerPage, nbColumns, docConfig.NoBack)
+ .Where(p => !string.IsNullOrEmpty(p) && File.Exists(p))
+ .ToList();
+ }
- foreach (var pageCards in pages)
+ ///
+ /// Produces the expected image-path sequence the audit compares a rendered recto-verso PDF
+ /// against: for each page-sized chunk of cards, the BACKS come first (per-row reversed, via
+ /// ) then the FRONTS in natural
+ /// order. Pure & deterministic — no file I/O, no PDF render.
+ ///
+ /// Extracted output-neutral from so the audit's ordering
+ /// contract is unit-testable. The per-row reversal shares the EXACT same method the renderer
+ /// () uses — pinned by
+ /// PrintAndPlayRectoVersoContractTests — so the audit's expected order can never drift
+ /// from the renderer's actual order. Previously this was an inline duplicate
+ /// (ToJaggedArray/Reverse/Flatten) that "must match PdfManager exactly" by convention;
+ /// a change to the renderer's reversal would have silently desynchronized the audit, producing
+ /// false audit failures (or worse, false passes) with no signal beyond the PDF render.
+ ///
+ /// All card images of the deck, in face order.
+ /// Page grid capacity (rows × columns).
+ /// Grid column count, driving the per-row back reversal.
+ /// When true, backs are omitted (faces ship alone).
+ public static IEnumerable BuildExpectedImageOrder(
+ IEnumerable images, int nbCardsPerPage, int nbColumns, bool noBack)
+ {
+ foreach (var pageCards in images.Chunk(nbCardsPerPage))
{
- // Back page logic - must match PdfManager exactly
- if (!docConfig.NoBack)
+ if (!noBack)
{
- var backCardsArray = pageCards.ToJaggedArray(nbColumns).Select(row => row.Reverse().ToArray()).ToArray().Flatten();
- orderedPaths.AddRange(backCardsArray.Select(c => c?.Back));
+ var backCardsArray = PrintAndPlayDocument.ReorderBacksForRectoVerso(pageCards, nbColumns);
+ foreach (var back in backCardsArray)
+ yield return back?.Back;
}
-
- // Front page logic
- orderedPaths.AddRange(pageCards.Select(c => c.Front));
+ foreach (var card in pageCards)
+ yield return card.Front;
}
- return orderedPaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToList();
}
private static string ComputeFileHash(string filePath)