|
1 | | -// Scans tests/cases/**/*.html and generates #[test] functions that call |
2 | | -// assert_html_case!(<absolute path>). Output goes to OUT_DIR/html_tests.rs, |
3 | | -// which tests/mod.rs include!s. |
| 1 | +// HTML -> imperative Rust translator: each tests/cases/<topic>/<case>.html |
| 2 | +// is translated into a `#[test]` that exercises the TestCtx high-level API |
| 3 | +// (create_node / create_text / set_style / append + layout_imperative + |
| 4 | +// getters). Output goes to tests/generated/<topic>/<case>.rs plus a nested |
| 5 | +// mod tree (tests/generated/mod.rs → <topic>/mod.rs → <case>.rs), which |
| 6 | +// tests/mod.rs pulls in via `mod generated;`. |
| 7 | +// |
| 8 | +// The translator walks the parsed DOM and emits imperative calls in |
| 9 | +// document order. Layout assertions (data-expect-*) are collected during |
| 10 | +// the walk and emitted after `ctx.layout_imperative()` so they read back |
| 11 | +// computed values. |
4 | 12 | use std::{fs, path::PathBuf}; |
5 | 13 |
|
| 14 | +use float_pigment_mlp::{ |
| 15 | + context::{Context, Parse}, |
| 16 | + node::{attribute::Attribute, NodeType}, |
| 17 | +}; |
| 18 | + |
6 | 19 | fn main() { |
7 | 20 | let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap(); |
8 | 21 | let cases_dir = PathBuf::from(&manifest).join("tests/cases"); |
9 | | - let mut tests = String::new(); |
10 | | - tests.push_str("// AUTO-GENERATED by build.rs. Do not edit.\n\n"); |
11 | | - |
12 | | - if !cases_dir.exists() { |
13 | | - let out = std::env::var("OUT_DIR").unwrap(); |
14 | | - fs::write(format!("{out}/html_tests.rs"), tests).unwrap(); |
15 | | - return; |
16 | | - } |
17 | | - |
18 | | - let mut entries = walk(&cases_dir, &cases_dir); |
19 | | - entries.sort(); |
20 | | - for (rel, abs) in entries { |
21 | | - if abs.extension().and_then(|e| e.to_str()) != Some("html") { |
22 | | - continue; |
23 | | - } |
24 | | - // function name: relative path, separators -> underscore |
25 | | - let fn_name = rel |
26 | | - .with_extension("") |
27 | | - .to_string_lossy() |
28 | | - .replace(['/', '\\', '-', ' '], "_"); |
29 | | - let abs_str = abs.to_str().unwrap(); |
30 | | - let content = fs::read_to_string(&abs).unwrap_or_default(); |
31 | | - let ignore_attr = if content.contains("data-ignore=\"true\"") { |
32 | | - "#[ignore]\n" |
33 | | - } else { |
34 | | - "" |
35 | | - }; |
36 | | - tests.push_str(&format!( |
37 | | - "{ignore_attr}#[test]\nfn html__{fn_name}() {{\n assert_html_case!(\"{abs_str}\");\n}}\n\n" |
38 | | - )); |
39 | | - } |
40 | | - |
41 | | - let out = std::env::var("OUT_DIR").unwrap(); |
42 | | - fs::write(format!("{out}/html_tests.rs"), tests).unwrap(); |
| 22 | + let gen_dir = PathBuf::from(&manifest).join("tests/generated"); |
| 23 | + // 清空旧生成(删整个 generated 再重建,避免残留) |
| 24 | + let _ = fs::remove_dir_all(&gen_dir); |
| 25 | + fs::create_dir_all(&gen_dir).unwrap(); |
| 26 | + |
| 27 | + // topic -> Vec<(case_name, body, ignore)> |
| 28 | + let mut by_topic: std::collections::BTreeMap<String, Vec<(String, String, bool)>> = |
| 29 | + std::collections::BTreeMap::new(); |
| 30 | + |
| 31 | + if cases_dir.exists() { |
| 32 | + let mut entries = walk(&cases_dir, &cases_dir); |
| 33 | + entries.sort(); |
| 34 | + for (rel, abs) in entries { |
| 35 | + if abs.extension().and_then(|e| e.to_str()) != Some("html") { |
| 36 | + continue; |
| 37 | + } |
| 38 | + let rel_str = rel.with_extension("").to_string_lossy().replace('\\', "/"); |
| 39 | + let mut parts = rel_str.split('/'); |
| 40 | + let topic = parts.next().unwrap_or("misc").to_string(); |
| 41 | + let name = parts.collect::<Vec<_>>().join("_"); |
| 42 | + let html = fs::read_to_string(&abs).unwrap_or_default(); |
| 43 | + let ignore = html.contains("data-ignore=\"true\""); |
| 44 | + let body = translate_html(&html); |
| 45 | + by_topic.entry(topic).or_default().push((name, body, ignore)); |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + // 生成每 topic 目录 + mod.rs + 各 case .rs。 |
| 50 | + // topic/name 原样用作目录名/文件名(文件系统允许 `-`),但需 sanitize |
| 51 | + // 为合法 Rust 标识符(`-` 非法)用于 mod 名与 fn 名。当前 cases 无 `-`, |
| 52 | + // 此 sanitize 为防御性 —— 与旧 build.rs `replace(['/', '\\', '-', ' '], "_")` 一致。 |
| 53 | + let mut top_mod = String::from("// AUTO-GENERATED by build.rs. Do not edit.\n\n"); |
| 54 | + for (topic, cases) in &by_topic { |
| 55 | + let topic_dir = gen_dir.join(topic); |
| 56 | + fs::create_dir_all(&topic_dir).unwrap(); |
| 57 | + let topic_ident = topic.replace('-', "_"); |
| 58 | + let mut topic_mod = String::from("// AUTO-GENERATED. Do not edit.\n\n"); |
| 59 | + for (name, body, ignore) in cases { |
| 60 | + let name_ident = name.replace('-', "_"); |
| 61 | + let fn_name = format!("html__{}_{}", topic_ident, name_ident); |
| 62 | + let ignore_attr = if *ignore { "#[ignore]\n" } else { "" }; |
| 63 | + let case_rs = format!( |
| 64 | + "// AUTO-GENERATED from tests/cases/{topic}/{name}.html. Do not edit.\nuse crate::TestCtx;\n\n{ignore_attr}#[test]\nfn {fn_name}() {{\n{body}}}\n" |
| 65 | + ); |
| 66 | + fs::write(topic_dir.join(format!("{name}.rs")), case_rs).unwrap(); |
| 67 | + topic_mod.push_str(&format!("mod {name_ident};\n")); |
| 68 | + } |
| 69 | + fs::write(topic_dir.join("mod.rs"), topic_mod).unwrap(); |
| 70 | + top_mod.push_str(&format!("mod {topic_ident};\n")); |
| 71 | + } |
| 72 | + fs::write(gen_dir.join("mod.rs"), top_mod).unwrap(); |
43 | 73 | println!("cargo:rerun-if-changed=tests/cases"); |
| 74 | + println!("cargo:rerun-if-changed=build.rs"); |
| 75 | +} |
| 76 | + |
| 77 | +/// Parse the HTML and emit imperative TestCtx calls. |
| 78 | +/// |
| 79 | +/// Order: create_node/create_text + set_style + append (pre-order DOM walk), |
| 80 | +/// then `ctx.layout_imperative()`, then the collected assert_eq! calls. |
| 81 | +fn translate_html(html: &str) -> String { |
| 82 | + let mut parse_ctx = Context::create(None); |
| 83 | + parse_ctx.parse(html); |
| 84 | + let mut out = String::from(" let mut ctx = TestCtx::new();\n"); |
| 85 | + let mut counter = Counter::default(); |
| 86 | + // The parser always wraps content in a Fragment root. `gen_node` mirrors |
| 87 | + // `TestCtx::create_node_recursive` by materialising that Fragment as a |
| 88 | + // Block wrapper Node, so the generated tree structurally matches the |
| 89 | + // legacy `from_str` path (important for layout assertions on top-level |
| 90 | + // elements — they sit inside the wrapper, not as the layout root). |
| 91 | + if let Some(tree) = parse_ctx.tree() { |
| 92 | + if let Some(root) = tree.root() { |
| 93 | + let _ = gen_node(&mut out, root, None, &mut counter); |
| 94 | + } |
| 95 | + } |
| 96 | + out.push_str(" ctx.layout_imperative();\n"); |
| 97 | + for a in &counter.asserts { |
| 98 | + out.push_str(a); |
| 99 | + } |
| 100 | + out |
| 101 | +} |
| 102 | + |
| 103 | +struct Counter { |
| 104 | + n: usize, |
| 105 | + t: usize, |
| 106 | + asserts: Vec<String>, |
| 107 | +} |
| 108 | +impl Default for Counter { |
| 109 | + fn default() -> Self { |
| 110 | + Self { |
| 111 | + n: 0, |
| 112 | + t: 0, |
| 113 | + asserts: vec![], |
| 114 | + } |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +/// Walk a DOM node, emit create_node/create_text + set_style + append, and |
| 119 | +/// collect data-expect-* assertions. Returns the variable name bound to this |
| 120 | +/// node (for the parent's `append` call). |
| 121 | +fn gen_node(out: &mut String, node: &NodeType, parent: Option<&str>, c: &mut Counter) -> String { |
| 122 | + match node { |
| 123 | + NodeType::Element(e) => { |
| 124 | + let var = format!("n{}", c.n); |
| 125 | + c.n += 1; |
| 126 | + out.push_str(&format!( |
| 127 | + " let {var} = ctx.create_node({});\n", |
| 128 | + rust_str_literal(e.tag()) |
| 129 | + )); |
| 130 | + let attrs = e.attributes(); |
| 131 | + if let Some(style) = attrs.get("style") { |
| 132 | + if !style.is_empty() { |
| 133 | + out.push_str(&format!( |
| 134 | + " ctx.set_style({var}, {});\n", |
| 135 | + rust_str_literal(&style) |
| 136 | + )); |
| 137 | + } |
| 138 | + } |
| 139 | + // Measure-text slots: tags recognised by `is_measure_text_slot` |
| 140 | + // (currently just `text-slot`) carry synthetic `len` / `fontSize` |
| 141 | + // attributes that drive an intrinsic-size measure func. Emit a |
| 142 | + // `set_measure_text` call so build_dfs wires up TextInfo exactly |
| 143 | + // like the legacy `create_node_recursive`. Defaults match the |
| 144 | + // legacy path: len=0, fontSize=16. |
| 145 | + if is_measure_text_slot(e.tag()) { |
| 146 | + let len = attrs |
| 147 | + .get("len") |
| 148 | + .and_then(|v| v.trim().parse::<usize>().ok()) |
| 149 | + .unwrap_or(0); |
| 150 | + let font_size = attrs |
| 151 | + .get("fontSize") |
| 152 | + .and_then(|v| v.trim().parse::<f32>().ok()) |
| 153 | + .unwrap_or(16.0); |
| 154 | + out.push_str(&format!( |
| 155 | + " ctx.set_measure_text({var}, {len}, {});\n", |
| 156 | + parse_value(&font_size.to_string()) |
| 157 | + )); |
| 158 | + } |
| 159 | + collect_asserts(&var, attrs, c); |
| 160 | + if let Some(p) = parent { |
| 161 | + out.push_str(&format!(" ctx.append({}, {var});\n", p)); |
| 162 | + } |
| 163 | + // Collect children into a Vec first to drop the RefMut borrow |
| 164 | + // before recursing (gen_node may itself borrow the same element's |
| 165 | + // siblings via the parent's RefCell — defensive; recursion target |
| 166 | + // is a different Rc<NodeType> so this is not strictly required, |
| 167 | + // but it keeps the borrow story obviously correct). |
| 168 | + let children: Vec<_> = e.children_mut().iter().cloned().collect(); |
| 169 | + for child in children.iter() { |
| 170 | + gen_node(out, child.as_ref(), Some(&var), c); |
| 171 | + } |
| 172 | + var |
| 173 | + } |
| 174 | + NodeType::Text(t) => { |
| 175 | + let var = format!("t{}", c.t); |
| 176 | + c.t += 1; |
| 177 | + out.push_str(&format!( |
| 178 | + " let {var} = ctx.create_text({});\n", |
| 179 | + rust_str_literal(t.text()) |
| 180 | + )); |
| 181 | + if let Some(p) = parent { |
| 182 | + out.push_str(&format!(" ctx.append({}, {var});\n", p)); |
| 183 | + } |
| 184 | + var |
| 185 | + } |
| 186 | + NodeType::Fragment(f) => { |
| 187 | + // Fragment always materialises as a Block wrapper Node — this |
| 188 | + // mirrors `TestCtx::create_node_recursive`, where a Fragment |
| 189 | + // becomes `Node::new_ptr()` (default Display::Block) and its |
| 190 | + // children are appended underneath. Skipping the wrapper breaks |
| 191 | + // layout assertions on top-level elements (they would become the |
| 192 | + // layout root instead of sitting inside a Block container). |
| 193 | + let var = format!("n{}", c.n); |
| 194 | + c.n += 1; |
| 195 | + out.push_str(&format!( |
| 196 | + " let {var} = ctx.create_node({});\n", |
| 197 | + rust_str_literal("div") |
| 198 | + )); |
| 199 | + if let Some(p) = parent { |
| 200 | + out.push_str(&format!(" ctx.append({}, {var});\n", p)); |
| 201 | + } |
| 202 | + let children: Vec<_> = f.children_mut().iter().cloned().collect(); |
| 203 | + for child in children.iter() { |
| 204 | + let _ = gen_node(out, child.as_ref(), Some(&var), c); |
| 205 | + } |
| 206 | + var |
| 207 | + } |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +/// Collect data-expect-* / expect_* assertions for a node. Assertions are |
| 212 | +/// appended after `ctx.layout_imperative()` in `translate_html`. |
| 213 | +fn collect_asserts(var: &str, attrs: &Attribute, c: &mut Counter) { |
| 214 | + // (html_attr_alt, html_attr_primary, getter) |
| 215 | + const MAP: &[(&str, &str, &str)] = &[ |
| 216 | + ("expect_width", "data-expect-width", "width"), |
| 217 | + ("expect_height", "data-expect-height", "height"), |
| 218 | + ("expect_left", "data-expect-left", "left"), |
| 219 | + ("expect_top", "data-expect-top", "top"), |
| 220 | + ("expect_margin_top", "data-expect-margin-top", "margin_top"), |
| 221 | + ( |
| 222 | + "expect_margin_right", |
| 223 | + "data-expect-margin-right", |
| 224 | + "margin_right", |
| 225 | + ), |
| 226 | + ( |
| 227 | + "expect_margin_bottom", |
| 228 | + "data-expect-margin-bottom", |
| 229 | + "margin_bottom", |
| 230 | + ), |
| 231 | + ( |
| 232 | + "expect_margin_left", |
| 233 | + "data-expect-margin-left", |
| 234 | + "margin_left", |
| 235 | + ), |
| 236 | + ]; |
| 237 | + for (alt, primary, getter) in MAP { |
| 238 | + let v = attrs.get(primary).or_else(|| attrs.get(alt)); |
| 239 | + if let Some(v) = v { |
| 240 | + // `.round()` mirrors the legacy `PartialLayoutPosition` eq impl, |
| 241 | + // which compares `expect == layout.to_f32().round()`. Without |
| 242 | + // rounding, percentage/flex layout values with f32 precision |
| 243 | + // drift (e.g. 99.90234 vs 100.0) would spuriously fail. |
| 244 | + c.asserts.push(format!( |
| 245 | + " assert_eq!(ctx.{getter}({var}).round(), {});\n", |
| 246 | + parse_value(&v) |
| 247 | + )); |
| 248 | + } |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +/// Format an HTML scalar value as an f32 literal. Integers get a trailing |
| 253 | +/// `.0` so the literal has type f32 and matches the getter return type. |
| 254 | +fn parse_value(v: &str) -> String { |
| 255 | + let v = v.trim(); |
| 256 | + if v.contains('.') { |
| 257 | + v.to_string() |
| 258 | + } else { |
| 259 | + format!("{}.0", v) |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +/// Tag set that the legacy `create_node_recursive` treats as a measure-text |
| 264 | +/// slot. Kept in sync with `is_measure_text_slot` in tests/mod.rs; build.rs |
| 265 | +/// cannot import the test helper, so we duplicate the predicate here. |
| 266 | +fn is_measure_text_slot(tag: &str) -> bool { |
| 267 | + tag == "text-slot" |
| 268 | +} |
| 269 | + |
| 270 | +/// Build a Rust string literal that round-trips the input text. Prefers raw |
| 271 | +/// strings (`r#"..."#`, escalating the `#` count) so generated code reads |
| 272 | +/// cleanly; falls back to a fully escaped `"..."` literal only when the text |
| 273 | +/// contains every raw-string terminator we try. |
| 274 | +fn rust_str_literal(s: &str) -> String { |
| 275 | + // Try escalating raw-string hash counts. `"#` rules out r#"..."#, etc. |
| 276 | + for hashes in 1..=5 { |
| 277 | + let pat = format!("\"{}", "#".repeat(hashes)); |
| 278 | + if !s.contains(&pat) { |
| 279 | + let h = "#".repeat(hashes); |
| 280 | + return format!("r{h}\"{s}\"{h}"); |
| 281 | + } |
| 282 | + } |
| 283 | + // Fallback: escaped string literal. |
| 284 | + let mut out = String::from('"'); |
| 285 | + for ch in s.chars() { |
| 286 | + match ch { |
| 287 | + '"' => out.push_str("\\\""), |
| 288 | + '\\' => out.push_str("\\\\"), |
| 289 | + '\n' => out.push_str("\\n"), |
| 290 | + '\r' => out.push_str("\\r"), |
| 291 | + '\t' => out.push_str("\\t"), |
| 292 | + other => out.push(other), |
| 293 | + } |
| 294 | + } |
| 295 | + out.push('"'); |
| 296 | + out |
44 | 297 | } |
45 | 298 |
|
46 | 299 | fn walk(root: &PathBuf, dir: &PathBuf) -> Vec<(PathBuf, PathBuf)> { |
|
0 commit comments