diff --git a/.github/labeler.yml b/.github/labeler.yml index 8bd6b4266..925a33bd5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -30,6 +30,7 @@ figures: - scripts/generate_graphs.py - scripts/generate_diagrams.py - scripts/check_figures.py + - scripts/generated_assets.py - src/phonometry/_plot/** - src/phonometry/_plotting.py - requirements-figures.txt @@ -41,6 +42,13 @@ reports: - .github/reports/** - scripts/generate_reports.py - scripts/generate_site_reports.py + - scripts/check_reports.py + - scripts/generated_assets.py + - requirements-reports.txt + # The fiche job installs both requirement files, and the embedded + # plot comes off the matplotlib/NumPy stack pinned in this one, so a + # bump here can move the rendered PDF and its preview. + - requirements-figures.txt conformance: - changed-files: diff --git a/.github/reports/ebu_r128_loudness_example.pdf b/.github/reports/ebu_r128_loudness_example.pdf index 8c9a022c7..9d29c34e4 100644 Binary files a/.github/reports/ebu_r128_loudness_example.pdf and b/.github/reports/ebu_r128_loudness_example.pdf differ diff --git a/.github/reports/ebu_r128_loudness_example.webp b/.github/reports/ebu_r128_loudness_example.webp index ed458a94b..bbddaf2dd 100644 Binary files a/.github/reports/ebu_r128_loudness_example.webp and b/.github/reports/ebu_r128_loudness_example.webp differ diff --git a/.github/reports/iec61043_intensity_example.pdf b/.github/reports/iec61043_intensity_example.pdf new file mode 100644 index 000000000..00c7b39c3 Binary files /dev/null and b/.github/reports/iec61043_intensity_example.pdf differ diff --git a/.github/reports/iec61043_intensity_example.webp b/.github/reports/iec61043_intensity_example.webp new file mode 100644 index 000000000..846ea6c1d Binary files /dev/null and b/.github/reports/iec61043_intensity_example.webp differ diff --git a/.github/reports/iec61260_filter_1995_example.pdf b/.github/reports/iec61260_filter_1995_example.pdf index 4d3064dd2..661ccf101 100644 Binary files a/.github/reports/iec61260_filter_1995_example.pdf and b/.github/reports/iec61260_filter_1995_example.pdf differ diff --git a/.github/reports/iec61260_filter_1995_example.webp b/.github/reports/iec61260_filter_1995_example.webp index 82f9bd958..9940a61fe 100644 Binary files a/.github/reports/iec61260_filter_1995_example.webp and b/.github/reports/iec61260_filter_1995_example.webp differ diff --git a/.github/reports/iec61260_filter_example.pdf b/.github/reports/iec61260_filter_example.pdf index 332aa6d86..01289c828 100644 Binary files a/.github/reports/iec61260_filter_example.pdf and b/.github/reports/iec61260_filter_example.pdf differ diff --git a/.github/reports/iec61260_filter_example.webp b/.github/reports/iec61260_filter_example.webp index 36cdd65a8..24dbaf5c3 100644 Binary files a/.github/reports/iec61260_filter_example.webp and b/.github/reports/iec61260_filter_example.webp differ diff --git a/.github/reports/iso1996_tone_audibility_example.pdf b/.github/reports/iso1996_tone_audibility_example.pdf index 3cf1f2eca..049fe0b12 100644 Binary files a/.github/reports/iso1996_tone_audibility_example.pdf and b/.github/reports/iso1996_tone_audibility_example.pdf differ diff --git a/.github/reports/iso1996_tone_audibility_example.webp b/.github/reports/iso1996_tone_audibility_example.webp index 3bf9988b9..b55f219a9 100644 Binary files a/.github/reports/iso1996_tone_audibility_example.webp and b/.github/reports/iso1996_tone_audibility_example.webp differ diff --git a/.github/reports/iso1999_nipts_example.pdf b/.github/reports/iso1999_nipts_example.pdf index 27472a72a..c131e7e66 100644 Binary files a/.github/reports/iso1999_nipts_example.pdf and b/.github/reports/iso1999_nipts_example.pdf differ diff --git a/.github/reports/iso1999_nipts_example.webp b/.github/reports/iso1999_nipts_example.webp index ff83e1ccb..db857f644 100644 Binary files a/.github/reports/iso1999_nipts_example.webp and b/.github/reports/iso1999_nipts_example.webp differ diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 251b821e6..c8e75e4ae 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -242,6 +242,46 @@ jobs: # for the whole set) and needs no rendering stack of its own. run: python scripts/check_figure_contrast.py + # The committed example .report() fiches (.github/reports) must match a fresh + # `make reports` run. Same drift gate as the figures, one layer further down + # the pipeline: the fiches are what the documentation links to as worked + # examples of what the library prints, so a stale one publishes a wrong + # answer. Nothing checked them until this job existed, and two of them sat a + # plot-styling release behind the code for weeks. The rendering stack is + # pinned across both requirements files (matplotlib/numpy in -figures, + # svglib/reportlab/pypdfium2 in -reports) because every link in the chain + # fixes the rendered page the check compares. + reports: + name: Example report fiches up to date + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Set up Python 3.13 + uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: 'pip' + - name: Install the locked fiche-rendering stack + run: | + python -m pip install --upgrade pip + pip install -e . + # Pin the render+compute stack last so it wins over looser deps. + pip install -r requirements-figures.txt -r requirements-reports.txt + - name: Regenerate the example fiches + run: make reports + - name: Fail if any committed fiche is stale + # Tolerance-aware compare instead of a byte diff: GitHub's heterogeneous + # runner CPUs shift a few of the embedded plot's path coordinates ~1 ULP, + # which rewrites the PDF page stream for no visible reason. The script + # compares the extracted text exactly and the rendered page within a + # calibrated pixel tolerance, so a real fiche change fails while + # cross-CPU coordinate noise passes. See scripts/check_reports.py. + run: python scripts/check_reports.py + tests: runs-on: ${{ matrix.os }} permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 048edc53f..f61aa4090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -999,6 +999,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- CI fails when `.github/reports` no longer matches a fresh `make reports` + run, which is the reason the fiches were able to drift for weeks in the + first place: the conformance report, the generated API reference, the + evidence pages and the documentation figures all had a staleness gate, and + the fiches had none. The new `Example report fiches up to date` job + regenerates the whole set and compares it through + `scripts/check_reports.py`. A byte diff is not an option, for the same + reason it was abandoned for the figures: every fiche embeds its plot as + vector geometry, and GitHub's heterogeneous runners compute a few of those + coordinates a last bit apart, which rewrites the page for no visible reason. + So each fiche is compared on what it says and on what it looks like: the + extracted text exactly, page by page, and the committed preview within the + same two-criteria pixel tolerance the figures use, recalibrated for a + document page. The thresholds are 64 pixels and an RMS of 0.5, and the + reasoning behind them is written out in the script rather than summarised + here, because it is not the clean separation it first looked like: moving + every plotted coordinate by one unit in the last decimal, which is an upper + bound rather than a forecast, pushes five of the 67 fiches past those + thresholds, all of them plots whose bar edges sit on a pixel boundary. + Moving a single coordinate in the worst of them changes nothing at all, and + the coordinates in question come from exact layout arithmetic, so the bound + is not what a real machine does. Raising the RMS far enough to clear it + would stop catching the restyled fills, which is the failure the check + exists to prevent. The two stale fiches moved 28710 and 27629 pixels and the + subtler fills moved the RMS to 0.79 and 1.33, so those are caught. They are + tighter than the figures' on purpose: a fiche preview is a flat document + page rasterized by a pinned binary rasterizer, a much quieter thing to + compare than a plot raster, and the figures' looser bound would have let + the restyled fills through. The rendering stack the job runs is pinned in + `requirements-reports.txt` alongside the existing figure pins, `make + reports` now renders into a scratch directory and swaps it in only once the + whole set is written, so a fiche that is no longer produced is actually + removed without a generator that dies halfway leaving the working tree + stripped of the committed examples, and the generator pins the numerical + thread pools the way the figure generator does, so a runner with a + different core count cannot reorder a floating-point sum into the rendered + page. The staleness helpers the two checks share moved to + `scripts/generated_assets.py`. A unit test also asserts every registered + fiche has a committed render, which fails in the same second as the commit + that forgets one, with no regeneration needed. + - The figures now spell the decimal logarithm the way the pages around them do. Every axis label, legend entry, title, annotation box and diagram caption the generators write still carried the ISO `lg`, so a guide that @@ -1550,6 +1591,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- The committed example `.report()` fiches were not checked against the code + that writes them, and two of them had fallen behind it. `iso1999_nipts` and + `iso1996_tone_audibility` still carried the shaded regions as they were + drawn before fills started being derived from the page they sit on: the + fractile band was the old 50 % composite `#ffddbb` instead of the + `#ffe7d2` wash, and the decisive tone's critical band was the hard-coded + `#eaf0f8` instead of `#c8deed`. Nothing a reader would misread, since every + printed value and every verdict was already right, but the two fiches the + documentation offers as examples of what the library prints were showing a + paler band than the library prints. `ebu_r128_loudness` and the two + `iec61260_filter` fiches carried the same kind of drift a shade smaller. + All five are regenerated here, together with the `iec61043_intensity` + fiche, which had been registered in the generator and documented in the + intensity guide without its PDF and preview ever being committed; the guide + now embeds it in both languages, like every other worked example. + - The `` in every page's head pointed at a file that does not exist on 147 of them. It was written for `/index.md` on every page but the two splash pages, while the copies diff --git a/Makefile b/Makefile index e38265126..476d0efa4 100644 --- a/Makefile +++ b/Makefile @@ -132,11 +132,31 @@ lighthouse: cd site && pnpm run lighthouse # Regenerate the committed example .report() fiches under .github/reports/, -# which the documentation links to as rendered normative-report examples. -# Not byte-checked in CI (the embedded vector plot differs by ~1 ULP across -# CPUs); tests/test_generate_reports.py only checks the generator still works. +# which the documentation links to as rendered normative-report examples. CI +# fails if this drifts (see the `reports` job in python-app.yml). The compare +# is tolerance-aware rather than a byte diff, for the same reason the figures' +# is: the embedded vector plot differs by ~1 ULP across CPUs. See +# scripts/check_reports.py. +# +# Renders into a scratch directory and swaps it in only once the whole set is +# written. Clearing the output first, which is how this used to work, means a +# generator that dies halfway leaves the working tree stripped of the committed +# examples and the maintainer reaching for `git checkout`. The clearing itself +# has to stay, because the generator only overwrites and never deletes, so a +# fiche that is no longer produced would survive as a stale orphan and slip +# past the staleness check; it just belongs after a successful run rather than +# before an attempted one. The trap covers the interrupted run the same way. reports: - $(PYTHON) scripts/generate_reports.py + set -e; \ + tmp=$$(mktemp -d .github/reports.tmp.XXXXXX); \ + old=$$(mktemp -d .github/reports.old.XXXXXX); \ + trap 'if [ -d "$$old"/current ]; then rm -rf .github/reports; mv "$$old"/current .github/reports; fi; rm -rf "$$tmp" "$$old"' EXIT INT TERM HUP; \ + $(FIGURE_ENV) $(PYTHON) scripts/generate_reports.py --output-dir "$$tmp"; \ + [ -n "$$(ls -A "$$tmp")" ] || { echo "no fiche was generated" >&2; exit 1; }; \ + mv .github/reports "$$old"/current; \ + mv "$$tmp" .github/reports; \ + mv "$$old"/current "$$old"/replaced; \ + rm -rf "$$old" # Regenerate the committed, versioned numerical conformance report, then bring # every count quoted from it into line. The --file-header flag prepends the diff --git a/docs/intensity.md b/docs/intensity.md index 8424a1bfe..a5a2f6be7 100644 --- a/docs/intensity.md +++ b/docs/intensity.md @@ -394,6 +394,11 @@ EN/IEC text (see the [errata registry](ERRATA.md)). Clause 8 combines separately `instrument_class_from_components(probe_class, processor_class)` returns 1 only when both are class 1, and 2 for every other pairing. +The example fiche, regenerated with `make reports`, is kept rendered in the +repository. Click the preview to open the PDF: + +[![One-page instrument-class-verification fiche: a metadata header, a per-band table listing the class 1 and class 2 minima, the measured residual index, the margin and the class achieved in each one-third-octave band from 50 Hz to 6.3 kHz, the measured index drawn as a step curve over the two Table 2 masks with the 100 Hz band ringed below the class 1 minimum, the boxed Class 2 - COMPLIES (binding margin +4.20 dB) result, the microphone separation and equivalent phase mismatch, and a FAIL verdict against the required class 1](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.pdf) + ### Reading `δpI0` as a phase error The requirement is really a phase-matching requirement in disguise. In an diff --git a/requirements-reports.txt b/requirements-reports.txt new file mode 100644 index 000000000..16c8f54a9 --- /dev/null +++ b/requirements-reports.txt @@ -0,0 +1,23 @@ +# Locked fiche-rendering stack for the reports CI check (the "Example report +# fiches up to date" job in python-app.yml). Install it on top of +# requirements-figures.txt, which pins the matplotlib/numpy half of the chain. +# +# A fiche goes matplotlib -> SVG -> svglib -> reportlab -> PDF -> pypdfium2 -> +# WebP preview, and the staleness check compares the rendered page. Every link +# after matplotlib therefore fixes what the check sees: svglib and its CSS/XML +# parsers decide how the SVG becomes vector graphics, reportlab lays out the +# page and embeds the fonts, and pypdfium2 rasterizes it. Cross-CPU ~1-ULP +# coordinate drift is absorbed by the tolerance in scripts/check_reports.py, so +# the stack does not have to byte-match the runner hardware, but a version bump +# in any of these moves the rendered pixels well past that tolerance. +# +# Bump these together with a fresh `make reports` regeneration (generate the +# fiches with exactly this stack, then commit both). Generated from the +# maintainer's venv that produced the committed fiches. +reportlab==5.0.0 +svglib==2.0.2 +pypdfium2==5.12.1 +lxml==6.1.1 +cssselect2==0.9.0 +tinycss2==1.5.1 +webencodings==0.5.1 diff --git a/scripts/check_figures.py b/scripts/check_figures.py index fe1da6ec7..9224e4468 100644 --- a/scripts/check_figures.py +++ b/scripts/check_figures.py @@ -17,14 +17,10 @@ must be identical, and every numeric token must agree within an absolute or relative tolerance. A moved element, changed label or new path fails; a last-bit coordinate wobble passes. -* **Raster (WebP/PNG)** -- identical dimensions, and *both* (a) at most - ``RASTER_MAX_SIG_PIXELS`` pixels whose per-channel difference exceeds - ``RASTER_LEVEL_TOL`` and (b) a per-pixel root-mean-square difference below - ``RASTER_RMS_TOL``. The pixel count - catches a *localised* change (a moved line, a relabelled axis) that a global - RMS would dilute in a large image; the RMS catches a *broad* change (a - recoloured background) that few-but-everywhere pixels would slip past the - count. Cross-CPU sub-ULP coordinate drift changes neither meaningfully. +* **Raster (WebP/PNG)** -- identical dimensions, at most + :data:`RASTER_TOL`\\ ``.max_sig_pixels`` meaningfully changed pixels and a + bounded root-mean-square difference (see + :class:`~generated_assets.RasterTolerance`). * **Anything else** -- exact byte compare. Added or removed files always fail: a new figure must be committed, and a @@ -37,14 +33,20 @@ from __future__ import annotations -import io import re -import subprocess import sys from pathlib import Path -import numpy as np -from PIL import Image +_SCRIPTS = Path(__file__).resolve().parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +from generated_assets import ( + RasterTolerance, + committed_bytes, + raster_problem, + tracked_files, +) IMG_DIR = ".github/images" @@ -55,18 +57,12 @@ SVG_REL_TOL = 1e-4 # A pixel counts as "meaningfully changed" if any channel differs by more than -# RASTER_LEVEL_TOL (0..255). Cross-CPU drift perturbs a coordinate by ~1e-6 units, +# ``level`` (0..255). Cross-CPU drift perturbs a coordinate by ~1e-6 units, # i.e. a ~1e-5-pixel geometric shift, whose anti-aliasing effect rounds to at -# most a level or two on a few edge pixels -- far below this threshold. -RASTER_LEVEL_TOL = 12 -# Allowed number of meaningfully-changed pixels. A real edit moves a plotted -# line or glyph, changing hundreds to thousands of edge pixels; noise changes -# a handful at most. -RASTER_MAX_SIG_PIXELS = 100 -# Broad-change guard: maximum root-mean-square per-channel difference. Catches -# a change spread thinly over the whole image (e.g. a recoloured background) -# that stays individually under RASTER_LEVEL_TOL. -RASTER_RMS_TOL = 2.0 +# most a level or two on a few edge pixels -- far below this threshold. A real +# edit moves a plotted line or glyph, changing hundreds to thousands of edge +# pixels, and a restyled fill moves the whole image past the RMS bound. +RASTER_TOL = RasterTolerance(level=12, max_sig_pixels=100, rms=2.0) # Integers and decimals, with optional sign and exponent. ``split``/``findall`` # with this pattern partition a file into fixed text and numeric values. @@ -84,27 +80,6 @@ _IDREF = re.compile(rb'(\bid="|url\(#|xlink:href="#|\bhref="#)([A-Za-z_][\w.:\-]*)') -def _committed(path: str) -> bytes | None: - """Return the bytes of ``path`` as committed at HEAD, or ``None``.""" - result = subprocess.run( - ["git", "show", f"HEAD:{path}"], - capture_output=True, - check=False, - ) - return result.stdout if result.returncode == 0 else None - - -def _tracked_files() -> set[str]: - """Return the set of ``.github/images`` paths tracked at HEAD.""" - result = subprocess.run( - ["git", "ls-tree", "-r", "--name-only", "HEAD", IMG_DIR], - capture_output=True, - text=True, - check=True, - ) - return {line for line in result.stdout.splitlines() if line} - - def _canonicalize_ids(data: bytes) -> bytes: """Rewrite hash-derived ids/references to first-appearance placeholders.""" mapping: dict[bytes, bytes] = {} @@ -136,26 +111,9 @@ def _svg_within_tolerance(old: bytes, new: bytes) -> bool: return True -def _raster_problem(old: bytes, new: bytes) -> str | None: - """Describe how two rasters differ beyond tolerance, or ``None`` if within it.""" - a = np.asarray(Image.open(io.BytesIO(old)).convert("RGBA"), dtype=np.float64) - b = np.asarray(Image.open(io.BytesIO(new)).convert("RGBA"), dtype=np.float64) - if a.shape != b.shape: - return f"dimensions changed {a.shape} != {b.shape}" - diff = np.abs(a - b) - sig_pixels = int(np.count_nonzero(diff.max(axis=-1) > RASTER_LEVEL_TOL)) - if sig_pixels > RASTER_MAX_SIG_PIXELS: - return (f"{sig_pixels} pixels changed by >{RASTER_LEVEL_TOL} " - f"(> {RASTER_MAX_SIG_PIXELS})") - rms = float(np.sqrt(np.mean(diff**2))) - if rms > RASTER_RMS_TOL: - return f"RMS {rms:.3f} > {RASTER_RMS_TOL}" - return None - - def main() -> int: disk = {str(p) for p in Path(IMG_DIR).rglob("*") if p.is_file()} - tracked = _tracked_files() + tracked = tracked_files(IMG_DIR) problems: list[str] = [] for path in sorted(disk - tracked): @@ -165,7 +123,7 @@ def main() -> int: for path in sorted(disk & tracked): new = Path(path).read_bytes() - old = _committed(path) + old = committed_bytes(path) if old is None: problems.append(f"cannot read committed {path}") continue @@ -175,7 +133,7 @@ def main() -> int: if not _svg_within_tolerance(old, new): problems.append(f"SVG changed beyond tolerance: {path}") elif path.endswith((".webp", ".png")): - reason = _raster_problem(old, new) + reason = raster_problem(old, new, RASTER_TOL) if reason is not None: problems.append(f"raster changed beyond tolerance ({reason}): {path}") else: diff --git a/scripts/check_reports.py b/scripts/check_reports.py new file mode 100644 index 000000000..afecfcd1c --- /dev/null +++ b/scripts/check_reports.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""Tolerance-aware staleness check for the committed ``.report()`` fiches. + +The ``Example report fiches up to date`` CI job regenerates ``.github/reports`` +with ``make reports`` and must confirm the result still matches what is +committed. Nothing used to check this, and the fiches drifted: two of them sat +a plot-styling release behind the library for weeks, and one was registered in +the generator without its rendered files ever being committed. The +documentation links to these PDFs as worked examples of what the library +prints, so a stale one is a wrong answer published under the maintainer's name. + +**Why not a byte diff.** Not for the usual PDF reason: the renderer asks +reportlab for an invariant document, so the creation date and document id are +fixed and two runs of unchanged code on one machine do produce identical +bytes. The reason is the one that made the figures job flaky before +:mod:`scripts.check_figures` replaced its byte diff with a tolerance. Every +fiche embeds its plot as vector geometry, and GitHub's runner fleet is +hardware-heterogeneous, so the same pinned stack computes a handful of those +coordinates ~1 ULP apart depending on which CPU microarchitecture the run +lands on. A byte gate would eventually fail on a difference no reader can see, +and a gate that cries wolf gets switched off. + +**What is compared instead**, per fiche: + +* **Extracted text** (``.pdf``) -- page count and the text of every page + must match exactly. This is the part of a fiche a reader quotes: the + measured values, the rating, the verdict, the standard clause. It carries no + floating-point noise, because every printed number is already rounded to the + decimals the standard reports. It does *not* cover the embedded plot, whose + labels are drawn as paths (``svg.fonttype='path'``), so it cannot stand + alone. +* **Rendered pixels** (``.webp``) -- the committed preview is the first + page of the committed PDF rasterized at a fixed width, so comparing + previews compares the rendered page itself, sidestepping the PDF + metadata entirely. Every registered fiche is a single page, so this covers + the whole document, plot included. The comparison is the same two-criteria + raster tolerance the figures use (see + :class:`~generated_assets.RasterTolerance`), recalibrated in + :data:`RASTER_TOL` for a document page. + +Added or removed files always fail: a newly registered fiche must be +committed, and one that is no longer generated must be removed from the tree. + +Calibrating :data:`RASTER_TOL` +------------------------------ + +matplotlib writes SVG coordinates through ``%f``, so one unit in the last +emitted decimal (1e-6 user units) is the largest text change a last-bit +coordinate difference can produce; anything smaller does not reach the file at +all. Displacing *every* plotted path coordinate by that amount, in a random +direction, is therefore an upper bound on what a coordinate difference could +do, and far worse than reality, where at most a handful of coordinates drift. + +Measured that way across all 67 fiches, the result is not a clean separation. +Most sit near zero, but five reach 235 to 1424 pixels and RMS 0.68 to 1.43, +above the thresholds below. Those are fiches whose plots have vertical bar +edges landing on a pixel boundary, where moving the whole edge by one unit in +the last decimal flips entire columns at once. + +The thresholds are set anyway, for a reason worth stating plainly: that upper +bound is not what happens. Displacing a *single* coordinate in the worst of +those five changed no pixels at all across sixty trials, because a coordinate +only re-renders differently if its float lands within about one unit in the +last place of a rounding boundary six decimals down, and the coordinates that +flip a bar edge come from exact layout arithmetic rather than from libm or +BLAS. If the SVG text does not change, the PDF and its raster are identical. +No cross-machine evidence exists either way: everything here was measured by +simulating displacement on one machine, not by rendering on two. + +What the thresholds do catch is the drift this check was written for: 28710 +and 27629 pixels on the two stale fiches, and RMS 0.79 to 1.33 on the subtler +restyled fills that came with them. Raising the RMS bound far enough to clear +the simulated worst case would stop catching those fills, which is the failure +this check exists to prevent, so the bound stays where it is. + +The pixel count is the weaker of the two criteria. One character changed in a +printed field moves 53 to 102 pixels, so most single-character edits pass it +and only the RMS sees them, by a margin as small as 7 percent. That is +acceptable because the PDF text is compared exactly: every printed value, +verdict and margin is covered whatever the raster does, and the raster +criteria only have to catch a changed plot. + +They are tighter than the figures' (100 pixels, RMS 2.0) on purpose: a fiche +preview is a flat, opaque document page rasterized from a PDF by a pinned +binary rasterizer, which is a markedly quieter thing to compare than a plot +raster. Keeping the figures' looser RMS here would let a restyled fill through +-- which is precisely how the two stale fiches went unnoticed. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPTS = Path(__file__).resolve().parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +from generated_assets import ( + RasterTolerance, + committed_bytes, + raster_problem, + tracked_files, +) + +REPORT_DIR = ".github/reports" + +#: Raster tolerance for the WebP previews. ``level`` matches the figures': a +#: per-channel difference of 12 (of 255) is far above the level or two that +#: anti-aliasing can wobble by, and far below a moved line or a restyled fill. +#: ``max_sig_pixels`` and ``rms`` are calibrated in the module docstring. +RASTER_TOL = RasterTolerance(level=12, max_sig_pixels=64, rms=0.5) + + +def pdf_pages_text(data: bytes) -> list[str]: + """Return the extracted text of every page of the PDF in ``data``. + + Uses the same rasterizer the generator previews with, so the check needs + no PDF library the fiche pipeline does not already pull in. + """ + import pypdfium2 as pdfium + + document = pdfium.PdfDocument(data) + try: + return [page.get_textpage().get_text_bounded() for page in document] + finally: + document.close() + + +def pdf_problem(old: bytes, new: bytes) -> str | None: + """Describe how two fiche PDFs differ in text, or ``None`` if they do not.""" + try: + old_pages = pdf_pages_text(old) + new_pages = pdf_pages_text(new) + except Exception as exc: # noqa: BLE001 - a corrupt fiche is a failure, not a crash + return f"could not extract text ({exc})" + if len(old_pages) != len(new_pages): + return f"page count changed {len(old_pages)} != {len(new_pages)}" + for number, (old_text, new_text) in enumerate(zip(old_pages, new_pages), start=1): + if old_text != new_text: + return f"text of page {number} changed" + return None + + +def _problems() -> list[str]: + """Collect every way the committed fiches differ from the regenerated ones.""" + disk = {str(p) for p in Path(REPORT_DIR).rglob("*") if p.is_file()} + tracked = tracked_files(REPORT_DIR) + problems: list[str] = [] + + for path in sorted(disk - tracked): + problems.append(f"new fiche not committed: {path}") + for path in sorted(tracked - disk): + problems.append(f"committed fiche no longer generated: {path}") + + for path in sorted(disk & tracked): + new = Path(path).read_bytes() + old = committed_bytes(path) + if old is None: + problems.append(f"cannot read committed {path}") + continue + if path.endswith(".webp"): + if old == new: + continue # byte-identical: fast path, no tolerance needed + reason = raster_problem(old, new, RASTER_TOL) + if reason is not None: + problems.append(f"preview changed beyond tolerance ({reason}): {path}") + elif path.endswith(".pdf"): + # Never byte-compared: a cross-CPU coordinate wobble in the + # embedded plot rewrites the page stream for no visible reason. + reason = pdf_problem(old, new) + if reason is not None: + problems.append(f"fiche changed ({reason}): {path}") + else: + problems.append(f"unexpected file in {REPORT_DIR}: {path}") + return problems + + +def main() -> int: + """Report every stale fiche; return 1 if there is one.""" + problems = _problems() + if problems: + print( + f"::error::{REPORT_DIR} is out of date - " + "run 'make reports' and commit the result." + ) + for problem in problems: + print(f" - {problem}") + return 1 + + fiches = len(list(Path(REPORT_DIR).glob("*.pdf"))) + print(f"All {fiches} committed fiches match within tolerance.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_reports.py b/scripts/generate_reports.py index 17d3ce7e8..59babfc31 100644 --- a/scripts/generate_reports.py +++ b/scripts/generate_reports.py @@ -13,19 +13,31 @@ document pages compress better lossless than lossy). Run it with ``make reports`` or ``python scripts/generate_reports.py``. -Neither the PDFs nor the WebP previews are byte-checked in CI: the embedded +Neither the PDFs nor the WebP previews are byte-compared in CI: the embedded plot is vector geometry whose floating-point coordinates differ by ~1 ULP -across CPUs (and the raster inherits that), so a byte comparison would be as -flaky as the figure pipeline was before its tolerance check (which cannot apply -to a binary PDF). They are regenerated by the maintainer instead; -``tests/test_generate_reports.py`` only asserts the generator still produces a -valid one-page PDF and a valid WebP preview for each example. +across CPUs, and the raster inherits that. The ``Example report fiches up to +date`` job regenerates the set and compares it within a tolerance instead +(:mod:`scripts.check_reports`); the fiches went unchecked for months before +that job existed, and two of them fell a plot-styling release behind +unnoticed. """ from __future__ import annotations -import argparse import os + +# Deterministic fiche output: pin every numerical thread pool to a single +# thread BEFORE numpy/scipy import their backends, so multi-threaded reductions +# cannot reorder floating-point sums and perturb the rendered plot across +# machines. The figure generator does the same, and for the same reason: the +# CI runner has a different core count than a dev box. +for _threads_var in ( + "OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", "NUMBA_NUM_THREADS", "VECLIB_MAXIMUM_THREADS", +): + os.environ.setdefault(_threads_var, "1") + +import argparse from collections.abc import Callable, Sequence from typing import Any @@ -3247,77 +3259,87 @@ def _duct_path_example() -> tuple[object, ReportMetadata, str]: return result, metadata, "duct_path_example.pdf" -#: Every example fiche the repository keeps rendered. New report kinds append -#: their factory here so ``make reports`` regenerates the full set. -_EXAMPLES: list[Callable[[], tuple[object, ReportMetadata, str]]] = [ - _airborne_example, - _impact_example, - _field_airborne_example, - _field_impact_example, - _lab_airborne_example, - _lab_impact_example, - _intensity_example, - _intensity_element_example, - _airborne_prediction_example, - _impact_prediction_example, - _facade_prediction_example, - _floor_covering_example, - _absorption_example, - _sound_absorption_example, - _impedance_tube_example, - _loudness_example, - _program_loudness_example, - _tone_audibility_example, - _impulse_prominence_example, - _wind_turbine_tonality_example, - _epnl_example, - _filter_class_example, - _filter_class_1995_example, - _intensity_class_example, - _iso4871_declaration_example, - _loudspeaker_example, - _microphone_example, - _occupational_exposure_example, - _human_vibration_example, - _nipts_example, - _htlan_example, - _room_acoustics_example, - _reverberation_prediction_example, - _enclosed_space_absorption_example, - _noise_criteria_example, - _room_criteria_example, - _open_plan_example, - _multiple_shock_example, - _mechanical_mobility_example, - _transfer_stiffness_example, - _sound_power_example, - _intensity_sound_power_example, - _reverberation_sound_power_example, - _vibration_sound_power_example, - _structure_borne_power_example, - _installed_structure_borne_example, - _scattering_example, - _diffusion_example, - _diffusion_polar_example, - _dynamic_stiffness_example, - _airflow_resistance_example, - _vibration_reduction_example, - _flanking_level_difference_example, - _flanking_impact_level_example, - _survey_airborne_example, - _survey_impact_example, - _survey_facade_example, - _field_facade_example, - _outdoor_attenuation_example, - _barrier_insertion_loss_example, - _sti_example, - _sii_example, - _enclosure_example, - _silencer_example, - _hvac_example, - _duct_path_example, - _rd1367_example, -] +#: Every example fiche the repository keeps rendered, from the file it writes +#: to the factory that builds it. New report kinds add an entry here so +#: ``make reports`` regenerates the full set. Keyed by filename so that the set +#: of rendered outputs can be read off the registry: naming it is what a caller +#: asking "is every registered fiche committed?" needs, and calling the factory +#: for it would run a full computation and build a plot for an answer already +#: written here. +_FICHES: dict[str, Callable[[], tuple[object, ReportMetadata, str]]] = { + "iso717_airborne_example.pdf": _airborne_example, + "iso717_impact_example.pdf": _impact_example, + "iso16283_airborne_example.pdf": _field_airborne_example, + "iso16283_impact_example.pdf": _field_impact_example, + "iso10140_airborne_example.pdf": _lab_airborne_example, + "iso10140_impact_example.pdf": _lab_impact_example, + "iso15186_intensity_example.pdf": _intensity_example, + "iso15186_element_example.pdf": _intensity_element_example, + "iso12354_airborne_prediction_example.pdf": _airborne_prediction_example, + "iso12354_impact_prediction_example.pdf": _impact_prediction_example, + "iso12354_facade_prediction_example.pdf": _facade_prediction_example, + "iso16251_floor_covering_example.pdf": _floor_covering_example, + "iso11654_absorption_example.pdf": _absorption_example, + "iso354_absorption_example.pdf": _sound_absorption_example, + "iso10534_impedance_tube_example.pdf": _impedance_tube_example, + "iso532_loudness_example.pdf": _loudness_example, + "ebu_r128_loudness_example.pdf": _program_loudness_example, + "iso1996_tone_audibility_example.pdf": _tone_audibility_example, + "ntacou112_impulse_prominence_example.pdf": _impulse_prominence_example, + "iec61400_wind_turbine_tonality_example.pdf": _wind_turbine_tonality_example, + "icao_epnl_example.pdf": _epnl_example, + "iec61260_filter_example.pdf": _filter_class_example, + "iec61260_filter_1995_example.pdf": _filter_class_1995_example, + "iec61043_intensity_example.pdf": _intensity_class_example, + "iso4871_declaration_example.pdf": _iso4871_declaration_example, + "iec60268_5_loudspeaker_example.pdf": _loudspeaker_example, + "iec60268_4_microphone_example.pdf": _microphone_example, + "iso9612_exposure_example.pdf": _occupational_exposure_example, + "human_vibration_example.pdf": _human_vibration_example, + "iso1999_nipts_example.pdf": _nipts_example, + "iso1999_htlan_example.pdf": _htlan_example, + "iso3382_room_acoustics_example.pdf": _room_acoustics_example, + "reverberation_prediction_example.pdf": _reverberation_prediction_example, + "enclosed_space_absorption_example.pdf": _enclosed_space_absorption_example, + "ansi_s12_2_noise_criteria_example.pdf": _noise_criteria_example, + "ansi_s12_2_room_criteria_example.pdf": _room_criteria_example, + "iso3382_3_open_plan_example.pdf": _open_plan_example, + "iso2631_5_multiple_shock_example.pdf": _multiple_shock_example, + "iso7626_mobility_example.pdf": _mechanical_mobility_example, + "iso10846_transfer_stiffness_example.pdf": _transfer_stiffness_example, + "iso3744_sound_power_example.pdf": _sound_power_example, + "iso9614_sound_power_intensity_example.pdf": _intensity_sound_power_example, + "iso3741_reverberation_power_example.pdf": _reverberation_sound_power_example, + "iso7849_vibration_power_example.pdf": _vibration_sound_power_example, + "en15657_structure_borne_power_example.pdf": _structure_borne_power_example, + "en12354_5_installed_structure_borne_example.pdf": _installed_structure_borne_example, + "iso17497_scattering_example.pdf": _scattering_example, + "iso17497_diffusion_example.pdf": _diffusion_example, + "iso17497_diffusion_polar_example.pdf": _diffusion_polar_example, + "en29052_dynamic_stiffness_example.pdf": _dynamic_stiffness_example, + "iso9053_airflow_resistance_example.pdf": _airflow_resistance_example, + "iso10848_kij_example.pdf": _vibration_reduction_example, + "iso10848_dnf_example.pdf": _flanking_level_difference_example, + "iso10848_lnf_example.pdf": _flanking_impact_level_example, + "iso10052_airborne_example.pdf": _survey_airborne_example, + "iso10052_impact_example.pdf": _survey_impact_example, + "iso10052_facade_example.pdf": _survey_facade_example, + "iso16283_facade_example.pdf": _field_facade_example, + "iso9613_outdoor_attenuation_example.pdf": _outdoor_attenuation_example, + "iso9613_barrier_insertion_loss_example.pdf": _barrier_insertion_loss_example, + "iec60268_16_sti_example.pdf": _sti_example, + "ansi_s3_5_sii_example.pdf": _sii_example, + "enclosure_insertion_loss_example.pdf": _enclosure_example, + "reactive_silencer_example.pdf": _silencer_example, + "hvac_duct_noise_example.pdf": _hvac_example, + "duct_path_example.pdf": _duct_path_example, + "rd1367_activity_example.pdf": _rd1367_example, +} + +#: The registered factories alone, in generation order. +_EXAMPLES: list[Callable[[], tuple[object, ReportMetadata, str]]] = list( + _FICHES.values() +) def preview_path_for(pdf_path: str) -> str: @@ -3328,9 +3350,10 @@ def preview_path_for(pdf_path: str) -> str: def _write_preview(pdf_path: str) -> str: """Rasterize the first page of ``pdf_path`` to a lossless WebP beside it. - The preview is what the documentation embeds inline; it is not byte-checked - (the raster inherits the vector plot's ~1 ULP cross-CPU drift), so it is - regenerated by the maintainer like the figures. Lossless WebP keeps the + The preview is what the documentation embeds inline; it is not byte-compared + in CI (the raster inherits the vector plot's ~1 ULP cross-CPU drift), but + :mod:`scripts.check_reports` compares it within a tolerance, so a stale + preview fails the build like a stale figure. Lossless WebP keeps the preview pixel-identical to the raster at roughly half the optimized-PNG size; ``method=6`` is the slowest, most exhaustive encoder search, whose output is fixed by the input pixels (byte-stable across runs). diff --git a/scripts/generated_assets.py b/scripts/generated_assets.py new file mode 100644 index 000000000..ff8744e36 --- /dev/null +++ b/scripts/generated_assets.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""Shared pieces of the tolerance-aware staleness checks. + +Two committed directories are regenerated from the library and must not drift +from it: ``.github/images`` (:mod:`scripts.check_figures`) and +``.github/reports`` (:mod:`scripts.check_reports`). Both answer the same +question the same way -- regenerate into the working tree, then compare +against ``git HEAD`` -- and neither can do it with a byte diff, because +GitHub's runner fleet is hardware-heterogeneous and the same pinned stack +computes a few plotted coordinates ~1 ULP apart depending on which CPU +microarchitecture the run lands on. + +What they share lives here: reading the committed bytes, listing what is +tracked, and comparing two rasters within a tolerance. What differs stays in +each checker, because the tolerances are not the same: a documentation figure +is a raster of a plot, a fiche preview is a rasterized document page whose +rendering runs through a pinned binary rasterizer and is markedly quieter. +""" + +from __future__ import annotations + +import io +import subprocess +from dataclasses import dataclass + +import numpy as np +from PIL import Image + + +@dataclass(frozen=True) +class RasterTolerance: + """How far two rasters of the same asset may drift and still match. + + The two criteria are complementary, and both must hold. ``max_sig_pixels`` + catches a *localised* change (a moved line, a relabelled axis) that a + global root-mean-square would dilute across a large image; ``rms`` catches + a *broad* change (a recoloured fill, a restyled background) that + few-but-everywhere pixels would slip past the count. Cross-CPU sub-ULP + coordinate drift moves neither meaningfully. + + :param level: Per-channel difference (0..255) above which a pixel counts + as meaningfully changed rather than anti-aliasing noise. + :param max_sig_pixels: How many such pixels are tolerated. + :param rms: Largest tolerated per-channel root-mean-square difference. + """ + + level: float + max_sig_pixels: int + rms: float + + +def committed_bytes(path: str) -> bytes | None: + """Return the bytes of ``path`` as committed at HEAD, or ``None``.""" + result = subprocess.run( + ["git", "show", f"HEAD:{path}"], + capture_output=True, + check=False, + ) + return result.stdout if result.returncode == 0 else None + + +def tracked_files(directory: str) -> set[str]: + """Return the set of paths under ``directory`` tracked at HEAD.""" + result = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", "HEAD", directory], + capture_output=True, + text=True, + check=True, + ) + return {line for line in result.stdout.splitlines() if line} + + +def raster_problem(old: bytes, new: bytes, tol: RasterTolerance) -> str | None: + """Describe how two rasters differ beyond ``tol``, or ``None`` if within it.""" + a = np.asarray(Image.open(io.BytesIO(old)).convert("RGBA"), dtype=np.float64) + b = np.asarray(Image.open(io.BytesIO(new)).convert("RGBA"), dtype=np.float64) + if a.shape != b.shape: + return f"dimensions changed {a.shape} != {b.shape}" + diff = np.abs(a - b) + sig_pixels = int(np.count_nonzero(diff.max(axis=-1) > tol.level)) + if sig_pixels > tol.max_sig_pixels: + return ( + f"{sig_pixels} pixels changed by >{tol.level:g} " + f"(> {tol.max_sig_pixels})" + ) + rms = float(np.sqrt(np.mean(diff**2))) + if rms > tol.rms: + return f"RMS {rms:.3f} > {tol.rms}" + return None diff --git a/site/src/components/ReportPreview.astro b/site/src/components/ReportPreview.astro index a5196f305..af5a5ec46 100644 --- a/site/src/components/ReportPreview.astro +++ b/site/src/components/ReportPreview.astro @@ -15,8 +15,9 @@ * * The fiche is an opaque white A4 page, so — unlike the theme-aware figures — * a single WebP serves both themes; a light frame keeps it legible on the dark - * ground. The WebP is NOT byte-checked in CI (its embedded vector plot differs - * by ~1 ULP across CPUs); the maintainer regenerates it with `make reports`. + * ground. The WebP is not byte-checked in CI (its embedded vector plot differs + * by ~1 ULP across CPUs); the `Example report fiches up to date` job compares + * it within a tolerance instead, so a stale preview fails the build. */ interface Props { /** Fiche basename in .github/reports, e.g. "iso717_airborne_example". */ diff --git a/site/src/content/docs/es/guides/intensity.mdx b/site/src/content/docs/es/guides/intensity.mdx index 78a650e70..201a0d228 100644 --- a/site/src/content/docs/es/guides/intensity.mdx +++ b/site/src/content/docs/es/guides/intensity.mdx @@ -28,6 +28,7 @@ references: import Video from '../../../../components/Video.astro'; import ThemeImage from '../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; La *presión* acústica dice cuánto suena un punto; la **intensidad acústica** dice hacia dónde *va* la energía. Es el flujo de potencia acústica (W/m²), una @@ -447,6 +448,16 @@ combina componentes suministrados por separado: `instrument_class_from_components(probe_class, processor_class)` devuelve 1 solo cuando ambos son de clase 1, y 2 para cualquier otra combinación. +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; haz clic en la vista previa para abrir el PDF. + + + ### Leer `δpI0` como un error de fase El requisito es en realidad un requisito de emparejamiento de fase diff --git a/site/src/content/docs/guides/intensity.mdx b/site/src/content/docs/guides/intensity.mdx index 56ce93145..937bd1d84 100644 --- a/site/src/content/docs/guides/intensity.mdx +++ b/site/src/content/docs/guides/intensity.mdx @@ -28,6 +28,7 @@ references: import Video from '../../../components/Video.astro'; import ThemeImage from '../../../components/ThemeImage.astro'; +import ReportPreview from '../../../components/ReportPreview.astro'; Sound *pressure* tells you how loud a point is; sound **intensity** tells you where the energy is *going*. It is the acoustic power flux (W/m²), a @@ -430,6 +431,16 @@ EN/IEC text (see the `instrument_class_from_components(probe_class, processor_class)` returns 1 only when both are class 1, and 2 for every other pairing. +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ### Reading `δpI0` as a phase error The requirement is really a phase-matching requirement in disguise. In an diff --git a/tests/test_check_reports.py b/tests/test_check_reports.py new file mode 100644 index 000000000..640b84929 --- /dev/null +++ b/tests/test_check_reports.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""The fiche staleness gate catches real drift and ignores encoding noise. + +``scripts/check_reports.py`` is the gate that would have caught the two fiches +that sat a plot-styling release behind the library. It has to fail on the +changes that made them stale (a restyled fill, a moved plot line, a reworded +table) and pass on the sub-pixel wobble a different runner CPU produces, so +these tests drive both sides of every criterion with synthetic pages rather +than trusting the thresholds by inspection. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +import numpy as np +import pytest + +pytest.importorskip("PIL") +from PIL import Image + +_SCRIPTS = str(pathlib.Path(__file__).resolve().parent.parent / "scripts") +if _SCRIPTS not in sys.path: + sys.path.insert(0, _SCRIPTS) + +import check_reports as cr +from generated_assets import raster_problem + +#: A stand-in for a fiche preview: a white page with a shaded band and a line, +#: at the aspect ratio the generator writes (A4 at 1000 px wide). +_PAGE = (1415, 1000, 4) + + +def _page(fill: tuple[int, int, int] = (255, 236, 219)) -> np.ndarray: + """A white page carrying one shaded band and one dark plotted line.""" + page = np.full(_PAGE, 255, dtype=np.uint8) + page[500:800, 150:900, :3] = fill + page[640:644, 150:900, :3] = 40 + return page + + +def _encode(page: np.ndarray) -> bytes: + """Encode a page as the lossless WebP the generator commits.""" + import io + + buffer = io.BytesIO() + Image.fromarray(page, "RGBA").convert("RGB").save( + buffer, "WEBP", lossless=True, quality=100, method=6 + ) + return buffer.getvalue() + + +def test_identical_previews_match() -> None: + data = _encode(_page()) + assert raster_problem(data, data, cr.RASTER_TOL) is None + + +def test_sub_pixel_wobble_passes() -> None: + """A handful of edge pixels off by an anti-aliasing level or two is noise. + + This is what a different runner CPU produces: the plotted geometry differs + in the last bit, so a few pixels along one edge land on a slightly + different coverage. Nothing a reader can see, and the gate must not fail + on it or it will be switched off. + """ + old = _page() + new = old.copy() + new[640, 150:200, :3] = 30 # 50 edge pixels, ten levels darker + problem = raster_problem(_encode(old), _encode(new), cr.RASTER_TOL) + assert problem is None + + +def test_restyled_fill_fails() -> None: + """The drift that went unnoticed: a shaded band changing tint. + + Every pixel of the band moves by well under the per-pixel level threshold, + so the pixel count alone lets it through; the RMS bound is what catches + it. Three of the fiches that had drifted were found this way, the other + two having moved enough pixels for the count to see them as well. + """ + old = _page() + new = _page(fill=(255, 231, 210)) + problem = raster_problem(_encode(old), _encode(new), cr.RASTER_TOL) + assert problem is not None + assert "RMS" in problem + + +def test_moved_line_fails() -> None: + """A localised change: the plotted line moves by four pixels.""" + old = _page() + new = old.copy() + new[640:644, 150:900, :3] = 255 + new[660:664, 150:900, :3] = 40 + problem = raster_problem(_encode(old), _encode(new), cr.RASTER_TOL) + assert problem is not None + assert "pixels changed" in problem + + +def test_resized_preview_fails() -> None: + old = _page() + problem = raster_problem(_encode(old), _encode(old[:-1]), cr.RASTER_TOL) + assert problem is not None + assert "dimensions changed" in problem + + +# The PDF half of the gate reads its pages through pypdfium2. Without it, +# ``pdf_problem`` reports an extraction failure for every input, which is a +# legitimate answer for a corrupt fiche but turns the text comparisons into +# failures rather than skips. The raster tests above need none of it, so the +# skip sits here rather than at module level. +needs_pdf_engine = pytest.mark.skipif( + importlib.util.find_spec("pypdfium2") is None, + reason="pypdfium2 is not installed", +) + + +def _fiche_pdf(text: str) -> bytes: + """A one-page PDF carrying ``text``, standing in for a rendered fiche.""" + import io + + pdfgen_canvas = pytest.importorskip("reportlab.pdfgen.canvas") + buffer = io.BytesIO() + page = pdfgen_canvas.Canvas(buffer, invariant=1) + page.drawString(72, 720, text) + page.save() + return buffer.getvalue() + + +@needs_pdf_engine +def test_unchanged_text_matches() -> None: + assert cr.pdf_problem(_fiche_pdf("Rw = 34 dB"), _fiche_pdf("Rw = 34 dB")) is None + + +@needs_pdf_engine +def test_changed_rating_fails() -> None: + """The value a reader quotes off the fiche moved: always a failure.""" + problem = cr.pdf_problem(_fiche_pdf("Rw = 34 dB"), _fiche_pdf("Rw = 35 dB")) + assert problem == "text of page 1 changed" + + +@needs_pdf_engine +def test_unreadable_fiche_fails() -> None: + """A truncated or corrupt PDF is a failure, not a traceback.""" + problem = cr.pdf_problem(_fiche_pdf("Rw = 34 dB"), b"not a pdf at all") + assert problem is not None + assert "could not extract text" in problem diff --git a/tests/test_generate_reports.py b/tests/test_generate_reports.py index 09c54b17e..2b13ed6a8 100644 --- a/tests/test_generate_reports.py +++ b/tests/test_generate_reports.py @@ -44,9 +44,38 @@ def _load_generator(): def test_generator_registers_examples() -> None: assert _MODULE._EXAMPLES, "the generator registered no examples" # No factory is registered twice: a duplicate entry would render the - # same fiche twice and hide a forgotten registration elsewhere. + # same fiche twice and hide a forgotten registration elsewhere. (The + # filenames cannot collide, being the registry's own keys.) names = [factory.__name__ for factory in _MODULE._EXAMPLES] assert len(set(names)) == len(names) + assert all(name.endswith(".pdf") for name in _MODULE._FICHES) + + +def test_every_registered_fiche_is_committed() -> None: + """Registering a fiche and never committing it is a silent hole. + + One example was registered in the generator, documented, and left without + its rendered PDF and preview in the tree for months: the documentation + linked to files that were not there. The staleness job catches it too, but + this needs no regeneration, so it fails in the same second as the commit + that forgets it. + + Reads the filenames off the registry keys rather than calling each factory + for the name it returns: the answer is a path lookup, and running all 67 + factories to get it would compute and plot every worked example first. + ``test_each_example_writes_a_one_page_pdf_and_preview`` is what confirms a + factory still writes the file it is registered under. + """ + committed = pathlib.Path(_MODULE._DEFAULT_DIR) + missing = [] + for name in _MODULE._FICHES: + for path in (committed / name, committed / _MODULE.preview_path_for(name)): + if not path.is_file(): + missing.append(path.name) + assert not missing, ( + f"registered fiches with no committed render: {sorted(missing)} - " + "run 'make reports' and commit the result" + ) @pytest.mark.parametrize( @@ -61,6 +90,9 @@ def test_each_example_writes_a_one_page_pdf_and_preview( written = _MODULE.generate_reports(str(tmp_path), examples=[factory]) assert len(written) == 1 p = pathlib.Path(written[0]) + # The factory writes the file it is registered under. This is what lets + # test_every_registered_fiche_is_committed trust the registry keys. + assert _MODULE._FICHES.get(p.name) is factory assert p.is_file() and p.stat().st_size > 0 with open(p, "rb") as handle: assert handle.read(4) == b"%PDF"