1 //! Navbar one-bar (m6, f0rge issues #18 + #27 + #28):2 //! single `<header>` (logo-with-text • nav only), scrollable nav strip3 //! (own `nav-scroll` class: `overflow-x:auto` + nowrap, snap off, visible4 //! focus), logo img alt text, `aria-current` on the active tab.5 //!6 //! Builds a small fixture repo with the real binary and string-asserts on7 //! the emitted HTML + `static/style.css` (no rendering engine needed).8 9 use std::sync::atomic::{AtomicU64, Ordering};10 11 static SEQ: AtomicU64 = AtomicU64::new(0);12 13 use std::path::{Path, PathBuf};14 use std::process::Command;15 16 fn bin() -> &'static str {17 env!("CARGO_BIN_EXE_hu13")18 }19 20 fn manifest() -> PathBuf {21 PathBuf::from(env!("CARGO_MANIFEST_DIR"))22 }23 24 fn fresh(prefix: &str) -> PathBuf {25 let dir = std::env::temp_dir().join(format!(26 "hu13-navbar-{prefix}-{}-{}",27 std::process::id(),28 std::time::SystemTime::now()29 .duration_since(std::time::UNIX_EPOCH)30 .map(|d| d.as_nanos())31 .unwrap_or(0)32 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))33 ));34 std::fs::create_dir_all(&dir).unwrap();35 dir36 }37 38 fn git(repo: &Path, args: &[&str]) {39 let st = Command::new("git")40 .arg("-C")41 .arg(repo)42 .args(args)43 .status()44 .expect("spawn git");45 assert!(st.success(), "git {args:?} failed");46 }47 48 fn fixture_repo() -> PathBuf {49 let dir = fresh("repo");50 git(&dir, &["init", "-b", "main"]);51 git(&dir, &["config", "user.name", "hu13-test"]);52 git(&dir, &["config", "user.email", "[email protected]"]);53 git(&dir, &["config", "commit.gpgsign", "false"]);54 std::fs::write(dir.join("README.md"), "# navbar fixture\n\nhello\n").unwrap();55 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();56 git(&dir, &["add", "-A"]);57 git(&dir, &["commit", "-m", "init"]);58 dir59 }60 61 fn build_fixture() -> PathBuf {62 let repo = fixture_repo();63 let out = fresh("out");64 let st = Command::new(bin())65 .arg("build")66 .arg(&repo)67 .arg("-o")68 .arg(&out)69 .status()70 .expect("spawn hu13 build");71 assert!(st.success(), "fixture build exits 0");72 out73 }74 75 fn css() -> String {76 std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")77 }78 79 #[test]80 fn one_header_bar_with_logo_sep_and_scroll_nav() {81 let out = build_fixture();82 for page in ["index.html", "commits.html", "issues/index.html"] {83 let html = std::fs::read_to_string(out.join(page)).expect(page);84 // One header bar on every page; the nav lives inside it.85 assert_eq!(86 html.matches("<header").count(),87 1,88 "{page} has a single <header>"89 );90 assert!(91 html.contains("<nav"),92 "{page} keeps a <nav> inside the header"93 );94 // Minimal lockup (#27 + #28): logo-with-text art • nav only.95 assert!(96 html.contains("<img class=\"site-logo\" src=\"/logo.svg\""),97 "{page} shows the logo lockup img"98 );99 assert!(100 html.contains("alt=\"hu13 logo\""),101 "{page} logo carries alt text"102 );103 assert!(104 !html.contains("hu13-word"),105 "{page} drops the hu13 word node (letters live in logo.svg)"106 );107 assert!(108 !html.contains("<span class=\"brand\">"),109 "{page} drops the repo brand node"110 );111 let header = html.split("</header>").next().expect("header close");112 assert_eq!(113 header.matches("•").count(),114 1,115 "{page} header is logo • nav (exactly one sep)"116 );117 // Scrollable nav strip under its own class.118 assert!(119 html.contains("nav-scroll"),120 "{page} nav has the scroll class"121 );122 }123 // Repo name still lives outside the header (#28): title, og:title,124 // code-page repo-head, and feed title.125 let index = std::fs::read_to_string(out.join("index.html")).expect("index");126 assert!(index.contains("<title>"), "title kept");127 assert!(128 index.contains("<meta property=\"og:title\""),129 "og:title kept"130 );131 assert!(132 index.contains("<div class=\"repo-head\"><h1>"),133 "code-page repo-head title kept"134 );135 let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed");136 assert!(feed.contains("<title>"), "feed title kept");137 }138 139 #[test]140 fn active_tab_marked_with_aria_current() {141 let out = build_fixture();142 let index = std::fs::read_to_string(out.join("index.html")).expect("index");143 assert!(144 index.contains("<a href=\"/\" aria-current=\"page\">"),145 "code tab active on index"146 );147 let commits = std::fs::read_to_string(out.join("commits.html")).expect("commits");148 assert!(149 commits.contains("<a href=\"/commits.html\" aria-current=\"page\">"),150 "commits tab active on commits.html"151 );152 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");153 assert!(154 issues.contains("<a href=\"/issues/index.html\" aria-current=\"page\">"),155 "issues tab active on issues/index.html"156 );157 // Exactly one active tab per page.158 for (name, html) in [159 ("index", &index),160 ("commits", &commits),161 ("issues", &issues),162 ] {163 assert_eq!(164 html.matches("aria-current=\"page\"").count(),165 1,166 "{name} marks exactly one active tab"167 );168 }169 }170 171 #[test]172 fn nav_scroll_css_scrolls_without_snap_and_shows_state() {173 let css = css();174 // Own class (appended, earlier rules untouched).175 assert!(css.contains(".nav-scroll"), "scroll class styled");176 // Scrollable strip: horizontal scroll + no wrapping.177 assert!(css.contains("overflow-x:auto"), "nav scrolls horizontally");178 assert!(css.contains("white-space:nowrap"), "nav links never wrap");179 // Scroll snap explicitly off (the strip is a plain scroller).180 assert!(181 css.contains("scroll-snap-type:none"),182 "snap off so the strip rests anywhere"183 );184 // Position stays visible: focus ring + active accent underline.185 assert!(186 css.contains(":focus-visible"),187 "keyboard focus stays visible"188 );189 assert!(190 css.contains("aria-current"),191 "active tab styled via aria-current"192 );193 }