1use std::path::{Path, PathBuf};
15
16use makeover_geometry::{Density, SizeClass};
17use makeover_webview::Emit;
18
19const CONST_NAME: &str = "TOUCH_DENSITY";
22
23const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
29
30pub 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
112fn js_files(dir: &Path) -> Vec<PathBuf> {
114 files_with_extension(dir, "js")
115}
116
117fn 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
143fn 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 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
172pub 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
212pub 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
236fn 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 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 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
298fn display_name(frontend: &Path, path: &Path) -> String {
300 path.strip_prefix(frontend)
301 .unwrap_or(path)
302 .display()
303 .to_string()
304}
305
306fn 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
322fn 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
340fn 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
358fn 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
384fn 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
405pub fn check_vocabulary(
490 frontend: impl AsRef<Path>,
491 opts: &Emit,
492 generated: &[&str],
493 allowed: &[(&str, &str)],
494 allowed_elements: &[(&str, &str, &str)],
495) {
496 check_vocabulary_with(frontend, opts, generated, &[], allowed, allowed_elements);
497}
498
499pub fn check_vocabulary_with(
518 frontend: impl AsRef<Path>,
519 opts: &Emit,
520 generated: &[&str],
521 upstream: &[&str],
522 allowed: &[(&str, &str)],
523 allowed_elements: &[(&str, &str, &str)],
524) {
525 let frontend = frontend.as_ref();
526 let css = frontend.join("css");
527 let files: Vec<PathBuf> = files_with_extension(&css, "css")
528 .into_iter()
529 .filter(|p| {
530 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
531 !generated.contains(&name.as_str())
532 })
533 .collect();
534 check_vocabulary_paths(
535 &files,
536 opts,
537 Some(frontend),
538 upstream,
539 allowed,
540 allowed_elements,
541 );
542}
543
544pub fn check_vocabulary_files<P: AsRef<Path>>(
555 paths: &[P],
556 opts: &Emit,
557 allowed: &[(&str, &str)],
558 allowed_elements: &[(&str, &str, &str)],
559) {
560 check_vocabulary_files_with(paths, opts, &[], allowed, allowed_elements);
561}
562
563pub fn check_vocabulary_files_with<P: AsRef<Path>>(
570 paths: &[P],
571 opts: &Emit,
572 upstream: &[&str],
573 allowed: &[(&str, &str)],
574 allowed_elements: &[(&str, &str, &str)],
575) {
576 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
577 check_vocabulary_paths(&paths, opts, None, upstream, allowed, allowed_elements);
578}
579
580fn check_vocabulary_paths(
582 paths: &[PathBuf],
583 opts: &Emit,
584 root: Option<&Path>,
585 upstream: &[&str],
586 allowed: &[(&str, &str)],
587 allowed_elements: &[(&str, &str, &str)],
588) {
589 let makeover =
590 makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
591 let mut owned = makeover.clone();
594 for sheet in upstream {
595 for (class, properties) in makeover_webview::vocabulary::declarations_by_class(sheet) {
596 owned.entry(class).or_default().extend(properties);
597 }
598 }
599 let mut clashes: Vec<String> = Vec::new();
600 let mut seen: Vec<(String, String)> = Vec::new();
601 let mut element_clashes: Vec<String> = Vec::new();
602 let mut element_seen: Vec<(String, String, String)> = Vec::new();
603
604 for path in paths {
605 println!("cargo::rerun-if-changed={}", path.display());
606 let raw = std::fs::read_to_string(path)
607 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
608 let name = match root {
609 Some(root) => display_name(root, path),
610 None => path.display().to_string(),
611 };
612 let generated = if upstream.contains(&raw.as_str()) {
615 &makeover
616 } else {
617 &owned
618 };
619 let local = makeover_webview::vocabulary::declarations_by_class(&raw);
622 for (class, properties) in &local {
623 let Some(theirs) = generated.get(class) else {
624 continue;
625 };
626 for property in properties.intersection(theirs) {
627 seen.push((class.clone(), property.clone()));
628 if allowed.contains(&(class.as_str(), property.as_str())) {
629 continue;
630 }
631 clashes.push(format!(" {name} .{class} {{ {property} }}"));
632 }
633 }
634
635 let mentioned = makeover_webview::vocabulary::mentions_by_class(&raw);
641 let by_element = makeover_webview::vocabulary::declarations_by_element(&raw);
642 for (element, properties) in &by_element {
643 for class in makeover_webview::vocabulary::classes_for_element(element, opts) {
644 let Some(theirs) = generated.get(&class) else {
645 continue;
646 };
647 for (property, rank) in properties {
648 if !theirs.contains(property) {
649 continue;
650 }
651 let spoken_for = mentioned
655 .get(&class)
656 .and_then(|properties| properties.get(property))
657 .is_some_and(|theirs| theirs >= rank);
658 if spoken_for {
659 continue;
660 }
661 element_seen.push((element.clone(), class.clone(), property.clone()));
662 if allowed_elements.contains(&(
663 element.as_str(),
664 class.as_str(),
665 property.as_str(),
666 )) {
667 continue;
668 }
669 element_clashes.push(format!(
670 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
671 ));
672 }
673 }
674 }
675 }
676
677 assert!(
678 clashes.is_empty(),
679 "{} hand-written declaration(s) take a property the generated stylesheet, \
680 or an upstream sheet named to this check, already sets on the same \
681 class. App CSS wins over @layer makeover, whether by a later layer or \
682 by being unlayered, so each of these wins over the design system \
683 silently:\n{}\n\nDelete the declaration, or, if \
684 it is a deliberate pairing on a different selector arm, add \
685 (class, property) to this check's allowed list and say why beside it. \
686 Count the consumers before deciding a divergence is worth keeping.",
687 clashes.len(),
688 clashes.join("\n")
689 );
690
691 assert!(
692 element_clashes.is_empty(),
693 "{} hand-written element rule(s) take a property the generated \
694 stylesheet sets on a class that element carries. App CSS wins over \
695 @layer makeover, whether by a later layer or by being unlayered, so a \
696 described component rendered on one of these elements loses the \
697 design system's version of that property silently -- which is how a \
698 destructive act came to look like an ordinary one:\n{}\n\nHand the \
699 property back on the arms makeover paints \
700 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
701 rule so it stops reaching described markup, or add \
702 (element, class, property) to this check's allowed-elements list and \
703 say why beside it.",
704 element_clashes.len(),
705 element_clashes.join("\n")
706 );
707
708 let stale: Vec<&(&str, &str)> = allowed
709 .iter()
710 .filter(|(class, property)| {
711 !seen.contains(&((*class).to_string(), (*property).to_string()))
712 })
713 .collect();
714 assert!(
715 stale.is_empty(),
716 "the allowed list declares {stale:?}, which no longer collides with \
717 anything. Delete the entries: an exception nobody is using is where the \
718 next real collision lands and reads as company."
719 );
720
721 let stale: Vec<&(&str, &str, &str)> = allowed_elements
722 .iter()
723 .filter(|(element, class, property)| {
724 !element_seen.contains(&(
725 (*element).to_string(),
726 (*class).to_string(),
727 (*property).to_string(),
728 ))
729 })
730 .collect();
731 assert!(
732 stale.is_empty(),
733 "the allowed-elements list declares {stale:?}, which no longer collides \
734 with anything. Delete the entries: an exception nobody is using is \
735 where the next real collision lands and reads as company."
736 );
737}
738
739fn haystack_names(haystack: &str, class: &str) -> bool {
768 haystack.match_indices(class).any(|(at, found)| {
769 haystack[at + found.len()..]
770 .chars()
771 .next()
772 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
773 })
774}
775
776pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
777 let generated = makeover_webview::vocabulary::names(opts);
778 let mut haystack = String::new();
779 for path in markup {
780 let path = path.as_ref();
781 println!("cargo::rerun-if-changed={}", path.display());
782 haystack.push_str(
783 &std::fs::read_to_string(path)
784 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
785 );
786 haystack.push('\n');
787 }
788
789 let rung = |class: &str| {
794 class
795 .strip_prefix(opts.class_prefix)
796 .and_then(|name| name.strip_prefix("min-"))
797 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
798 };
799 let ladder_used = generated
800 .iter()
801 .any(|class| rung(class) && haystack_names(&haystack, class));
802 let mut unused: Vec<&String> = generated
803 .iter()
804 .filter(|class| !rung(class) && !haystack.contains(class.as_str()))
805 .collect();
806 let ladder_name = format!("{}min-N", opts.class_prefix);
807 if !ladder_used && generated.iter().any(|class| rung(class)) {
808 unused.push(&ladder_name);
809 }
810
811 assert!(
812 unused.len() <= high_water,
813 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
814 The vocabulary grew or the markup stopped using it:\n{}",
815 unused.len(),
816 generated.len(),
817 high_water,
818 unused
819 .iter()
820 .map(|c| format!(" .{c}"))
821 .collect::<Vec<_>>()
822 .join("\n")
823 );
824
825 if unused.len() < high_water {
826 println!(
827 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
828 lower the seal so it cannot grow back",
829 unused.len(),
830 high_water
831 );
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 fn scratch(name: &str) -> PathBuf {
840 let dir =
841 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
842 let _ = std::fs::remove_dir_all(&dir);
843 std::fs::create_dir_all(&dir).expect("create scratch");
844 dir
845 }
846
847 fn write(dir: &Path, name: &str, src: &str) {
848 if let Some(parent) = dir.join(name).parent() {
849 std::fs::create_dir_all(parent).unwrap();
850 }
851 std::fs::write(dir.join(name), src).unwrap();
852 }
853
854 fn declaring() -> String {
855 format!(
856 "const {CONST_NAME} = '{}';\n",
857 Density::Touch.media_condition()
858 )
859 }
860
861 #[test]
862 fn the_crates_own_string_passes() {
863 let dir = scratch("ok");
864 write(&dir, "touch.js", &declaring());
865 check_touch_density(&dir);
866 }
867
868 #[test]
869 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
870 fn a_drifted_literal_fails() {
871 let dir = scratch("drift");
872 write(&dir, "touch.js", &declaring());
873 write(
874 &dir,
875 "haptics.js",
876 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
877 );
878 check_touch_density(&dir);
879 }
880
881 #[test]
882 #[should_panic(expected = "device sniff")]
883 fn the_sniff_cannot_come_back() {
884 let dir = scratch("sniff");
885 write(&dir, "touch.js", &declaring());
886 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
887 check_touch_density(&dir);
888 }
889
890 #[test]
891 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
892 fn a_frontend_that_states_nothing_fails() {
893 let dir = scratch("empty");
894 write(&dir, "app.js", "export const x = 1;\n");
895 check_touch_density(&dir);
896 }
897
898 #[test]
899 fn a_use_site_is_not_a_declaration() {
900 let src =
904 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
905 assert!(touch_density_literals(&src).is_empty());
906 }
907
908 #[test]
909 fn nested_files_are_read() {
910 let dir = scratch("nested");
913 write(&dir, "touch.js", &declaring());
914 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
915 let files = js_files(&dir);
916 assert_eq!(files.len(), 2);
917 }
918
919 #[test]
920 fn a_non_js_file_is_ignored() {
921 let dir = scratch("nonjs");
922 write(&dir, "touch.js", &declaring());
923 write(&dir, "styles.css", "body { }\n");
924 assert_eq!(js_files(&dir).len(), 1);
925 }
926
927 fn frontend(name: &str) -> PathBuf {
928 let dir = scratch(name);
929 std::fs::create_dir_all(dir.join("css")).unwrap();
930 std::fs::create_dir_all(dir.join("js")).unwrap();
931 dir
932 }
933
934 fn boundary() -> u16 {
936 SizeClass::Medium.min_px()
937 }
938
939 #[test]
940 fn the_crates_own_boundaries_pass() {
941 let dir = frontend("bp-ok");
942 write(
943 &dir,
944 "css/styles.css",
945 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
946 );
947 check_breakpoints(&dir, &[]);
948 }
949
950 #[test]
951 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
952 fn a_stale_css_width_fails() {
953 let dir = frontend("bp-css");
954 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
955 check_breakpoints(&dir, &[]);
956 }
957
958 #[test]
959 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
960 fn a_stale_js_width_fails() {
961 let dir = frontend("bp-js");
962 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
963 check_breakpoints(&dir, &[]);
964 }
965
966 #[test]
967 fn a_declared_tuning_width_passes() {
968 let dir = frontend("bp-tuning");
969 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
970 check_breakpoints(&dir, &[1400]);
971 }
972
973 #[test]
974 fn a_width_in_a_comment_is_prose() {
975 let dir = frontend("bp-comment");
978 write(
979 &dir,
980 "css/styles.css",
981 "/* was @media (max-width: 768px) until the size classes landed */\n",
982 );
983 check_breakpoints(&dir, &[]);
984 }
985
986 #[test]
987 fn an_unparenthesized_width_is_not_a_breakpoint() {
988 let dir = frontend("bp-inline");
992 write(
993 &dir,
994 "js/style.js",
995 "el.style.cssText = 'max-width: 320px; display: block';\n",
996 );
997 check_breakpoints(&dir, &[]);
998 }
999
1000 #[test]
1001 fn nested_css_is_read() {
1002 let dir = frontend("bp-nested");
1005 write(
1006 &dir,
1007 "css/screens/detail.css",
1008 "@media (max-width: 768px) { }\n",
1009 );
1010 let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
1011 assert!(found.is_err(), "a nested stylesheet must be scanned");
1012 }
1013
1014 #[test]
1015 fn a_named_list_is_checked() {
1016 let dir = frontend("bp-list");
1017 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1018 let listed = dir.join("css/style.css");
1019 let err =
1020 std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
1021 let msg = err.downcast_ref::<String>().expect("String payload");
1022 assert!(msg.contains("style.css:1"), "got: {msg}");
1023 }
1024
1025 #[test]
1026 #[should_panic(expected = "read ")]
1027 fn a_listed_file_that_is_gone_fails() {
1028 let dir = frontend("bp-missing");
1031 check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
1032 }
1033
1034 #[test]
1035 fn a_listed_js_file_is_parsed_as_script() {
1036 let dir = frontend("bp-list-js");
1039 write(
1040 &dir,
1041 "js/style.js",
1042 "el.style.cssText = 'max-width: 320px';\n",
1043 );
1044 check_breakpoints_files(&[dir.join("js/style.js")], &[]);
1045 }
1046
1047 #[test]
1048 fn the_error_names_the_file_and_line() {
1049 let dir = frontend("bp-message");
1050 write(
1051 &dir,
1052 "css/styles.css",
1053 "body { }\n@media (max-width: 768px) { }\n",
1054 );
1055 let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
1056 let msg = err
1057 .downcast_ref::<String>()
1058 .expect("panic payload is a String");
1059 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1060 }
1061
1062 #[test]
1063 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1064 let upstream = ".tile-set { gap: 4px; }\n";
1067 let dir = scratch("vocab-upstream");
1068 write(&dir, "css/quasi.css", upstream);
1069 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1070 let err = std::panic::catch_unwind(|| {
1071 check_vocabulary_with(&dir, &Emit::default(), &[], &[upstream], &[], &[]);
1072 })
1073 .unwrap_err();
1074 let msg = err
1075 .downcast_ref::<String>()
1076 .expect("panic payload is a String");
1077 assert!(
1078 msg.contains("css/styles.css .tile-set { gap }"),
1079 "got: {msg}"
1080 );
1081 assert!(
1082 !msg.contains("css/quasi.css"),
1083 "the served copy clashed with itself: {msg}"
1084 );
1085
1086 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1089 }
1090
1091 #[test]
1092 fn a_rule_restating_a_generated_class_fails_and_names_it() {
1093 let dir = scratch("vocab-clash");
1094 write(
1097 &dir,
1098 "css/styles.css",
1099 "body { color: red; }\n.card { box-shadow: none; }\n",
1100 );
1101 let err =
1102 std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1103 .unwrap_err();
1104 let msg = err
1105 .downcast_ref::<String>()
1106 .expect("panic payload is a String");
1107 assert!(msg.contains(".card"), "got: {msg}");
1108 assert!(msg.contains("box-shadow"), "got: {msg}");
1109 assert!(msg.contains("css/styles.css"), "got: {msg}");
1110 }
1111
1112 #[test]
1113 fn an_app_class_of_its_own_is_left_alone() {
1114 let dir = scratch("vocab-clean");
1115 write(
1116 &dir,
1117 "css/styles.css",
1118 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1119 );
1120 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1121 }
1122
1123 #[test]
1124 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1125 let dir = scratch("vocab-generated");
1126 let opts = Emit::default();
1127 write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
1128 check_vocabulary(&dir, &opts, &["layout.css"], &[], &[]);
1131 }
1132
1133 #[test]
1134 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1135 let dir = scratch("vocab-prefix");
1136 let opts = Emit {
1137 class_prefix: "mo-",
1138 ..Emit::default()
1139 };
1140 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1143 check_vocabulary(&dir, &opts, &[], &[], &[]);
1144
1145 let dir = scratch("vocab-prefix-clash");
1146 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1147 assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[], &[])).is_err());
1148 }
1149
1150 #[test]
1151 fn a_class_shared_without_a_shared_property_is_left_alone() {
1152 let dir = scratch("vocab-additive");
1153 write(
1156 &dir,
1157 "css/styles.css",
1158 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1159 );
1160 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1161 }
1162
1163 #[test]
1164 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1165 let dir = scratch("vocab-allowed");
1166 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1167 check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1168
1169 let dir = scratch("vocab-allowed-stale");
1172 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1173 let err = std::panic::catch_unwind(|| {
1174 check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1175 })
1176 .unwrap_err();
1177 let msg = err
1178 .downcast_ref::<String>()
1179 .expect("panic payload is a String");
1180 assert!(msg.contains("no longer collides"), "got: {msg}");
1181 }
1182
1183 #[test]
1184 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1185 let dir = scratch("vocab-element");
1186 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1190 let err =
1191 std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1192 .unwrap_err();
1193 let msg = err
1194 .downcast_ref::<String>()
1195 .expect("panic payload is a String");
1196 assert!(msg.contains("select {"), "got: {msg}");
1197 assert!(msg.contains(".field"), "got: {msg}");
1198 assert!(msg.contains("box-shadow"), "got: {msg}");
1199 assert!(msg.contains("css/styles.css"), "got: {msg}");
1200 }
1201
1202 #[test]
1203 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1204 let dir = scratch("vocab-element-handoff");
1205 write(
1210 &dir,
1211 "css/styles.css",
1212 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1213 );
1214 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1215 }
1216
1217 #[test]
1218 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1219 let dir = scratch("vocab-element-spoken-for");
1220 write(
1225 &dir,
1226 "css/styles.css",
1227 "select { box-shadow: none; }\n.field { box-shadow: none; }\n",
1228 );
1229 check_vocabulary(&dir, &Emit::default(), &[], &[("field", "box-shadow")], &[]);
1230 }
1231
1232 #[test]
1233 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1234 let dir = scratch("vocab-element-weak-handoff");
1235 write(
1239 &dir,
1240 "css/styles.css",
1241 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1242 );
1243 let err =
1244 std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1245 .unwrap_err();
1246 let msg = err
1247 .downcast_ref::<String>()
1248 .expect("panic payload is a String");
1249 assert!(msg.contains(".field"), "got: {msg}");
1250
1251 let dir = scratch("vocab-element-strong-handoff");
1253 write(
1254 &dir,
1255 "css/styles.css",
1256 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1257 );
1258 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1259 }
1260
1261 #[test]
1262 fn a_scoped_rule_is_not_read_as_an_element_rule() {
1263 let dir = scratch("vocab-element-scoped");
1264 write(
1268 &dir,
1269 "css/styles.css",
1270 ".wizard select { box-shadow: none; }\n",
1271 );
1272 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1273 }
1274
1275 #[test]
1276 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1277 let dir = scratch("vocab-element-unpaired");
1278 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1281 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1282 }
1283
1284 #[test]
1285 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1286 let dir = scratch("vocab-element-allowed");
1287 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1288 check_vocabulary(
1289 &dir,
1290 &Emit::default(),
1291 &[],
1292 &[],
1293 &[("select", "field", "box-shadow")],
1294 );
1295
1296 let dir = scratch("vocab-element-allowed-stale");
1299 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1300 let err = std::panic::catch_unwind(|| {
1301 check_vocabulary(
1302 &dir,
1303 &Emit::default(),
1304 &[],
1305 &[],
1306 &[("select", "field", "box-shadow")],
1307 );
1308 })
1309 .unwrap_err();
1310 let msg = err
1311 .downcast_ref::<String>()
1312 .expect("panic payload is a String");
1313 assert!(msg.contains("no longer collides"), "got: {msg}");
1314 }
1315
1316 #[test]
1317 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
1318 let dir = scratch("vocab-seal");
1319 let opts = Emit::default();
1320 let names = makeover_webview::vocabulary::names(&opts);
1321 let rungs = names.iter().filter(|c| c.starts_with("min-")).count();
1322 assert!(rungs > 1, "the floor ladder is in the vocabulary");
1323 let dead = names.len() - rungs + 1;
1326 write(&dir, "index.html", "<div></div>\n");
1327 let markup = [dir.join("index.html")];
1328
1329 check_vocabulary_use(&markup, &opts, dead);
1330 assert!(
1331 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
1332 "a vocabulary deader than the seal has to fail"
1333 );
1334 }
1335
1336 #[test]
1337 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
1338 let opts = Emit::default();
1341 let names = makeover_webview::vocabulary::names(&opts);
1342 let rungs = names.iter().filter(|c| c.starts_with("min-")).count();
1343 let dead = names.len() - rungs + 1;
1344
1345 let dir = scratch("vocab-ladder");
1346 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
1347 let one_rung = [dir.join("index.html")];
1348 check_vocabulary_use(&one_rung, &opts, dead - 2);
1350
1351 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
1353 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
1354 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
1355 }
1356
1357 #[test]
1358 fn both_quote_styles_read() {
1359 let want = Density::Touch.media_condition();
1360 for q in ['\'', '"'] {
1361 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
1362 let found = touch_density_literals(&src);
1363 assert_eq!(found.len(), 1);
1364 assert_eq!(found[0].1, want);
1365 }
1366 }
1367}