Skip to main content

makeover_build/
drift.rs

1//! Checks that a hand-written frontend still agrees with the crate that
2//! generates its siblings.
3//!
4//! The generated files cannot drift: they ask makeover-geometry for the answer.
5//! The hand-written ones state it, and a stylesheet or a script that disagrees
6//! with the crate is not an error at any point -- it is a rule that quietly
7//! stops matching where it used to. Cheaper to read a panic naming the line.
8//!
9//! Deliberately assertions and not substitutions. A JS or CSS file that has to
10//! be generated to be correct stops being readable on its own, and it is worth
11//! something that you can still open the frontend in a browser and have it
12//! work.
13
14use std::path::{Path, PathBuf};
15
16use makeover_geometry::{Density, SizeClass};
17use makeover_webview::Emit;
18
19/// The declaration this check reads. Shared vocabulary, not a parameter: two
20/// apps and a server naming the same string want the same name for it.
21const CONST_NAME: &str = "TOUCH_DENSITY";
22
23/// The capability sniffs the media query replaced, so neither can come back by
24/// copy-paste.
25///
26/// Both ask the hardware what it has rather than what is pointing at the
27/// screen, so both say yes to a touchscreen laptop driving a mouse.
28const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
29
30/// Fail the build if a JS copy of the touch-density query has drifted from
31/// [`Density::Touch`].
32///
33/// Every `.js` file under `js_dir`, recursively, must state the crate's own
34/// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must
35/// declare it, and no file may name a capability sniff.
36///
37/// The string is the crate's and no app gets a say in it, which is why this
38/// check takes no policy argument. The generated `geometry.css` already keys
39/// its touch gap overrides on the same condition, so the gestures and the
40/// spacing agree by construction rather than by two people remembering.
41///
42/// Emits `cargo:rerun-if-changed` for every file it read.
43///
44/// # Panics
45///
46/// If `js_dir` cannot be read, if no declaration is found, or if any file
47/// disagrees with the crate. A build script has nowhere useful to return an
48/// error to, and a frontend that disagrees with its own stylesheet is worse
49/// than a failed build.
50pub fn check_touch_density(js_dir: impl AsRef<Path>) {
51    let js_dir = js_dir.as_ref();
52    let want = Density::Touch.media_condition();
53    let mut wrong: Vec<String> = Vec::new();
54    let mut found = 0usize;
55
56    let files = js_files(js_dir);
57    for path in &files {
58        let src = std::fs::read_to_string(path).expect("read js file");
59        let name = path
60            .strip_prefix(js_dir)
61            .unwrap_or(path)
62            .display()
63            .to_string();
64
65        for (offset, literal) in touch_density_literals(&src) {
66            found += 1;
67            if literal != want {
68                wrong.push(format!(
69                    "  {name}:{}  {CONST_NAME} = '{literal}'",
70                    line_of(&src, offset)
71                ));
72            }
73        }
74
75        for needle in SNIFFS {
76            if let Some(offset) = src.find(needle) {
77                wrong.push(format!(
78                    "  {name}:{}  {needle} -- device sniff, not a density question",
79                    line_of(&src, offset)
80                ));
81            }
82        }
83    }
84
85    assert!(
86        found > 0,
87        "no {CONST_NAME} literal found under {}.\n\n\
88         A frontend that asks whether it is being touched states\n\
89         makeover_geometry::Density::Touch's media condition in a const of that\n\
90         name, and this check exists to keep every copy equal to it. If the\n\
91         const was renamed, rename it back rather than dropping the check; if\n\
92         this frontend genuinely asks no density question, drop the call.",
93        js_dir.display()
94    );
95
96    assert!(
97        wrong.is_empty(),
98        "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
99         Density::Touch.media_condition() is: {want}\n\n\
100         Wrong:\n{}\n\n\
101         Fix the JS to state the crate's string. Never widen it to catch a\n\
102         device the query misses: density is what is pointing at the screen,\n\
103         and a laptop with a touchscreen and a mouse is a pointer device.",
104        wrong.join("\n")
105    );
106
107    for path in &files {
108        println!("cargo:rerun-if-changed={}", path.display());
109    }
110}
111
112/// Every `.js` file under `dir`, recursively, sorted.
113fn js_files(dir: &Path) -> Vec<PathBuf> {
114    files_with_extension(dir, "js")
115}
116
117/// Every file under `dir` with extension `ext`, recursively, sorted.
118///
119/// Recursive because a consumer's frontend is not always one flat directory:
120/// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
121/// `static/`, and a check that silently skipped the nested half would report
122/// clean on the files most likely to have been copied.
123fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
124    let mut out = Vec::new();
125    let mut stack = vec![dir.to_path_buf()];
126    while let Some(d) = stack.pop() {
127        for entry in std::fs::read_dir(&d)
128            .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
129            .flatten()
130        {
131            let path = entry.path();
132            if path.is_dir() {
133                stack.push(path);
134            } else if path.extension().is_some_and(|x| x == ext) {
135                out.push(path);
136            }
137        }
138    }
139    out.sort();
140    out
141}
142
143/// `(byte offset of the declaration, the literal's contents)` for every
144/// `const TOUCH_DENSITY = '...'` in a JS source.
145fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
146    let mut out = Vec::new();
147    let mut at = 0;
148    while let Some(i) = src[at..].find(CONST_NAME) {
149        let start = at + i;
150        at = start + CONST_NAME.len();
151        // Only the declaration states the string; a use site reads the const.
152        let Some(rest) = src[at..].strip_prefix(" = ") else {
153            continue;
154        };
155        let open = at + " = ".len();
156        let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
157            continue;
158        };
159        let body = open + 1;
160        if let Some(j) = src[body..].find(quote) {
161            out.push((start, &src[body..body + j]));
162            at = body + j + 1;
163        }
164    }
165    out
166}
167
168fn line_of(src: &str, offset: usize) -> usize {
169    src[..offset].matches('\n').count() + 1
170}
171
172/// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
173///
174/// Every pixel width named by a media query under `frontend/css` or
175/// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of
176/// `tuning_widths`.
177///
178/// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
179/// stylesheets and silently leaves every hand-written query behind, and what
180/// you get is not an error but a stylesheet that disagrees with itself at the
181/// old boundary.
182///
183/// `tuning_widths` is the one thing an app gets a say in, which is why this
184/// takes a parameter where [`check_touch_density`] does not. A shell boundary
185/// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is
186/// a point inside a shell where something reflows without the shell changing --
187/// a dashboard dropping from three columns to two, a pane's width cap ending.
188/// Nothing switches shells at one, so it should not move when a size class
189/// does. Pass `&[]` if the app has none, and treat every addition as owing a
190/// note saying what it tunes: the list is where a genuine boundary goes to hide
191/// from this check.
192///
193/// The generated stylesheets are scanned too, and pass by construction: they
194/// ask makeover-geometry for the number rather than stating it. Scanning them
195/// costs nothing and means a consumer never has to name which files are
196/// hand-written.
197///
198/// Emits `cargo:rerun-if-changed` for every file it read.
199///
200/// # Panics
201///
202/// If `frontend/css` or `frontend/js` cannot be read, or if any width is
203/// neither a size-class boundary nor a declared tuning width. A build script
204/// has nowhere useful to return an error to.
205pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
206    let frontend = frontend.as_ref();
207    let mut files = files_with_extension(&frontend.join("css"), "css");
208    files.extend(js_files(&frontend.join("js")));
209    check_paths(&files, tuning_widths, Some(frontend));
210}
211
212/// [`check_breakpoints`] against a named list of files rather than a tree.
213///
214/// For a frontend whose generated and hand-written files share a directory, so
215/// there is nothing to point a directory scan at: the MNW server keeps both
216/// under `static/` alongside a bundler's output, and bundled third-party CSS
217/// is exactly the place a width nobody chose would come from.
218///
219/// The cost is that the list is hand-maintained, and a stylesheet nobody adds
220/// to it is unchecked rather than failing. Prefer [`check_breakpoints`] where
221/// the layout allows it.
222///
223/// A `.js` path is parsed as script and anything else as stylesheet, which is
224/// the only difference: a media condition is parenthesised in both.
225///
226/// # Panics
227///
228/// If a listed file cannot be read -- a listed path that no longer exists is a
229/// check silently covering less than it says -- or if any width is neither a
230/// size-class boundary nor a declared tuning width.
231pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
232    let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
233    check_paths(&paths, tuning_widths, None);
234}
235
236/// The check itself. `root`, when given, is stripped from reported paths.
237fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
238    let allowed = allowed_widths(tuning_widths);
239    let mut stale: Vec<String> = Vec::new();
240
241    for path in paths {
242        let raw = std::fs::read_to_string(path)
243            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
244        let name = match root {
245            Some(root) => display_name(root, path),
246            None => path.display().to_string(),
247        };
248
249        if path.extension().is_some_and(|x| x == "js") {
250            // No declarations in JS, so any parenthesised width is a query.
251            for (offset, px) in js_widths(&raw) {
252                if !allowed.contains(&px) {
253                    stale.push(format!("  {name}:{}  ({px}px)", line_of(&raw, offset)));
254                }
255            }
256            continue;
257        }
258
259        // Comments first: a note about a breakpoint that used to be here is
260        // prose, not a rule, and should not fail a build.
261        let src = strip_block_comments(&raw);
262        for (offset, condition) in media_conditions(&src) {
263            for px in media_widths(condition) {
264                if !allowed.contains(&px) {
265                    stale.push(format!(
266                        "  {name}:{}  @media{condition}  ({px}px)",
267                        line_of(&src, offset)
268                    ));
269                }
270            }
271        }
272    }
273
274    assert!(
275        stale.is_empty(),
276        "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
277         Allowed: {allowed:?}\n\
278         ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
279         Stale:\n{}\n\n\
280         If a size class moved, update these to match. If one of these is a new\n\
281         tuning width inside the wide shell rather than a shell boundary, add it\n\
282         to the caller's tuning list with a note saying what it tunes.\n\n\
283         Best of all, make the rule dimensional so it needs no threshold: a grid\n\
284         wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
285         clamp(). A threshold is for what appears and disappears.",
286        allowed
287            .iter()
288            .filter(|px| !tuning_widths.contains(px))
289            .collect::<Vec<_>>(),
290        stale.join("\n")
291    );
292
293    for path in paths {
294        println!("cargo:rerun-if-changed={}", path.display());
295    }
296}
297
298/// A path as the frontend sees it, for an error a reader can act on.
299fn display_name(frontend: &Path, path: &Path) -> String {
300    path.strip_prefix(frontend)
301        .unwrap_or(path)
302        .display()
303        .to_string()
304}
305
306/// Every width a hand-written media query is allowed to name.
307///
308/// Read out of [`SizeClass::media_condition`] rather than typed, which is the
309/// whole point: that is the one place the numbers come from, and a bump in
310/// makeover-geometry has to reach the stylesheet through here.
311fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
312    let mut widths: Vec<u16> = SizeClass::all()
313        .iter()
314        .flat_map(|c| media_widths(&c.media_condition()))
315        .collect();
316    widths.extend_from_slice(tuning_widths);
317    widths.sort_unstable();
318    widths.dedup();
319    widths
320}
321
322/// The pixel values in a media condition, in the order they appear.
323fn media_widths(condition: &str) -> Vec<u16> {
324    let mut out = Vec::new();
325    let mut rest = condition;
326    while let Some(i) = rest.find("-width:") {
327        rest = &rest[i + "-width:".len()..];
328        let digits: String = rest
329            .trim_start()
330            .chars()
331            .take_while(char::is_ascii_digit)
332            .collect();
333        if let Ok(px) = digits.parse() {
334            out.push(px);
335        }
336    }
337    out
338}
339
340/// `(byte offset of the `@media`, the condition text before the `{`)`.
341fn media_conditions(css: &str) -> Vec<(usize, &str)> {
342    let mut out = Vec::new();
343    let mut at = 0;
344    while let Some(i) = css[at..].find("@media") {
345        let start = at + i;
346        let after = start + "@media".len();
347        match css[after..].find('{') {
348            Some(j) => {
349                out.push((start, &css[after..after + j]));
350                at = after + j;
351            }
352            None => break,
353        }
354    }
355    out
356}
357
358/// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
359///
360/// The parentheses are the whole test, and they have to be: a media condition
361/// is always parenthesized and a CSS declaration never is, so `'max-width:
362/// 320px'` in an inline-style string is not a breakpoint and must not read as
363/// one. goingson's shared-updater.js builds exactly that, and the first version
364/// of this check failed the build on it.
365fn js_widths(src: &str) -> Vec<(usize, u16)> {
366    let mut out = Vec::new();
367    for pat in ["(max-width:", "(min-width:"] {
368        let mut at = 0;
369        while let Some(i) = src[at..].find(pat) {
370            let start = at + i;
371            let rest = src[start + pat.len()..].trim_start();
372            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
373            if let Ok(px) = digits.parse()
374                && rest[digits.len()..].starts_with("px)")
375            {
376                out.push((start, px));
377            }
378            at = start + pat.len();
379        }
380    }
381    out
382}
383
384/// Replace every `/* ... */` with spaces, so byte offsets still line up.
385fn strip_block_comments(css: &str) -> String {
386    let bytes = css.as_bytes();
387    let mut out = String::with_capacity(css.len());
388    let mut i = 0;
389    while i < bytes.len() {
390        if bytes[i..].starts_with(b"/*") {
391            let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
392            for c in css[i..end].chars() {
393                out.push(if c == '\n' { '\n' } else { ' ' });
394            }
395            i = end;
396        } else {
397            let c = css[i..].chars().next().unwrap();
398            out.push(c);
399            i += c.len_utf8();
400        }
401    }
402    out
403}
404
405/// Fail the build if a hand-written stylesheet re-specifies a class the
406/// generated one already defines.
407///
408/// The generated sheet sits in `@layer makeover`. Unlayered app CSS beats a
409/// layer by construction, whatever the specificity, so an app rule naming a
410/// generated class does not merge with it: it wins, silently, and the design
411/// system's version of that component stops applying to the one app most likely
412/// to be treated as the reference. Both sort-caret defects found on 2026-08-11
413/// were this, and both were live for months because nothing looked.
414///
415/// `frontend` is the directory holding `css/`. `generated` names the sheets
416/// this crate writes, relative to `frontend/css`, which are skipped: the
417/// generated file naming a generated class is the point.
418///
419/// Emits `cargo:rerun-if-changed` for every file it read.
420///
421/// # Panics
422///
423/// If `frontend/css` cannot be read, or if any hand-written sheet defines a
424/// rule for a generated class. A build script has nowhere useful to return an
425/// error to, and an app quietly overriding its own design system is worse than
426/// a failed build.
427pub fn check_vocabulary(frontend: impl AsRef<Path>, opts: &Emit, generated: &[&str]) {
428    let frontend = frontend.as_ref();
429    let css = frontend.join("css");
430    let files: Vec<PathBuf> = files_with_extension(&css, "css")
431        .into_iter()
432        .filter(|p| {
433            let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
434            !generated.contains(&name.as_str())
435        })
436        .collect();
437    check_vocabulary_paths(&files, opts, Some(frontend));
438}
439
440/// [`check_vocabulary`] against a named list of files rather than a tree.
441///
442/// For a frontend whose generated and hand-written sheets share a directory, so
443/// a directory scan has nothing to point at. Same trade as
444/// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet
445/// nobody adds to it is unchecked rather than failing.
446///
447/// # Panics
448///
449/// If a listed file cannot be read, or if any of them re-specifies a generated
450/// class.
451pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit) {
452    let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
453    check_vocabulary_paths(&paths, opts, None);
454}
455
456/// The check itself. `root`, when given, is stripped from reported paths.
457fn check_vocabulary_paths(paths: &[PathBuf], opts: &Emit, root: Option<&Path>) {
458    let generated = makeover_webview::vocabulary::vocabulary(opts);
459    let mut clashes: Vec<String> = Vec::new();
460
461    for path in paths {
462        println!("cargo::rerun-if-changed={}", path.display());
463        let raw = std::fs::read_to_string(path)
464            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
465        let name = match root {
466            Some(root) => display_name(root, path),
467            None => path.display().to_string(),
468        };
469        // Read the app's sheet the same way the crate reads its own, or the two
470        // sides are not comparable.
471        let local = makeover_webview::vocabulary::classes_in_css(&raw);
472        for class in local.intersection(&generated) {
473            clashes.push(format!("  {name}  .{class}"));
474        }
475    }
476
477    assert!(
478        clashes.is_empty(),
479        "{} hand-written rule(s) re-specify a class the generated stylesheet already \
480         defines. App CSS is unlayered and beats @layer makeover, so each of these wins \
481         over the design system silently:\n{}\n\nDelete the local rule, or, if it adds \
482         something makeover does not answer for, move the addition onto a class of the \
483         app's own. Count the consumers before deciding it is a divergence worth keeping.",
484        clashes.len(),
485        clashes.join("\n")
486    );
487}
488
489/// Warn when the generated vocabulary has grown dead, and fail when it grows
490/// deader than the recorded high-water mark.
491///
492/// A generated class no markup emits is a rule shipped to every user for
493/// nothing, and the proportion was large when it was first measured: 42% of the
494/// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced
495/// Breakfast. Those are not failures on their own or no app would build. What
496/// this converts is the direction: dead vocabulary becoming a number in a build
497/// script means a change that worsens it stops being something somebody
498/// notices.
499///
500/// One-sided, the same shape as the MNW server's `frontend_globals` seal:
501/// exceeding `high_water` fails, coming in under it warns and asks for the seal
502/// to be lowered. A build that fails because dead CSS was deleted would teach
503/// the wrong lesson.
504///
505/// `markup` is every file that can carry a class: templates, `.js`, `.html`,
506/// and any Rust that writes markup. A class is counted as used if its name
507/// appears in any of them, which is deliberately generous. A stricter reading
508/// would need to know how each app builds its class strings, and a check that
509/// guesses wrong fails a correct build.
510///
511/// # Panics
512///
513/// If a listed file cannot be read, or if more classes are unused than
514/// `high_water`.
515pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
516    let generated = makeover_webview::vocabulary::names(opts);
517    let mut haystack = String::new();
518    for path in markup {
519        let path = path.as_ref();
520        println!("cargo::rerun-if-changed={}", path.display());
521        haystack.push_str(
522            &std::fs::read_to_string(path)
523                .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
524        );
525        haystack.push('\n');
526    }
527
528    let unused: Vec<&String> = generated
529        .iter()
530        .filter(|class| !haystack.contains(class.as_str()))
531        .collect();
532
533    assert!(
534        unused.len() <= high_water,
535        "{} of {} generated classes are emitted by no markup, above the recorded {}. \
536         The vocabulary grew or the markup stopped using it:\n{}",
537        unused.len(),
538        generated.len(),
539        high_water,
540        unused
541            .iter()
542            .map(|c| format!("  .{c}"))
543            .collect::<Vec<_>>()
544            .join("\n")
545    );
546
547    if unused.len() < high_water {
548        println!(
549            "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
550             lower the seal so it cannot grow back",
551            unused.len(),
552            high_water
553        );
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    fn scratch(name: &str) -> PathBuf {
562        let dir =
563            std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
564        let _ = std::fs::remove_dir_all(&dir);
565        std::fs::create_dir_all(&dir).expect("create scratch");
566        dir
567    }
568
569    fn write(dir: &Path, name: &str, src: &str) {
570        if let Some(parent) = dir.join(name).parent() {
571            std::fs::create_dir_all(parent).unwrap();
572        }
573        std::fs::write(dir.join(name), src).unwrap();
574    }
575
576    fn declaring() -> String {
577        format!(
578            "const {CONST_NAME} = '{}';\n",
579            Density::Touch.media_condition()
580        )
581    }
582
583    #[test]
584    fn the_crates_own_string_passes() {
585        let dir = scratch("ok");
586        write(&dir, "touch.js", &declaring());
587        check_touch_density(&dir);
588    }
589
590    #[test]
591    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
592    fn a_drifted_literal_fails() {
593        let dir = scratch("drift");
594        write(&dir, "touch.js", &declaring());
595        write(
596            &dir,
597            "haptics.js",
598            &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
599        );
600        check_touch_density(&dir);
601    }
602
603    #[test]
604    #[should_panic(expected = "device sniff")]
605    fn the_sniff_cannot_come_back() {
606        let dir = scratch("sniff");
607        write(&dir, "touch.js", &declaring());
608        write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
609        check_touch_density(&dir);
610    }
611
612    #[test]
613    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
614    fn a_frontend_that_states_nothing_fails() {
615        let dir = scratch("empty");
616        write(&dir, "app.js", "export const x = 1;\n");
617        check_touch_density(&dir);
618    }
619
620    #[test]
621    fn a_use_site_is_not_a_declaration() {
622        // The const is read far more often than it is declared, and a read
623        // states no string. Counting one as a declaration would make the
624        // `found > 0` assertion pass on a frontend that only imports it.
625        let src =
626            format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
627        assert!(touch_density_literals(&src).is_empty());
628    }
629
630    #[test]
631    fn nested_files_are_read() {
632        // The server keeps its scripts in subdirectories, and the nested half
633        // is the half most likely to be a copy.
634        let dir = scratch("nested");
635        write(&dir, "touch.js", &declaring());
636        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
637        let files = js_files(&dir);
638        assert_eq!(files.len(), 2);
639    }
640
641    #[test]
642    fn a_non_js_file_is_ignored() {
643        let dir = scratch("nonjs");
644        write(&dir, "touch.js", &declaring());
645        write(&dir, "styles.css", "body { }\n");
646        assert_eq!(js_files(&dir).len(), 1);
647    }
648
649    fn frontend(name: &str) -> PathBuf {
650        let dir = scratch(name);
651        std::fs::create_dir_all(dir.join("css")).unwrap();
652        std::fs::create_dir_all(dir.join("js")).unwrap();
653        dir
654    }
655
656    /// A width every size class agrees is a boundary.
657    fn boundary() -> u16 {
658        SizeClass::Medium.min_px()
659    }
660
661    #[test]
662    fn the_crates_own_boundaries_pass() {
663        let dir = frontend("bp-ok");
664        write(
665            &dir,
666            "css/styles.css",
667            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
668        );
669        check_breakpoints(&dir, &[]);
670    }
671
672    #[test]
673    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
674    fn a_stale_css_width_fails() {
675        let dir = frontend("bp-css");
676        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
677        check_breakpoints(&dir, &[]);
678    }
679
680    #[test]
681    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
682    fn a_stale_js_width_fails() {
683        let dir = frontend("bp-js");
684        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
685        check_breakpoints(&dir, &[]);
686    }
687
688    #[test]
689    fn a_declared_tuning_width_passes() {
690        let dir = frontend("bp-tuning");
691        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
692        check_breakpoints(&dir, &[1400]);
693    }
694
695    #[test]
696    fn a_width_in_a_comment_is_prose() {
697        // The note explaining which breakpoint used to be here is not a rule,
698        // and failing a build on documentation would teach people to delete it.
699        let dir = frontend("bp-comment");
700        write(
701            &dir,
702            "css/styles.css",
703            "/* was @media (max-width: 768px) until the size classes landed */\n",
704        );
705        check_breakpoints(&dir, &[]);
706    }
707
708    #[test]
709    fn an_unparenthesized_width_is_not_a_breakpoint() {
710        // A JS string building an inline style states `max-width: 320px` with
711        // no parentheses. It is a declaration, not a query, and the first
712        // version of this check failed the build on one.
713        let dir = frontend("bp-inline");
714        write(
715            &dir,
716            "js/style.js",
717            "el.style.cssText = 'max-width: 320px; display: block';\n",
718        );
719        check_breakpoints(&dir, &[]);
720    }
721
722    #[test]
723    fn nested_css_is_read() {
724        // Same argument as the touch check: the nested half is the half most
725        // likely to be a copy.
726        let dir = frontend("bp-nested");
727        write(
728            &dir,
729            "css/screens/detail.css",
730            "@media (max-width: 768px) { }\n",
731        );
732        let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
733        assert!(found.is_err(), "a nested stylesheet must be scanned");
734    }
735
736    #[test]
737    fn a_named_list_is_checked() {
738        let dir = frontend("bp-list");
739        write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
740        let listed = dir.join("css/style.css");
741        let err =
742            std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
743        let msg = err.downcast_ref::<String>().expect("String payload");
744        assert!(msg.contains("style.css:1"), "got: {msg}");
745    }
746
747    #[test]
748    #[should_panic(expected = "read ")]
749    fn a_listed_file_that_is_gone_fails() {
750        // The list is hand-maintained, so a path that stopped existing is a
751        // check quietly covering less than it claims. Louder than skipping it.
752        let dir = frontend("bp-missing");
753        check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
754    }
755
756    #[test]
757    fn a_listed_js_file_is_parsed_as_script() {
758        // The unparenthesized-declaration rule is what separates the two, and
759        // picking the parser off the extension is the whole difference.
760        let dir = frontend("bp-list-js");
761        write(
762            &dir,
763            "js/style.js",
764            "el.style.cssText = 'max-width: 320px';\n",
765        );
766        check_breakpoints_files(&[dir.join("js/style.js")], &[]);
767    }
768
769    #[test]
770    fn the_error_names_the_file_and_line() {
771        let dir = frontend("bp-message");
772        write(
773            &dir,
774            "css/styles.css",
775            "body { }\n@media (max-width: 768px) { }\n",
776        );
777        let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
778        let msg = err
779            .downcast_ref::<String>()
780            .expect("panic payload is a String");
781        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
782    }
783
784    #[test]
785    fn a_rule_restating_a_generated_class_fails_and_names_it() {
786        let dir = scratch("vocab-clash");
787        // `.card` is makeover's. An app rule for it beats the generated one,
788        // because app CSS is unlayered and the generated sheet is not.
789        write(
790            &dir,
791            "css/styles.css",
792            "body { color: red; }\n.card { box-shadow: none; }\n",
793        );
794        let err =
795            std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
796        let msg = err
797            .downcast_ref::<String>()
798            .expect("panic payload is a String");
799        assert!(msg.contains(".card"), "got: {msg}");
800        assert!(msg.contains("css/styles.css"), "got: {msg}");
801    }
802
803    #[test]
804    fn an_app_class_of_its_own_is_left_alone() {
805        let dir = scratch("vocab-clean");
806        write(
807            &dir,
808            "css/styles.css",
809            ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
810        );
811        check_vocabulary(&dir, &Emit::default(), &[]);
812    }
813
814    #[test]
815    fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
816        let dir = scratch("vocab-generated");
817        let opts = Emit::default();
818        write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
819        // Without the skip this is the loudest failure possible: every class in
820        // the vocabulary, reported as a clash with the vocabulary.
821        check_vocabulary(&dir, &opts, &["layout.css"]);
822    }
823
824    #[test]
825    fn a_prefixed_app_is_checked_against_its_own_prefix() {
826        let dir = scratch("vocab-prefix");
827        let opts = Emit {
828            class_prefix: "mo-",
829            ..Emit::default()
830        };
831        // Bare `.card` is the app's own class once the generated sheet writes
832        // `.mo-card`, so this has to pass.
833        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
834        check_vocabulary(&dir, &opts, &[]);
835
836        let dir = scratch("vocab-prefix-clash");
837        write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
838        assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
839    }
840
841    #[test]
842    fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
843        let dir = scratch("vocab-seal");
844        let opts = Emit::default();
845        let all = makeover_webview::vocabulary::names(&opts).len();
846        // Markup naming nothing: every class is unused.
847        write(&dir, "index.html", "<div></div>\n");
848        let markup = [dir.join("index.html")];
849
850        check_vocabulary_use(&markup, &opts, all);
851        assert!(
852            std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
853            "a vocabulary deader than the seal has to fail"
854        );
855    }
856
857    #[test]
858    fn both_quote_styles_read() {
859        let want = Density::Touch.media_condition();
860        for q in ['\'', '"'] {
861            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
862            let found = touch_density_literals(&src);
863            assert_eq!(found.len(), 1);
864            assert_eq!(found[0].1, want);
865        }
866    }
867}