moss_core/asset_paths.rs
1//! Pure URL/path transform helpers — no filesystem access, no env lookups.
2//! Used by moss-core's render functions (see crate::render::*) and by
3//! upstream src-tauri call sites.
4//!
5//! # Design Intent
6//!
7//! Every component that derives an output path from a video or image source
8//! path (e.g., .mov → .mp4, .mov → .thumb.jpg, .jpg → .webp) MUST use these
9//! functions. This ensures consistency between:
10//! - HTML attributes set by the synthesizers (src, data-placeholder-src, poster)
11//! - AssetReady event paths emitted by the build pipeline
12//! - AssetRegistry keys used by the preview server
13//! - iframe-bridge.ts path comparisons
14//!
15//! If naming conventions change, they change here only.
16//!
17//! These are pure string functions — no I/O, no filesystem access.
18//! They work on both root-relative paths ("videos/clip.mov") and
19//! document-relative paths ("../clip.mov").
20
21/// Known video extensions that get transcoded to .mp4.
22const MOV_SUFFIXES: &[&str] = &[".mov", ".MOV", ".Mp4", ".MP4"];
23
24/// All video extensions (including those that pass through unchanged for `to_mp4`
25/// but still need thumbnail derivation).
26const ALL_VIDEO_SUFFIXES: &[&str] = &[
27 ".mov", ".MOV", ".mp4", ".MP4", ".Mp4", ".webm", ".WEBM",
28];
29
30/// Known image extensions that get re-encoded to .webp.
31/// Order matters: longer suffixes (jpeg) before shorter (jpg) is not required
32/// because we strip exact suffixes, but keep the list grouped by family for readability.
33const IMAGE_SUFFIXES: &[&str] = &[
34 ".jpg", ".JPG", ".jpeg", ".JPEG",
35 ".png", ".PNG",
36 ".webp", ".WEBP",
37];
38
39/// Converts a video source path to its .mp4 output path.
40///
41/// - `.mov` / `.MOV` → `.mp4`
42/// - `.Mp4` / `.MP4` → `.mp4` (case normalization)
43/// - `.mp4` → `.mp4` (unchanged)
44/// - `.webm` → `.webm` (pass-through, not transcoded)
45///
46/// Any directory prefix is preserved.
47///
48/// # Examples
49/// ```
50/// # use moss_core::asset_paths::to_mp4;
51/// assert_eq!(to_mp4("clip.mov"), "clip.mp4");
52/// assert_eq!(to_mp4("../clip.MOV"), "../clip.mp4");
53/// assert_eq!(to_mp4("clip.webm"), "clip.webm");
54/// ```
55pub fn to_mp4(source: &str) -> String {
56 for suffix in MOV_SUFFIXES {
57 if let Some(stem) = source.strip_suffix(suffix) {
58 return format!("{stem}.mp4");
59 }
60 }
61 // .mp4 (lowercase) and .webm pass through unchanged
62 source.to_string()
63}
64
65/// Converts a video source path to its thumbnail path (.thumb.jpg).
66///
67/// All recognized video extensions are stripped and replaced with `.thumb.jpg`.
68///
69/// # Examples
70/// ```
71/// # use moss_core::asset_paths::to_thumb;
72/// assert_eq!(to_thumb("clip.mov"), "clip.thumb.jpg");
73/// assert_eq!(to_thumb("../clip.mp4"), "../clip.thumb.jpg");
74/// ```
75pub fn to_thumb(source: &str) -> String {
76 for suffix in ALL_VIDEO_SUFFIXES {
77 if let Some(stem) = source.strip_suffix(suffix) {
78 return format!("{stem}.thumb.jpg");
79 }
80 }
81 // Fallback: append .thumb.jpg (shouldn't happen with known video files)
82 format!("{source}.thumb.jpg")
83}
84
85/// Like `to_thumb`, but returns `None` for non-video paths instead of
86/// blindly appending `.thumb.jpg`.
87///
88/// Use this when the caller needs to *decide* whether a path is a video
89/// (e.g. cover-color resolution), as opposed to forcing the conversion.
90///
91/// # Examples
92/// ```
93/// # use moss_core::asset_paths::to_thumb_if_video;
94/// assert_eq!(to_thumb_if_video("clip.mov").as_deref(), Some("clip.thumb.jpg"));
95/// assert_eq!(to_thumb_if_video("clip.MP4").as_deref(), Some("clip.thumb.jpg"));
96/// assert_eq!(to_thumb_if_video("photo.jpg"), None);
97/// ```
98pub fn to_thumb_if_video(source: &str) -> Option<String> {
99 for suffix in ALL_VIDEO_SUFFIXES {
100 if let Some(stem) = source.strip_suffix(suffix) {
101 return Some(format!("{stem}.thumb.jpg"));
102 }
103 }
104 None
105}
106
107/// Converts an image source path to its WebP output path.
108///
109/// - `.jpg` / `.JPG` / `.jpeg` / `.JPEG` → `.webp`
110/// - `.png` / `.PNG` → `.webp`
111/// - `.webp` / `.WEBP` → `.webp` (case normalization only)
112///
113/// Any directory prefix is preserved. Unknown extensions fall back to
114/// appending `.webp` (same defensive style as `to_thumb`).
115///
116/// # Examples
117/// ```
118/// # use moss_core::asset_paths::to_webp;
119/// assert_eq!(to_webp("photo.jpg"), "photo.webp");
120/// assert_eq!(to_webp("../photo.PNG"), "../photo.webp");
121/// assert_eq!(to_webp("photo.webp"), "photo.webp");
122/// ```
123pub fn to_webp(source: &str) -> String {
124 for suffix in IMAGE_SUFFIXES {
125 if let Some(stem) = source.strip_suffix(suffix) {
126 return format!("{stem}.webp");
127 }
128 }
129 // Fallback: append .webp (shouldn't happen with known image files)
130 format!("{source}.webp")
131}
132
133/// The raster source extensions that participate in the responsive ladder
134/// (png/jpg/jpeg/webp). Single source of truth for the ladder-membership
135/// **GATE** — the "does this extension take part at all?" question, distinct
136/// from the "which rung WIDTHS exist?" question [`ladder_rungs`] answers (whose
137/// own doc enumerates the ladder-DERIVATION sites). Phase B (Task 12) lifted
138/// webp's participation by editing ONLY this predicate, so every gate site
139/// picked up webp in lockstep.
140///
141/// GATE SITES — the six `is_ladder_source_ext(` production callers
142/// (grep-verified 2026-07-23), each deciding png/jpg/jpeg/webp participation:
143/// 1. **emission** — `is_raster_original` (`render/image.rs`);
144/// 2. **registration** — the rung loop in `generate_blocking_content`
145/// (`build/render/blocking.rs`);
146/// 3. **encode dispatch** — `rungs_gated` in `convert_single_image`
147/// (`build/media/image.rs`);
148/// 4. **success/failure rung sweep** — `registered_rungs` in
149/// `run_image_conversion` (`build/media/image.rs`);
150/// 5. **fingerprint-skip heal** — `dispatch_image_conversions`
151/// (`build/media/image.rs`);
152/// 6. **`should_skip`'s AlreadySmall carve-out** — `raster_with_picture`
153/// (`build/media/image.rs`).
154///
155/// These six are the "six census sites" named in the design doc and
156/// MIGRATION-STATE. NOTE: `encode_rungs` is NOT a gate site — it is a
157/// [`ladder_rungs`] DERIVATION consumer (it computes the rung SET, it does not
158/// gate on extension). A maintainer adding a new participating format updates
159/// THIS list; one adding a rung-set derivation updates [`ladder_rungs`]' census.
160///
161/// NOTE ON `<picture>` vs `<img srcset>`: ladder membership is NOT the same as
162/// "emits a `<picture><source>` webp CONVERSION". png/jpg/jpeg are re-encoded to
163/// a differently-named `.webp` and emit `<picture><source srcset=X.webp>`; a
164/// webp SOURCE is already webp, so [`to_webp`]`(src) == src` and it emits the
165/// ladder directly on `<img srcset>` (no `<picture>` — a `<source>` identical to
166/// the `<img>` is pointless). Callers that need the CONVERSION-only subset
167/// (e.g. `should_skip`'s AlreadySmall carve-out, which must keep small webp
168/// eligible to skip re-encode) combine this with [`is_webp_source_ext`]:
169/// `is_ladder_source_ext(ext) && !is_webp_source_ext(ext)`.
170///
171/// Case-insensitive; `ext` is the extension WITHOUT the leading dot
172/// ("png", "JPG", "jpeg", "webp"). Emission derives the gate from a full path
173/// via `render/image.rs::is_raster_original` / `is_webp_source`; the pipeline
174/// sites pass the scan-derived `item.ext` / a `source_file.extension()` string.
175///
176/// # Examples
177/// ```
178/// # use moss_core::asset_paths::is_ladder_source_ext;
179/// assert!(is_ladder_source_ext("png"));
180/// assert!(is_ladder_source_ext("JPG"));
181/// assert!(is_ladder_source_ext("webp")); // joined the ladder in Phase B (Task 12)
182/// ```
183pub fn is_ladder_source_ext(ext: &str) -> bool {
184 matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp")
185}
186
187/// True when `ext` is a WebP source extension (`webp`, case-insensitive).
188///
189/// A webp SOURCE is already webp: it does NOT get a differently-named
190/// `<picture><source>` webp conversion — instead it carries the responsive
191/// ladder directly on `<img srcset>` (Phase B, Task 12). Two callers need this
192/// distinction that [`is_ladder_source_ext`] (which now unions webp in) can no
193/// longer make alone:
194/// - emission (`render/image.rs::synthesize_inner`) routes webp to the
195/// `<img srcset>` branch instead of the `<picture>` branch;
196/// - `should_skip`'s AlreadySmall carve-out excludes webp from the
197/// "must-convert, never-skip" set so a small rung-free webp can still skip
198/// the wasteful webp→webp re-encode.
199///
200/// `ext` is the extension WITHOUT the leading dot.
201///
202/// # Examples
203/// ```
204/// # use moss_core::asset_paths::is_webp_source_ext;
205/// assert!(is_webp_source_ext("webp"));
206/// assert!(is_webp_source_ext("WEBP"));
207/// assert!(!is_webp_source_ext("png"));
208/// assert!(!is_webp_source_ext("jpg"));
209/// ```
210pub fn is_webp_source_ext(ext: &str) -> bool {
211 ext.eq_ignore_ascii_case("webp")
212}
213
214/// Max edge (px) of any deployed raster. Single source of truth — the encode
215/// pipeline's `ImageCompressionConfig::default()` reads this constant, and the
216/// srcset base-width descriptor caps at it. 2400 covers retina displays.
217pub const DEPLOY_MAX_EDGE: u32 = 2400;
218
219/// The responsive ladder: rung widths generated below the deployed base.
220/// Must be strictly ascending — the `take_while` in [`ladder_rungs`] depends on it.
221/// This is the canonical ladder definition and part of the public `asset_paths`
222/// vocabulary (named in the moss-core CHANGELOG); consumers should prefer
223/// [`ladder_rungs`] / [`deployed_width`] over indexing `LADDER` directly, so
224/// ladder policy stays in one place.
225/// See docs/archive/2026-07-22-responsive-image-variants-design.md.
226pub const LADDER: [u32; 2] = [800, 1600];
227
228/// Width the deployed base variant actually has after the encoder's
229/// aspect-preserving longest-EDGE resize (`img.resize(max_edge, max_edge,
230/// Lanczos3)` in build/media/image.rs). When the longest edge exceeds
231/// [`DEPLOY_MAX_EDGE`], BOTH dimensions shrink by the same ratio — for
232/// portraits the deployed width is therefore SMALLER than `min(w, 2400)`:
233/// a 3024×4032 portrait deploys at 1800×2400, so its base width is 1800.
234///
235/// Integer math (u64 multiply, truncating divide, floor at 1) mirrors the
236/// image crate's `resize_dimensions` as closely as practical. srcset width
237/// descriptors are browser HINTS: ±1px rounding drift vs the encoder's
238/// float `.round()` is acceptable — a Task-5 cross-check test against real
239/// encode output pins gross agreement.
240pub fn deployed_width(natural_w: u32, natural_h: u32) -> u32 {
241 let long_edge = natural_w.max(natural_h);
242 if long_edge <= DEPLOY_MAX_EDGE {
243 return natural_w;
244 }
245 ((natural_w as u64 * DEPLOY_MAX_EDGE as u64 / long_edge as u64) as u32).max(1)
246}
247
248/// Which ladder rungs exist for a source of `natural_w`×`natural_h` px.
249///
250/// DETERMINISTIC-AGREEMENT CONTRACT — the ladder-DERIVATION census: the
251/// `ladder_rungs(` production callers (grep-verified 2026-07-23). Keep this
252/// list current; every set-agreement site derives ladder membership from the
253/// same scan-derived inputs. These answer "which rung WIDTHS exist?"; the
254/// separate participation GATE ("does this extension take part at all?") is the
255/// six-site [`is_ladder_source_ext`] census — a site usually gates there first,
256/// then derives here, so most appear on both lists. The five set-agreement
257/// sites (all five must produce the identical rung SET):
258///
259/// 1. **emission** — the synthesizer's `resolve_ladder`
260/// (`render/image.rs::synthesize_inner`) decides which rung srcset
261/// candidates appear in HTML;
262/// 2. **registration** — blocking.rs's rung loop promises each rung URL via
263/// `set_source_passthrough` + `set_pending`;
264/// 3. **encode** — `encode_rungs` (build/media/rungs.rs, extracted from image.rs
265/// at Task 10.5) derives the same ladder from oriented dims on the
266/// full-encode and warm-cache paths; `convert_single_image`'s `ladder_len`
267/// (build/media/image.rs) reads the same call to keep the oriented original
268/// alive for the rung re-encode;
269/// 4. **dispatch/sweep/Err-arm retraction** — the worker success/failure
270/// arms' `registered_rungs` reconstruction (build/media/image.rs)
271/// resolves or retracts every promise registration made;
272/// 5. **fingerprint-skip heal** — the unchanged-fingerprint pass
273/// (`dispatch_image_conversions`, build/media/image.rs) rematerializes each
274/// rung it recomputes here.
275///
276/// One further `ladder_rungs(` caller is NOT a set-agreement member:
277/// `should_skip`'s AlreadySmall carve-out (build/media/image.rs) consults only
278/// `ladder_rungs(..).is_empty()` — a "carries any rung?" boolean that keeps a
279/// small rung-bearing webp from skipping re-encode — so it never emits,
280/// registers, encodes, or sweeps a rung SET and cannot disagree on membership.
281///
282/// A rung is emitted in HTML iff it is registered iff it is encoded. Never
283/// add an input here that one of these sites cannot supply (e.g. encode
284/// outcomes, cache state) — that is the parallel-oracle bug class deleted
285/// 2026-05-20 (see build/media/image.rs:297-310).
286///
287/// EXIF-ORIENTATION AGREEMENT (canonical; the pipeline sites back-reference
288/// here). The "same scan-derived dims on every site" premise holds
289/// UNCONDITIONALLY — including an EXIF-oriented (orientation 5-8, i.e. a 90°/270°
290/// rotation) png/webp/jpeg. Scan's `extract_image_dimensions` (build/scan/
291/// scan.rs) swaps `w`×`h` for orientation 5-8 on EVERY ladder source, reading
292/// orientation through the SAME `read_exif_orientation` the encode side's
293/// `decode_oriented` uses — so the dims scan feeds emission/registration/sweep/
294/// heal equal encode's oriented dims byte-for-byte and the two ladders cannot
295/// diverge. (Before the 2026-07-23 scan-swap fix — design follow-up #1 — scan's
296/// orientation read was JPEG-GATED, so an EXIF-rotated png/webp stored the
297/// UNswapped header dims and could strand an emitted-but-never-encoded rung →
298/// publish-404, preview degrading to the placeholder via the unresolved-promise
299/// sweep.) Do NOT re-gate scan's swap to jpeg-only, and do NOT "fix" any future
300/// divergence by narrowing the encode side: the encoder strips EXIF, so a base
301/// that isn't oriented at encode time ships stored sideways (visibly rotated).
302///
303/// Rungs are strictly below the deployed base WIDTH (post-resize, see
304/// [`deployed_width`] — portrait sources have a smaller base width than
305/// `min(w, DEPLOY_MAX_EDGE)`) so the base descriptor never duplicates a
306/// rung and no rung is ever wider than the base. Animated sources get no
307/// ladder (animation-preserving multi-size re-encode is out of scope).
308///
309/// APNG: an animated PNG passes `is_raster_original` like any png, and the
310/// BASE webp encode already FLATTENS it to a still today (`should_skip` in
311/// build/media/image.rs sniffs animation only for gif/webp). Rungs run
312/// through the same encode path and inherit the same flattening —
313/// consistent by construction, no 404 risk.
314///
315/// ANIMATED-FLAG AGREEMENT (Phase B, Task 12). Only ONE of the five sites
316/// passes a non-`false` flag: **emission** passes the scan-derived
317/// `assets.is_animated(src)` for webp sources (an animated webp → empty
318/// ladder → bare `<img>`, no srcset). The four pipeline sites (registration,
319/// `rungs_gated`/`ladder_len`, `registered_rungs`, fingerprint-heal, plus
320/// `encode_rungs`) keep the `false` literal, and that literal is PROVABLY
321/// correct — not an assumption: an animated webp returns
322/// `SkipReason::AnimatedWebp` in `should_skip`, so `collect_images_for_
323/// conversion` drops it BEFORE it can become an `ImageConversionItem`. Every
324/// webp that reaches the pipeline is therefore non-animated, and `false`
325/// equals its real flag. Because scan's `sniff_is_animated` and `should_skip`
326/// both call the SAME `is_animated_webp`, the emission verdict and the
327/// pipeline's inclusion verdict never disagree: an animated webp is
328/// simultaneously flagged in the snapshot (emission → no rungs) AND filtered
329/// from the item list (pipeline → no rungs). Both sides produce zero rungs.
330/// png/jpg/jpeg are never animated through this path (animated gif/webp are
331/// not in the conversion set) and keep `false` everywhere too.
332///
333/// WARNING: any future pipeline-side skip that is NOT expressible as an
334/// input to this function — e.g. copying the Y1 sized-raster APNG
335/// verbatim-keep guard (build/media/image.rs ~line 830) onto rung encodes —
336/// would create emitted-but-never-encoded rungs, i.e. the non-recoverable
337/// chosen-`<source>` 404 (ADR-013). Task 5 must NOT copy that guard.
338pub fn ladder_rungs(natural_w: u32, natural_h: u32, is_animated: bool) -> &'static [u32] {
339 if is_animated {
340 return &[];
341 }
342 let base = deployed_width(natural_w, natural_h);
343 let n = LADDER.iter().take_while(|&&w| w < base).count();
344 &LADDER[..n]
345}
346
347/// Rung variant URL: `photo.jpg` + 800 → `photo.w800.webp`.
348/// Same derivation style as [`to_webp`]; preserves directory prefix.
349///
350/// # Examples
351/// ```
352/// # use moss_core::asset_paths::to_webp_rung;
353/// assert_eq!(to_webp_rung("photo.jpg", 800), "photo.w800.webp");
354/// assert_eq!(to_webp_rung("../photo.PNG", 1600), "../photo.w1600.webp");
355/// ```
356pub fn to_webp_rung(source: &str, width: u32) -> String {
357 let webp = to_webp(source);
358 let stem = webp.strip_suffix(".webp").unwrap_or(&webp);
359 format!("{stem}.w{width}.webp")
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 // ── to_mp4 tests ───────────────────────────────────────────────
367
368 #[test]
369 fn test_to_mp4_mov() {
370 assert_eq!(to_mp4("clip.mov"), "clip.mp4");
371 }
372
373 #[test]
374 fn test_to_mp4_mov_uppercase() {
375 assert_eq!(to_mp4("clip.MOV"), "clip.mp4");
376 }
377
378 #[test]
379 fn test_to_mp4_mp4_mixed_case() {
380 assert_eq!(to_mp4("clip.Mp4"), "clip.mp4");
381 }
382
383 #[test]
384 fn test_to_mp4_mp4_uppercase() {
385 assert_eq!(to_mp4("clip.MP4"), "clip.mp4");
386 }
387
388 #[test]
389 fn test_to_mp4_already_mp4() {
390 assert_eq!(to_mp4("clip.mp4"), "clip.mp4");
391 }
392
393 #[test]
394 fn test_to_mp4_webm_passthrough() {
395 assert_eq!(to_mp4("clip.webm"), "clip.webm");
396 }
397
398 #[test]
399 fn test_to_mp4_with_relative_prefix() {
400 assert_eq!(to_mp4("../clip.mov"), "../clip.mp4");
401 }
402
403 #[test]
404 fn test_to_mp4_with_dot_prefix() {
405 assert_eq!(to_mp4("./clip.MOV"), "./clip.mp4");
406 }
407
408 #[test]
409 fn test_to_mp4_with_directory() {
410 assert_eq!(to_mp4("videos/clip.mov"), "videos/clip.mp4");
411 }
412
413 #[test]
414 fn test_to_mp4_unicode_filename() {
415 assert_eq!(to_mp4("videos/冬日之歌.mov"), "videos/冬日之歌.mp4");
416 }
417
418 #[test]
419 fn test_to_mp4_space_in_name() {
420 assert_eq!(to_mp4("./Morning Mist.mov"), "./Morning Mist.mp4");
421 }
422
423 // ── to_thumb tests ─────────────────────────────────────────────
424
425 #[test]
426 fn test_to_thumb_mov() {
427 assert_eq!(to_thumb("clip.mov"), "clip.thumb.jpg");
428 }
429
430 #[test]
431 fn test_to_thumb_mp4() {
432 assert_eq!(to_thumb("clip.mp4"), "clip.thumb.jpg");
433 }
434
435 #[test]
436 fn test_to_thumb_mov_uppercase() {
437 assert_eq!(to_thumb("clip.MOV"), "clip.thumb.jpg");
438 }
439
440 #[test]
441 fn test_to_thumb_mp4_uppercase() {
442 assert_eq!(to_thumb("clip.MP4"), "clip.thumb.jpg");
443 }
444
445 #[test]
446 fn test_to_thumb_mp4_mixed() {
447 assert_eq!(to_thumb("clip.Mp4"), "clip.thumb.jpg");
448 }
449
450 #[test]
451 fn test_to_thumb_webm() {
452 assert_eq!(to_thumb("clip.webm"), "clip.thumb.jpg");
453 }
454
455 #[test]
456 fn test_to_thumb_with_directory() {
457 assert_eq!(to_thumb("videos/clip.mov"), "videos/clip.thumb.jpg");
458 }
459
460 #[test]
461 fn test_to_thumb_with_relative_prefix() {
462 assert_eq!(to_thumb("../clip.mp4"), "../clip.thumb.jpg");
463 }
464
465 #[test]
466 fn test_to_thumb_unicode() {
467 assert_eq!(to_thumb("videos/冬日之歌.mov"), "videos/冬日之歌.thumb.jpg");
468 }
469
470 #[test]
471 fn test_to_thumb_space() {
472 assert_eq!(to_thumb("./Morning Mist.MOV"), "./Morning Mist.thumb.jpg");
473 }
474
475 // ── to_thumb_if_video tests ────────────────────────────────────
476
477 #[test]
478 fn test_to_thumb_if_video_recognizes_lowercase_extensions() {
479 assert_eq!(to_thumb_if_video("clip.mov").as_deref(), Some("clip.thumb.jpg"));
480 assert_eq!(to_thumb_if_video("clip.mp4").as_deref(), Some("clip.thumb.jpg"));
481 assert_eq!(to_thumb_if_video("clip.webm").as_deref(), Some("clip.thumb.jpg"));
482 }
483
484 #[test]
485 fn test_to_thumb_if_video_recognizes_uppercase_extensions() {
486 // Regression for the original bug: uppercase video extensions
487 // (.MOV from iPhone, .MP4 from some cameras) must be detected.
488 assert_eq!(to_thumb_if_video("clip.MOV").as_deref(), Some("clip.thumb.jpg"));
489 assert_eq!(to_thumb_if_video("clip.MP4").as_deref(), Some("clip.thumb.jpg"));
490 assert_eq!(to_thumb_if_video("clip.Mp4").as_deref(), Some("clip.thumb.jpg"));
491 assert_eq!(to_thumb_if_video("clip.WEBM").as_deref(), Some("clip.thumb.jpg"));
492 }
493
494 #[test]
495 fn test_to_thumb_if_video_with_directory() {
496 assert_eq!(to_thumb_if_video("音乐/cover.mp4").as_deref(), Some("音乐/cover.thumb.jpg"));
497 assert_eq!(to_thumb_if_video("../clip.MOV").as_deref(), Some("../clip.thumb.jpg"));
498 }
499
500 #[test]
501 fn test_to_thumb_if_video_returns_none_for_images() {
502 assert_eq!(to_thumb_if_video("photo.jpg"), None);
503 assert_eq!(to_thumb_if_video("photo.png"), None);
504 assert_eq!(to_thumb_if_video("photo.webp"), None);
505 assert_eq!(to_thumb_if_video("photo.JPEG"), None);
506 }
507
508 #[test]
509 fn test_to_thumb_if_video_returns_none_for_unknown() {
510 // Unlike `to_thumb`, no `.thumb.jpg` fallback is appended.
511 // Callers using this helper want a definitive yes/no.
512 assert_eq!(to_thumb_if_video("file.gif"), None);
513 assert_eq!(to_thumb_if_video("file"), None);
514 assert_eq!(to_thumb_if_video(""), None);
515 }
516
517 // ── to_webp tests ──────────────────────────────────────────────
518
519 #[test]
520 fn test_to_webp_jpg() {
521 assert_eq!(to_webp("photo.jpg"), "photo.webp");
522 }
523
524 #[test]
525 fn test_to_webp_jpg_uppercase() {
526 assert_eq!(to_webp("photo.JPG"), "photo.webp");
527 }
528
529 #[test]
530 fn test_to_webp_jpeg() {
531 assert_eq!(to_webp("photo.jpeg"), "photo.webp");
532 }
533
534 #[test]
535 fn test_to_webp_jpeg_uppercase() {
536 assert_eq!(to_webp("photo.JPEG"), "photo.webp");
537 }
538
539 #[test]
540 fn test_to_webp_png() {
541 assert_eq!(to_webp("photo.png"), "photo.webp");
542 }
543
544 #[test]
545 fn test_to_webp_png_uppercase() {
546 assert_eq!(to_webp("photo.PNG"), "photo.webp");
547 }
548
549 #[test]
550 fn test_to_webp_already_webp() {
551 assert_eq!(to_webp("photo.webp"), "photo.webp");
552 }
553
554 #[test]
555 fn test_to_webp_webp_uppercase() {
556 assert_eq!(to_webp("photo.WEBP"), "photo.webp");
557 }
558
559 #[test]
560 fn test_to_webp_with_directory() {
561 assert_eq!(to_webp("images/photo.jpg"), "images/photo.webp");
562 }
563
564 #[test]
565 fn test_to_webp_with_relative_prefix() {
566 assert_eq!(to_webp("../photo.png"), "../photo.webp");
567 }
568
569 #[test]
570 fn test_to_webp_with_dot_prefix() {
571 assert_eq!(to_webp("./photo.JPG"), "./photo.webp");
572 }
573
574 #[test]
575 fn test_to_webp_unicode_filename() {
576 assert_eq!(to_webp("images/冬日之歌.jpg"), "images/冬日之歌.webp");
577 }
578
579 #[test]
580 fn test_to_webp_space_in_name() {
581 assert_eq!(to_webp("./Morning Mist.png"), "./Morning Mist.webp");
582 }
583
584 #[test]
585 fn test_to_webp_unknown_extension_fallback() {
586 // Unknown extensions fall back to appending .webp (matches to_thumb's defensive style).
587 assert_eq!(to_webp("file.gif"), "file.gif.webp");
588 }
589
590 // ── is_ladder_source_ext tests ─────────────────────────────────
591
592 #[test]
593 fn is_ladder_source_ext_accepts_png_jpg_jpeg_webp_case_insensitively() {
594 // Phase B (Task 12) added webp/WEBP: a webp SOURCE now participates in
595 // the responsive ladder (emitted on `<img srcset>`, not `<picture>`).
596 for ext in [
597 "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "Jpg", "jPeG", "webp", "WEBP", "WebP",
598 ] {
599 assert!(is_ladder_source_ext(ext), "{ext} must be a ladder source");
600 }
601 }
602
603 #[test]
604 fn is_ladder_source_ext_rejects_non_ladder_formats() {
605 // webp joined in Phase B (asserted above); these never join.
606 for ext in ["gif", "svg", "avif", "heic", "bmp", "tiff", ""] {
607 assert!(!is_ladder_source_ext(ext), "{ext} must NOT be a ladder source");
608 }
609 }
610
611 #[test]
612 fn is_webp_source_ext_matches_only_webp() {
613 // The conversion-vs-webp split: webp is a ladder source that is NOT a
614 // `<picture>` conversion (it emits `<img srcset>` and can skip a small
615 // re-encode). png/jpg/jpeg are ladder sources that ARE conversions.
616 for ext in ["webp", "WEBP", "WebP"] {
617 assert!(is_webp_source_ext(ext), "{ext} must be a webp source");
618 }
619 for ext in ["png", "PNG", "jpg", "jpeg", "gif", "svg", ""] {
620 assert!(!is_webp_source_ext(ext), "{ext} must NOT be a webp source");
621 }
622 }
623
624 // ── ladder tests ───────────────────────────────────────────────
625
626 #[test]
627 fn ladder_is_strictly_ascending() {
628 assert!(LADDER.windows(2).all(|w| w[0] < w[1]));
629 }
630
631 #[test]
632 fn ladder_rungs_below_deployed_base_only() {
633 assert_eq!(ladder_rungs(2000, 1200, false), &[800, 1600][..]);
634 assert_eq!(ladder_rungs(1601, 900, false), &[800, 1600][..]);
635 // strict: no upscale, no dup of base
636 assert_eq!(ladder_rungs(1600, 900, false), &[800][..]);
637 assert_eq!(ladder_rungs(801, 600, false), &[800][..]);
638 assert_eq!(ladder_rungs(800, 600, false), &[] as &[u32]);
639 assert_eq!(ladder_rungs(0, 0, false), &[] as &[u32]);
640 }
641
642 #[test]
643 fn ladder_rungs_capped_width_never_duplicates_base() {
644 // 4000px-wide landscape deploys at DEPLOY_MAX_EDGE (2400) wide;
645 // rungs must stay below the cap.
646 assert_eq!(ladder_rungs(4000, 3000, false), &[800, 1600][..]);
647 assert_eq!(ladder_rungs(2400, 1600, false), &[800, 1600][..]);
648 // Square at exactly the cap: base 2400, both rungs below it.
649 assert_eq!(ladder_rungs(2400, 2400, false), &[800, 1600][..]);
650 }
651
652 #[test]
653 fn ladder_rungs_portrait_uses_post_resize_width() {
654 // 3024×4032 portrait: the encoder shrinks the longest EDGE to 2400,
655 // so the deployed base is 1800 wide — both rungs still below it.
656 assert_eq!(ladder_rungs(3024, 4032, false), &[800, 1600][..]);
657 // Extreme portrait 1179×8000: base width 353 — NO rung is below it,
658 // so the ladder must be empty (a w800 rung would be WIDER than the
659 // base: ladder inversion).
660 assert_eq!(ladder_rungs(1179, 8000, false), &[] as &[u32]);
661 // 1200×3600: base width exactly 800 — strict `<` excludes the 800 rung.
662 assert_eq!(ladder_rungs(1200, 3600, false), &[] as &[u32]);
663 }
664
665 #[test]
666 fn ladder_rungs_animated_is_empty() {
667 assert_eq!(ladder_rungs(2000, 1200, true), &[] as &[u32]);
668 }
669
670 #[test]
671 fn deployed_width_caps_longest_edge() {
672 // Landscape: width IS the longest edge — capped directly.
673 assert_eq!(deployed_width(4000, 3000), 2400);
674 assert_eq!(deployed_width(2000, 1200), 2000);
675 // Portrait: HEIGHT is the longest edge; width shrinks by the same
676 // aspect-preserving ratio the encoder applies.
677 assert_eq!(deployed_width(3024, 4032), 1800);
678 assert_eq!(deployed_width(1179, 8000), 353);
679 assert_eq!(deployed_width(1200, 3600), 800);
680 // Square at the cap: untouched.
681 assert_eq!(deployed_width(2400, 2400), 2400);
682 // Degenerate sliver never collapses to 0.
683 assert_eq!(deployed_width(1, 100_000), 1);
684 }
685
686 #[test]
687 fn to_webp_rung_inserts_width_suffix() {
688 assert_eq!(to_webp_rung("photo.jpg", 800), "photo.w800.webp");
689 assert_eq!(to_webp_rung("a/b/photo.PNG", 1600), "a/b/photo.w1600.webp");
690 assert_eq!(to_webp_rung("photo.webp", 800), "photo.w800.webp");
691 }
692
693 #[test]
694 fn to_webp_rung_unknown_extension_fallback() {
695 // Unknown extensions inherit to_webp's defensive-append behavior.
696 assert_eq!(to_webp_rung("file.gif", 800), "file.gif.w800.webp");
697 }
698}