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 }