From 79a002a21a27c7616261ae0c2cc8341e0ab92faa Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 15 Jun 2026 15:02:31 +0300 Subject: Make every stat bucket a clickable filter mini-album, rendered in parallel Generalises the per-camera mini-albums to every stats category: clicking any row on the stats page (camera, lens, year, month, aperture, shutter, ISO, focal length, megapixels, aspect, orientation, format, exposure program, metering, white balance, flash) now opens a filter mini-album of just the matching photos, with view pages whose prev/next cycle within the filter. Aggregation: a single _stats_tally now records, for every tallied bucket, the bucket's photo list and a unique filename-safe "pagebase" (camera-canon-eos-r5, iso-400, year-2023, ...) with collision resolution, exposed via STATS_FILTER_* maps. The old camera-only STATS_CAMERA_SLUGS/PHOTOS/SLUG_OWNERS are gone. Render: every bar/leaderboard row is wrapped in a link to its bucket's pagebase via _stats_filter_link. render_filter_pages replaces render_camera_pages and renders ALL filter mini-albums (gallery + per-photo view pages) reusing camera.tmpl/cameraview.tmpl. The galleries/view pages reuse the album's shared photos/thumbs/blurs assets -- only the HTML differs. Parallelism: filter pages render through the shared job pool, throttled to IMAGE_JOBS (the same pool the album view pages use). The album render warms every photo's EXIF cache (via details pages) before the parallel filter render, so the filter jobs only read the cache. Tests updated for the new STATS_FILTER_* structures and extended to assert non-camera filter mini-albums (orientation, ISO) and their stats-page links. Co-Authored-By: Claude Opus 4.8 --- tests/cli.sh | 92 ++++++++++++++++++++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 39 deletions(-) (limited to 'tests/cli.sh') diff --git a/tests/cli.sh b/tests/cli.sh index a05485c..cabd96f 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -3101,6 +3101,15 @@ test_generate_stats_pages_created_and_nav_linked() { test::assert_contains 'href="./camera-canon-eos-r5--1.html"' "$camera_html" test::assert_not_contains 'href="./photos/' "$camera_html" + # Non-camera stats are clickable mini-albums too: the ISO row links to a + # filter page that exists and is itself a gallery of matching photos. + test::assert_contains 'href="iso-400.html"' \ + "$(<"$TEST_TMPDIR/dist/stats.html")" + test::assert_file_exists "$TEST_TMPDIR/dist/iso-400.html" + test::assert_file_exists "$TEST_TMPDIR/dist/iso-400--1.html" + test::assert_contains 'href="./iso-400--1.html"' \ + "$(<"$TEST_TMPDIR/dist/iso-400.html")" + # A per-camera view page exists and its navigation stays within the camera: # prev/next point at this camera's view pages, plus links back to the gallery # and to the album details page for the photo. @@ -3993,7 +4002,7 @@ BASH test::teardown } -test_render_camera_pages_renders_one_page_per_camera() { +test_render_filter_pages_renders_mini_albums() { local html local html_again local dist_dir @@ -4002,11 +4011,11 @@ test_render_camera_pages_renders_one_page_per_camera() { dist_dir="$TEST_TMPDIR/dist" mkdir -p "$dist_dir" - # Feed synthetic EXIF straight into accumulate_photo_stats (same approach as - # the stats-page test) to exercise three cameras: one whose label needs - # HTML-escaping (& and <), and a slug-collision pair ("Canon EOS 5D" vs - # "Canon EOS-5D" both slug to canon-eos-5d, so the second gets -2). Then - # render the per-camera pages twice into separate dirs to assert determinism. + # Feed synthetic EXIF into accumulate_photo_stats to exercise several filter + # categories: cameras (one label needing HTML-escaping; a slug-collision pair + # "Canon EOS 5D" vs "Canon EOS-5D" -> canon-eos-5d / canon-eos-5d-2) plus the + # orientation buckets derived from Geometry. Render the filter mini-albums + # twice into separate dirs to assert deterministic output despite parallelism. bash -euo pipefail -s \ "$TEST_SHURIKEN" \ "$TEST_REPO_ROOT/share/templates/default" \ @@ -4030,11 +4039,15 @@ TARBALL_INCLUDE=no SHURIKEN_OUTPUT_MODE=quiet # Seed so the per-thumbnail animation class is reproducible across runs. RANDOM_SEED=camera-test +# Render serially here: this test renders filters without an album render to warm +# the EXIF cache, so parallel jobs would otherwise race to populate it. The +# parallel path (with a warm cache) is covered by the full --generate test. +IMAGE_JOBS=1 apply_config_defaults -# The per-camera view pages read each photo's EXIF tooltip from INCOMING_DIR, so +# The filter view pages read each photo's EXIF tooltip from INCOMING_DIR, so # provide empty stand-in files (no ImageMagick here, so the tooltip is empty but -# the view pages still render with full camera navigation). +# the view pages still render with full filter navigation). INCOMING_DIR="$dist_dir/incoming" mkdir -p "$INCOMING_DIR" for stub in a.jpg b.jpg c.png d.jpg; do @@ -4067,36 +4080,34 @@ EXIF mkdir -p "$dist_dir/run1" "$dist_dir/run2" feed -render_camera_pages run1 .. +render_filter_pages run1 .. feed -render_camera_pages run2 .. +render_filter_pages run2 .. BASH - # One page per camera, named with the slugs the aggregation stored. The - # colliding labels get distinct files (canon-eos-5d and canon-eos-5d-2). + # Camera mini-albums: one gallery per camera, collision-resolved names. test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d.html" test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d-2.html" test::assert_file_exists "$dist_dir/run1/camera-nikon-co-z6.html" + # Non-camera stats are mini-albums too: orientation comes from Geometry, so + # the three landscape photos get their own filter mini-album. + test::assert_file_exists "$dist_dir/run1/orientation-landscape.html" + test::assert_file_exists "$dist_dir/run1/orientation-landscape--1.html" - # The Canon EOS 5D gallery lists exactly its two photos as thumbnails linking - # to this camera's own view pages (camera---.html), not the other - # camera's photo. The img carries a thumb class (plus a seeded animation - # class) pointing at thumbs/. + # The Canon EOS 5D gallery lists its two photos as thumbnails linking to this + # filter's own view pages (--.html); img points at thumbs/. html=$(cat "$dist_dir/run1/camera-canon-eos-5d.html") test::assert_contains 'href="../camera-canon-eos-5d--1.html"' "$html" test::assert_contains 'class="thumb ' "$html" test::assert_contains 'src="../thumbs/a.jpg" />' "$html" test::assert_contains 'href="../camera-canon-eos-5d--2.html"' "$html" - test::assert_not_contains 'photos/c.png' "$html" - test::assert_not_contains 'photos/d.jpg' "$html" test::assert_not_contains 'href="../photos/' "$html" # Heading shows the (trusted) camera label and a back-to-stats link. test::assert_contains 'Canon EOS 5D' "$html" test::assert_contains 'Back to stats' "$html" - # Each per-camera view page exists and its prev/next stay within the camera - # (two photos, so view 1's prev and next both point at view 2), with a link - # back to the gallery. + # A filter view page cycles within its own filter (two photos, so view 1's + # prev and next both point at view 2), with a link back to the gallery. test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d--1.html" test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d--2.html" html=$(cat "$dist_dir/run1/camera-canon-eos-5d--1.html") @@ -4112,11 +4123,11 @@ BASH test::assert_not_contains 'Nikon & Co ' "$html" test::assert_contains 'href="../camera-nikon-co-z6--1.html"' "$html" - # Output is deterministic: the second run is byte-identical to the first. + # Output is deterministic across runs despite parallel rendering. html=$(cat "$dist_dir/run1/camera-canon-eos-5d.html") html_again=$(cat "$dist_dir/run2/camera-canon-eos-5d.html") if [ "$html" != "$html_again" ]; then - printf 'FAIL: camera page not reproducible across runs\n' >&2 + printf 'FAIL: filter page not reproducible across runs\n' >&2 exit 1 fi test::teardown @@ -5315,9 +5326,10 @@ test_stats_aggregates_synthetic_exif_fixtures() { test "${STATS_TOTALS[photos]}" -eq 2 test "${STATS_CAMERAS[Canon EOS 5D Mark IV]}" -eq 2 - test "${STATS_CAMERA_SLUGS[Canon EOS 5D Mark IV]}" = 'canon-eos-5d-mark-iv' - # Per-camera photo list keeps both frames in encounter order. - test "${STATS_CAMERA_PHOTOS[canon-eos-5d-mark-iv]}" = $'a.jpg\nb.png' + test "${STATS_FILTER_PAGEBASE[camera${STATS_FILTER_KEYSEP}Canon EOS 5D Mark IV]}" \ + = 'camera-canon-eos-5d-mark-iv' + # The camera's filter mini-album keeps both frames in encounter order. + test "${STATS_FILTER_PHOTOS[camera-canon-eos-5d-mark-iv]}" = $'a.jpg\nb.png' test "${STATS_LENSES[EF50mm f/1.8 STM]}" -eq 1 test "${STATS_YEARS[2023]}" -eq 1 test "${STATS_YEARS[2024]}" -eq 1 @@ -5356,10 +5368,10 @@ test_stats_tolerates_missing_and_edge_case_fields() { test "${#STATS_CAMERAS[@]}" -eq 0 test "${STATS_FORMAT[GIF]}" -eq 1 - # Make only (no Model) still produces a leaderboard entry and slug. + # Make only (no Model) still produces a leaderboard entry and mini-album. accumulate_photo_stats 'phone.jpg' <<< $' exif:Make: Apple' test "${STATS_CAMERAS[Apple]}" -eq 1 - test "${STATS_CAMERA_SLUGS[Apple]}" = 'apple' + test "${STATS_FILTER_PAGEBASE[camera${STATS_FILTER_KEYSEP}Apple]}" = 'camera-apple' # Rational guards: zero denominator and bare decimal exposure time. fixture=$' exif:FNumber: 4/0\n' @@ -5372,27 +5384,29 @@ test_stats_tolerates_missing_and_edge_case_fields() { } test_stats_distinct_cameras_get_unique_slugs() { + local sep test::setup test::source_shuriken_lib reset_photo_exif_stats + sep="$STATS_FILTER_KEYSEP" # Two distinct camera labels that sanitize to the same base slug must keep - # separate leaderboard entries, separate camera-.html slugs, and - # separate per-camera photo lists (regression: slug collision merged them). + # separate leaderboard entries, separate pagebases (mini-album filenames), + # and separate photo lists (regression: slug collision merged them). accumulate_photo_stats 'a.jpg' <<< $' exif:Model: Canon EOS 1D' accumulate_photo_stats 'b.jpg' <<< $' exif:Model: Canon EOS-1D!!' test "${STATS_CAMERAS[Canon EOS 1D]}" -eq 1 test "${STATS_CAMERAS[Canon EOS-1D!!]}" -eq 1 - test "${STATS_CAMERA_SLUGS[Canon EOS 1D]}" = 'canon-eos-1d' - test "${STATS_CAMERA_SLUGS[Canon EOS-1D!!]}" = 'canon-eos-1d-2' - test "${STATS_CAMERA_PHOTOS[canon-eos-1d]}" = 'a.jpg' - test "${STATS_CAMERA_PHOTOS[canon-eos-1d-2]}" = 'b.jpg' + test "${STATS_FILTER_PAGEBASE[camera${sep}Canon EOS 1D]}" = 'camera-canon-eos-1d' + test "${STATS_FILTER_PAGEBASE[camera${sep}Canon EOS-1D!!]}" = 'camera-canon-eos-1d-2' + test "${STATS_FILTER_PHOTOS[camera-canon-eos-1d]}" = 'a.jpg' + test "${STATS_FILTER_PHOTOS[camera-canon-eos-1d-2]}" = 'b.jpg' - # Re-encountering the first camera reuses its slug and appends, not a new one. + # Re-encountering the first camera reuses its pagebase and appends. accumulate_photo_stats 'c.jpg' <<< $' exif:Model: Canon EOS 1D' test "${STATS_CAMERAS[Canon EOS 1D]}" -eq 2 - test "${STATS_CAMERA_PHOTOS[canon-eos-1d]}" = $'a.jpg\nc.jpg' + test "${STATS_FILTER_PHOTOS[camera-canon-eos-1d]}" = $'a.jpg\nc.jpg' test::teardown } @@ -5460,7 +5474,7 @@ test_stats_collect_reads_cached_identify_output() { test "${STATS_TOTALS[photos]}" -eq 2 test "${STATS_CAMERAS[Nikon Z6]}" -eq 2 - test "${STATS_CAMERA_PHOTOS[nikon-z6]}" = $'one.jpg\ntwo.jpg' + test "${STATS_FILTER_PHOTOS[camera-nikon-z6]}" = $'one.jpg\ntwo.jpg' test::teardown } @@ -5733,8 +5747,8 @@ main() { 'render_stats_page renders sections and escapes EXIF labels' \ test_render_stats_page_renders_sections_and_escapes test::run_case \ - 'render_camera_pages renders one page per camera' \ - test_render_camera_pages_renders_one_page_per_camera + 'render_filter_pages renders mini-albums for every stat bucket' \ + test_render_filter_pages_renders_mini_albums test::run_case \ 'template context validator fails fast without errexit' \ test_template_context_validator_fails_fast_without_errexit -- cgit v1.2.3