Skip to content

Commit 4f6ebc0

Browse files
committed
test(framework): imperative HTML-to-Rust codegen replacing runtime from_str
build.rs translates each tests/cases/**/*.html into a self-contained imperative #[test] under tests/generated/<topic>/<name>.rs via TestCtx high-level API (create_node/set_style/append/layout_imperative + getters). Legacy from_str/create_node_recursive/batch assert_html_case! removed. 978 passed, equivalent to prior runtime-parsing path.
1 parent 5543ad9 commit 4f6ebc0

7 files changed

Lines changed: 643 additions & 423 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

float-pigment-forest/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
/target
33
Cargo.lock
44
**/*.rs.bk
5-
.DS_store
5+
.DS_store
6+
/tests/generated/

float-pigment-forest/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ lazy_static = "1.4"
4848
fs_extra = { version = "1.3", optional = true }
4949

5050
[dev-dependencies]
51-
rand = "0.8.5"
5251
criterion = "0.5.1"
5352
float-pigment-mlp = { workspace = true }
5453
rustc-hash = "1.1.0"
@@ -59,3 +58,6 @@ piston_window = "0.128.0"
5958
[lints.clippy]
6059
missing_safety_doc = "allow"
6160
clone_on_copy = "allow"
61+
62+
[build-dependencies]
63+
float-pigment-mlp = { workspace = true }

float-pigment-forest/build.rs

Lines changed: 290 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,299 @@
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.
412
use std::{fs, path::PathBuf};
513

14+
use float_pigment_mlp::{
15+
context::{Context, Parse},
16+
node::{attribute::Attribute, NodeType},
17+
};
18+
619
fn main() {
720
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
821
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();
4373
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
44297
}
45298

46299
fn walk(root: &PathBuf, dir: &PathBuf) -> Vec<(PathBuf, PathBuf)> {

0 commit comments

Comments
 (0)