From d123fa547711f7c0e272cec4dd6b5b19ab1897b2 Mon Sep 17 00:00:00 2001 From: Your Date: Tue, 16 Jun 2026 16:12:26 +0200 Subject: [PATCH] test(printplay): #204 extract recto-verso back reorder to pure method + 9 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Print & Play PDF reverses each grid ROW of card backs before rendering so backs line up with fronts when the sheet is flipped along its horizontal edge. This alignment contract was inlined in PrintAndPlayDocument.Compose() with zero unit coverage — it could only be exercised by rendering a QuestPDF document and visually inspecting the back alignment. A regression (a full-array mirror instead of per-row, or flipping rows too) produces printed sheets whose backs mismatch their fronts — unusable sheets caught only at print time. Extracted output-neutral into the pure generic static PrintAndPlayDocument.ReorderBacksForRectoVerso(backs, nbColumns) (identical computation: ToJaggedArray -> reverse each row -> Flatten) and pinned the contract with 9 additive tests: - full rows (per-row reversal, row order preserved) - trailing short row (singleton reverses to itself, stays at tail) - single column (horizontal flip is identity) - one short row / width-2 / empty / single-back degenerate cases - byte[] grounding (production element type) - cell-wise alignment semantic: output[row][col] == input[row][cols-1-col] Full suite on clean master base: 291 passed / 0 failed / 5 skipped (no regression). Dispatch #204 gamma (cont. po-2024) — pivot sanctioned by dispatch qbw8vq ("sinon pivote PdfManager layout math"). Co-Authored-By: Claude Opus 4.6 --- .../PrintAndPlayRectoVersoContractTests.cs | 206 ++++++++++++++++++ .../WebBasedGenerator/PrintAndPlayDocument.cs | 20 +- 2 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayRectoVersoContractTests.cs 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 000000000..0bc1c586a --- /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 43244876f..e6820eaf8 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);