diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayRectoVersoContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayRectoVersoContractTests.cs new file mode 100644 index 00000000..0bc1c586 --- /dev/null +++ b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayRectoVersoContractTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Argumentum.AssetConverter; +using FluentAssertions; +using Xunit; + +namespace Argumentum.AssetConverter.Tests.WebBasedGenerator +{ + /// + /// Contract pin for — dispatch #204 + /// tertiaire / γ (cont. po-2024). + /// + /// The Print & Play PDF () prints card backs on the reverse of + /// each sheet so a sheet can be flipped to read the back. To make each back line up behind its + /// matching front when the sheet is turned along its horizontal edge, the backs of EACH GRID ROW + /// are reversed before rendering. This is a per-row reversal of the back grid — NOT a full mirror + /// of the flat array — because a horizontal flip swaps left/right WITHIN each row while preserving + /// row order (row 0 stays row 0). In other words the back at output position (row, col) is the + /// original back at (row, nbColumns-1-col). + /// + /// This reordering was previously INLINED inside as the + /// composition backs.ToJaggedArray(nbColumns).Select(row => row.Reverse().ToArray()).ToArray().Flatten(), + /// with ZERO unit coverage — it could only be exercised by rendering a full QuestPDF document and + /// visually inspecting the back alignment. A regression here (e.g. reversing the WHOLE array + /// instead of per-row, or flipping rows too) produces printed sheets whose backs mismatch their + /// fronts — unusable sheets, only caught at print time. It has been extracted (output-neutral — + /// the call site preserves the exact computation) into the pure, deterministic + /// so the alignment contract is + /// unit-testable. These tests pin the contract additively. + /// + public class PrintAndPlayRectoVersoContractTests + { + /// Builds N labelled backs B0..B(N-1) for readable ordering assertions. + private static string[] Backs(int count) => + Enumerable.Range(0, count).Select(i => $"B{i}").ToArray(); + + // ───────────────────────────────────────────────────────────────────────────── + // (1) THE HEADLINE — the per-row reversal that aligns backs with fronts on a + // horizontal flip. Each row is reversed in place; row ORDER is preserved (row 0 + // stays row 0). For 6 backs in 3 columns: input rows [B0,B1,B2] and [B3,B4,B5] + // become [B2,B1,B0] and [B5,B4,B3], flattened to [B2,B1,B0,B5,B4,B3]. + // A regression that mirrors the WHOLE flat array would yield [B5,B4,B3,B2,B1,B0] + // (rows swapped) — the assertion below rejects both that and a no-op. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void FullRows_EvenlyDivisible_ReversesEachRow_PreservesRowOrder() + { + var backs = Backs(6); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 3); + + result.Should().Equal(new[] { "B2", "B1", "B0", "B5", "B4", "B3" }, + "each 3-wide row is reversed so backs align with fronts on a horizontal flip: " + + "[B0,B1,B2]→[B2,B1,B0] and [B3,B4,B5]→[B5,B4,B3]. Row order is preserved — NOT a full " + + "array mirror, which would wrongly yield [B5,B4,B3,B2,B1,B0]."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (2) Trailing short row — the last row has fewer cards than nbColumns. Its few + // cards are still reversed (a single-element row reverses to itself). The short + // row is NOT padded nor moved. 7 backs in 3 columns → the lone B6 on row 2 + // reverses to [B6] and stays at the tail. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void TrailingShortRow_LastRowReversed_SingletonStaysInPlace() + { + var backs = Backs(7); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 3); + + result.Should().Equal(new[] { "B2", "B1", "B0", "B5", "B4", "B3", "B6" }, + "the trailing short row [B6] is reversed to itself (a single-element row is its own " + + "reverse) and stays at the tail — it is neither dropped, padded, nor relocated."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (3) Single column — a horizontal flip over a single column is a no-op (each + // 1-wide row reverses to itself). This pins that the reordering correctly + // degrades to identity for 1-wide sheets, rather than doing something + // surprising with the lone element per row. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void SingleColumn_HorizontalFlipIsNoOp_IdentityOrder() + { + var backs = Backs(3); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 1); + + result.Should().Equal(new[] { "B0", "B1", "B2" }, + "a 1-wide sheet has no within-row ordering to swap, so a horizontal flip is an identity: " + + "each single-element row reverses to itself, and the output equals the input."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (4) One short row — nbColumns larger than the card count yields a single row, + // which is reversed wholesale. 2 backs in 5 columns → the lone row [B0,B1] is + // reversed to [B1,B0]. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void OneShortRow_ReversesWithinRow() + { + var backs = Backs(2); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 5); + + result.Should().Equal(new[] { "B1", "B0" }, + "when the whole page fits one short row, that row is reversed wholesale: [B0,B1]→[B1,B0]."); + } + + [Fact] + public void EvenRowWidthTwo_ReversesEachRow() + { + var backs = Backs(4); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 2); + + result.Should().Equal(new[] { "B1", "B0", "B3", "B2" }, + "two 2-wide rows [B0,B1] and [B2,B3] reverse to [B1,B0] and [B3,B2]."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (5) Degenerate inputs — empty backs and a lone back must not throw and must + // round-trip to empty / unchanged. Guards against an IndexOutOfRange or a + // NullReference in the jagged/reverse chain when a page has no backs. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void EmptyBacks_ReturnsEmpty_NoCrash() + { + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(Array.Empty(), nbColumns: 3); + + result.Should().BeEmpty("an empty back set has nothing to reorder and must not throw."); + } + + [Fact] + public void SingleBack_AnyColumnCount_Unchanged() + { + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(new[] { "B0" }, nbColumns: 3); + + result.Should().Equal(new[] { "B0" }, + "a lone card forms a single-element row that reverses to itself — there is nothing to " + + "flip it against, so it is unchanged."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (6) PRODUCTION ELEMENT TYPE GROUNDING — the reordering is generic; production calls + // it with byte[] (each card's PNG/JPEG bytes). Tagging each byte[] card with a + // single byte lets us assert the ordering without a deep image comparison. This + // proves the generic works for the actual production element type, not just string. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Works_WithProductionElementType_ByteArray() + { + byte[] Card(int n) => new byte[] { (byte)n }; + var backs = Enumerable.Range(0, 6).Select(Card).ToArray(); + + var result = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns: 3); + + // Same per-row-reversal contract as the string case: [0,1,2]→[2,1,0], [3,4,5]→[5,4,3]. + result.Select(b => (int)b[0]).Should().Equal(new[] { 2, 1, 0, 5, 4, 3 }, + "the generic reordering works for the production element type byte[] (card image bytes), " + + "applying the identical per-row reversal."); + } + + // ───────────────────────────────────────────────────────────────────────────── + // (7) THE ALIGNMENT SEMANTIC, stated directly. After reordering, the back at output + // position (row, col) is the back that was at INPUT position (row, nbColumns-1-col). + // This is the precise mirror-within-row that makes a horizontally-flipped sheet's + // backs line up with its fronts. Asserted by rebuilding the input and output grids + // and checking every cell — catches any deviation from within-row reversal. + // ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void AlignmentContract_OutputRowCol_EqualsInputRowMirroredCol() + { + const int cols = 3; + const int rows = 2; + var backs = Backs(rows * cols); // B0..B5 + + var output = PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, cols); + + // Re-jag both input and output into their grids for cell-wise comparison. + var inputGrid = backs.ToJaggedArray(cols); + var outputGrid = output.ToJaggedArray(cols); + + output.Length.Should().Be(backs.Length, "reordering preserves the total card count"); + outputGrid.Length.Should().Be(rows, "the output has the same row count as the input"); + + for (int row = 0; row < rows; row++) + { + for (int col = 0; col < cols; col++) + { + outputGrid[row][col].Should().Be(inputGrid[row][cols - 1 - col], + $"output[{row}][{col}] must equal input[{row}][{cols - 1 - col}] — the within-row " + + $"mirror that aligns backs with fronts on a horizontal flip"); + } + } + } + } +} diff --git a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs index 43244876..e6820eaf 100644 --- a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs +++ b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs @@ -68,7 +68,7 @@ public void Compose(IDocumentContainer container) // Back page — only render if at least one card on this page has a non-null back if (!_docConfig.NoBack && pageBackImages.Any(b => b != null)) { - var backCardsArray = pageBackImages.ToJaggedArray(nbColumns).Select(row => row.Reverse().ToArray()).ToArray().Flatten(); + var backCardsArray = ReorderBacksForRectoVerso(pageBackImages, nbColumns); container.Page(page => { ComposePage(page, pageSize, pageMarginMm, nbColumns, backCardsArray); @@ -86,6 +86,24 @@ public void Compose(IDocumentContainer container) } } + /// + /// Reorders back images for horizontal recto-verso printing. Each grid ROW of backs is + /// reversed so that, when the printed sheet is flipped along its horizontal edge (the way a + /// Print & Play sheet is turned to read the back), each back lines up behind its matching + /// front. This is a PER-ROW reversal of the back grid — not a full mirror of the flat array — + /// because a horizontal flip swaps left/right WITHIN each row while preserving row order + /// (row 0 stays row 0). In other words the back at output position (row, col) is the original + /// back at (row, nbColumns-1-col). + /// Extracted output-neutral from (the inline composition + /// backs.ToJaggedArray(nbColumns).Select(row => row.Reverse().ToArray()).ToArray().Flatten()) + /// so this fragile alignment contract is unit-testable in isolation, without a QuestPDF render. + /// + public static T[] ReorderBacksForRectoVerso(IList backs, int nbColumns) + => backs.ToJaggedArray(nbColumns) + .Select(row => row.Reverse().ToArray()) + .ToArray() + .Flatten(); + private void ComposePage(PageDescriptor page, PageSize pageSize, float pageMarginMm, int nbColumns, IEnumerable images) { page.Size(pageSize);