tests/code_space.rs 7.5 KiB 206 lines Raw
1 //! Code-tab breathing room (m6, f0rge issue #40):2 //! the repo-head sits clear of the navbar via CSS margin (never `<br>`3 //! spacers), with a slightly larger title — while staying tight on 360px.4 //!5 //! Builds a small fixture repo with the real binary and string-asserts on6 //! the emitted HTML + `static/style.css` (no rendering engine needed).7 8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static SEQ: AtomicU64 = AtomicU64::new(0);11 12 use std::path::{Path, PathBuf};13 use std::process::Command;14 15 fn bin() -> &'static str {16     env!("CARGO_BIN_EXE_hu13")17 }18 19 fn manifest() -> PathBuf {20     PathBuf::from(env!("CARGO_MANIFEST_DIR"))21 }22 23 fn fresh(prefix: &str) -> PathBuf {24     let dir = std::env::temp_dir().join(format!(25         "hu13-codespace-{prefix}-{}-{}",26         std::process::id(),27         std::time::SystemTime::now()28             .duration_since(std::time::UNIX_EPOCH)29             .map(|d| d.as_nanos())30             .unwrap_or(0)31             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))32     ));33     std::fs::create_dir_all(&dir).unwrap();34     dir35 }36 37 fn git(repo: &Path, args: &[&str]) {38     let st = Command::new("git")39         .arg("-C")40         .arg(repo)41         .args(args)42         .status()43         .expect("spawn git");44     assert!(st.success(), "git {args:?} failed");45 }46 47 fn fixture_repo() -> PathBuf {48     let dir = fresh("repo");49     git(&dir, &["init", "-b", "main"]);50     git(&dir, &["config", "user.name", "hu13-test"]);51     git(&dir, &["config", "user.email", "[email protected]"]);52     git(&dir, &["config", "commit.gpgsign", "false"]);53     std::fs::write(dir.join("README.md"), "# space fixture\n\nhello\n").unwrap();54     std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();55     git(&dir, &["add", "-A"]);56     git(&dir, &["commit", "-m", "init"]);57     dir58 }59 60 fn build_fixture() -> PathBuf {61     let repo = fixture_repo();62     let out = fresh("out");63     let st = Command::new(bin())64         .arg("build")65         .arg(&repo)66         .arg("-o")67         .arg(&out)68         .status()69         .expect("spawn hu13 build");70     assert!(st.success(), "fixture build exits 0");71     out72 }73 74 fn css() -> String {75     std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")76 }77 78 /// Value of `prop` in the first `selector{...}` rule inside `scope`.79 fn rule_value(scope: &str, selector: &str, prop: &str) -> Option<String> {80     let start = scope.find(selector)?;81     let block_start = scope[start..].find('{')? + start;82     let block_end = scope[block_start..].find('}')? + block_start;83     let block = &scope[block_start..block_end];84     let pos = block.find(prop)?;85     let after = block[pos + prop.len()..].trim_start().strip_prefix(':')?;86     let value: String = after87         .trim_start()88         .chars()89         .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t' | '\n'))90         .collect();91     Some(value)92 }93 94 fn parse_rem(value: &str) -> Option<f32> {95     value.strip_suffix("rem")?.trim().parse::<f32>().ok()96 }97 98 #[test]99 fn repo_head_spacing_is_css_margin_not_br_tags() {100     // Structure assert: the code tab keeps its stacked repo-head101     // (name + clone summary with branch/CI) with air from CSS margin —102     // no `<br>` spacers between the navbar and the repo-head.103     // (m6 finish, f0rge #53: branch/sha/CI live ONLY in the clone summary.)104     let out = build_fixture();105     let index = std::fs::read_to_string(out.join("index.html")).expect("index");106     let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");107     let details_end = index[head_start..].find("</details>").expect("details end") + head_start;108     let head = &index[head_start..details_end];109     assert!(head.contains("<h1>"), "repo-head keeps its title");110     assert!(111         !head.contains("<br"),112         "spacing is CSS margin, not <br> tags"113     );114     assert!(!index.contains("repo-meta"), "no dupe repo-meta line");115     assert!(!index.contains("<p>CI:"), "no dupe CI line");116     let s0 = index117         .find("<summary><span class=\"cb-seg\">Clone")118         .expect("summary");119     let s1 = index[s0..].find("</summary>").expect("summary end") + s0;120     let summary = &index[s0..s1];121     assert!(summary.contains("main"), "branch in summary");122     assert!(summary.contains("CI"), "CI in summary");123     // The header bar ends (</header>) before <main> carries the repo-head:124     // the break between nav and repo-head is a block boundary + margin.125     let header_end = index.find("</header>").expect("header close");126     assert!(127         header_end < head_start,128         "repo-head sits below the navbar block"129     );130     let between = &index[header_end..head_start];131     assert!(132         !between.contains("<br"),133         "no spacer tags between navbar and repo-head"134     );135 136     // CSS assert: `.repo-head` carries the top margin via --gap-head.137     let css = css();138     let base = css.split("@media (min-width").next().expect("base css");139     let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");140     assert!(141         margin.contains("var(--gap-head)"),142         "repo-head margin-top uses --gap-head, got {margin:?}"143     );144     let gap = rule_value(base, ":root{", "--gap-head").expect("gap-head var");145     assert!(146         parse_rem(&gap).is_some_and(|v| v > 0.0),147         "gap-head is positive breathing room, got {gap:?}"148     );149 }150 151 #[test]152 fn repo_head_title_reads_as_page_title() {153     // Slightly larger title treatment: explicit size on the repo-head h1.154     let css = css();155     let base = css.split("@media (min-width").next().expect("base css");156     let size = rule_value(base, ".repo-head h1", "font-size").expect("repo-head h1 font-size");157     let rem = parse_rem(&size).expect("h1 size in rem");158     assert!(159         rem >= 1.5,160         "repo-head title is title-scale (>=1.5rem), got {size:?}"161     );162     let out = build_fixture();163     let index = std::fs::read_to_string(out.join("index.html")).expect("index");164     assert!(165         index.contains("<div class=\"repo-head\"><h1>"),166         "repo-head h1 opens the code tab body"167     );168 }169 170 #[test]171 fn mobile_stays_tight_wider_screens_get_more() {172     // 360px must not drown in whitespace: the base (pre-media-query)173     // --gap-head stays modest; >=768px may open up a touch more.174     // (m6 finish, f0rge #50: gaps ride on --gap-head/--gap-content vars.)175     let css = css();176     assert!(css.contains("--gap-head"), "gap-head var");177     assert!(css.contains("--gap-content"), "gap-content var");178     let base = css.split("@media (min-width").next().expect("base css");179     let base_gap = rule_value(base, ":root{", "--gap-head").expect("base gap-head");180     let base_rem = parse_rem(&base_gap).expect("base gap in rem");181     assert!(182         base_rem <= 1.0,183         "mobile gap-head stays tight (<=1rem), got {base_gap:?}"184     );185     let desktop = css186         .split("@media (min-width:768px)")187         .nth(1)188         .expect("768px breakpoint");189     // Only the current breakpoint block: cut before the next @media.190     let block = desktop.split("@media").next().expect("768 block");191     let wide_gap = rule_value(block, ":root{", "--gap-head").expect("wide gap-head");192     let wide_rem = parse_rem(&wide_gap).expect("wide gap in rem");193     assert!(194         wide_rem >= base_rem,195         "wider screens keep at least base air ({wide_gap:?} vs {base_gap:?})"196     );197     assert!(198         wide_rem <= 2.0,199         "even wide stays modest (<=2rem), got {wide_gap:?}"200     );201     // Heads share the var (single pattern, not per-page px).202     assert!(203         base.contains("margin-top:var(--gap-head)"),204         "heads use gap-head var"205     );206 }