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)