From d9edc47a02bad8488d980214ac6869692a8c4cdf Mon Sep 17 00:00:00 2001 From: erweixin <2991552132@qq.com> Date: Sat, 5 Sep 2026 09:49:51 +0800 Subject: [PATCH] test(core): capture compatibility baselines for refactoring Add opt-in captures for the main, mhchem and prooftree corpora, comparing complete ASTs, DisplayLists, render results and PNG bytes against a local baseline. Keep committed visual references and generated data untouched. Cover inherited styles/colors, array tags with row gaps and proof labels at domain boundaries, and document how to reproduce acceptance checks. --- CONTRIBUTING.md | 3 + .../ratex-layout/tests/domain_boundaries.rs | 76 +++++++++++ .../tests/refactor_compatibility.rs | 124 ++++++++++++++++++ docs/CORE_INTERNALS.md | 46 +++++++ docs/PROJECT_STRUCTURE.md | 3 + 5 files changed, 252 insertions(+) create mode 100644 crates/ratex-layout/tests/domain_boundaries.rs create mode 100644 crates/ratex-render/tests/refactor_compatibility.rs create mode 100644 docs/CORE_INTERNALS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3afc920e..921d64b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,9 @@ Thanks for helping improve RaTeX. Keep changes focused and consistent with surrounding code. +When restructuring core logic, follow the module boundaries and compatibility +capture workflow in [`docs/CORE_INTERNALS.md`](docs/CORE_INTERNALS.md). + ## Prerequisites - **Rust**: stable toolchain ([rustup](https://rustup.rs)); see README for minimum version. diff --git a/crates/ratex-layout/tests/domain_boundaries.rs b/crates/ratex-layout/tests/domain_boundaries.rs new file mode 100644 index 00000000..4618c078 --- /dev/null +++ b/crates/ratex-layout/tests/domain_boundaries.rs @@ -0,0 +1,76 @@ +//! Contracts at the boundaries between independently maintained layout domains. +use ratex_layout::{layout, to_display_list, LayoutOptions}; +use ratex_parser::parse; +use ratex_types::display_item::DisplayItem; +use ratex_types::{Color, MathStyle}; + +#[test] +fn compound_domains_preserve_inherited_color_in_every_style() { + let color = Color::new(0.2, 0.4, 0.6, 0.75); + for style in [MathStyle::Display, MathStyle::Text, MathStyle::Script] { + for formula in [ + r"\left(\frac{\widehat{x}_i}{\sqrt{y}}\middle|z\right)", + r"\begin{array}{c|c}\hline x&\overline{y}\\[1em]z&\sqrt{w}\end{array}", + r"\begin{CD} A @>f>> B \\ @VVgV @AAhA \\ C @= D \end{CD}", + r"\begin{prooftree}\LeftLabel{L}\AxiomC{P}\UnaryInfC{Q}\end{prooftree}", + ] { + let options = LayoutOptions::default().with_style(style).with_color(color); + let display = to_display_list(&layout(&parse(formula).unwrap(), &options)); + assert!(!display.items.is_empty(), "{formula}"); + for item in display.items { + let actual = match item { + DisplayItem::GlyphPath { color, .. } + | DisplayItem::Line { color, .. } + | DisplayItem::Rect { color, .. } + | DisplayItem::Path { color, .. } => color, + }; + assert_eq!(actual, color, "{formula}: {style:?}"); + } + } + } +} + +#[test] +fn explicit_array_tags_survive_row_gap_layout() { + let plain = r"\begin{align}x&=y\tag{A}\\z&=w\tag{B}\end{align}"; + let spaced = r"\begin{align}x&=y\tag{A}\\[1em]z&=w\tag{B}\end{align}"; + let render = + |formula| to_display_list(&layout(&parse(formula).unwrap(), &LayoutOptions::default())); + let before = render(plain); + let after = render(spaced); + assert!(after.height + after.depth > before.height + before.depth); + for character in ['A', 'B'] { + let count = |items: &[DisplayItem]| { + items + .iter() + .filter(|item| { + matches!(item, + DisplayItem::GlyphPath { char_code, .. } if *char_code == character as u32) + }) + .count() + }; + assert_eq!(count(&before.items), 1); + assert_eq!(count(&after.items), 1); + } +} + +#[test] +fn proof_labels_add_ink_without_changing_the_inference_count() { + let plain = r"\begin{prooftree}\AxiomC{P}\UnaryInfC{Q}\end{prooftree}"; + let labeled = + r"\begin{prooftree}\LeftLabel{L}\RightLabel{R}\AxiomC{P}\UnaryInfC{Q}\end{prooftree}"; + let render = + |formula| to_display_list(&layout(&parse(formula).unwrap(), &LayoutOptions::default())); + let before = render(plain); + let after = render(labeled); + let rules = |items: &[DisplayItem]| { + items + .iter() + .filter(|item| matches!(item, DisplayItem::Line { .. })) + .count() + }; + assert_eq!(rules(&before.items), 1); + assert_eq!(rules(&after.items), 1); + assert!(after.items.len() > before.items.len()); + assert!(after.width > before.width); +} diff --git a/crates/ratex-render/tests/refactor_compatibility.rs b/crates/ratex-render/tests/refactor_compatibility.rs new file mode 100644 index 00000000..37ae2ade --- /dev/null +++ b/crates/ratex-render/tests/refactor_compatibility.rs @@ -0,0 +1,124 @@ +//! Same-machine compatibility capture for structural refactors. Artifacts are +//! deliberately opt-in and never replace the committed golden references. +use std::fs; +use std::path::{Path, PathBuf}; + +use ratex_layout::{layout, to_display_list, LayoutOptions}; +use ratex_parser::parse; +use ratex_render::{render_to_png, RenderOptions}; +use ratex_types::{Color, MathStyle}; +use serde_json::{json, Value}; + +fn capture_suite(root: &Path, output: &Path, name: &str, corpus: &str, font_size: f32, dpr: f32) { + let directory = output.join(name); + fs::create_dir(&directory).expect("create new suite directory"); + let source = fs::read_to_string(root.join("tests/golden").join(corpus)).unwrap(); + let options = RenderOptions { + font_dir: root.join("fonts").to_string_lossy().into_owned(), + font_size, + device_pixel_ratio: dpr, + ..RenderOptions::default() + }; + let mut records = Vec::new(); + let mut errors = String::new(); + for (index, formula) in source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with(['#', '%'])) + .enumerate() + { + let index = index + 1; + let mut record = json!({"index": index, "formula": formula}); + match parse(formula) { + Err(error) => { + record["status"] = json!("parse_error"); + record["error"] = json!(error.to_string()); + errors.push_str(&format!("ERR {index:4} {formula} — parse error: {error}\n")); + } + Ok(ast) => { + record["ast"] = serde_json::to_value(&ast).unwrap(); + let box_ = layout(&ast, &LayoutOptions::default()); + let display = to_display_list(&box_); + record["box"] = json!([box_.width, box_.height, box_.depth]); + record["display"] = serde_json::to_value(&display).unwrap(); + // Exercise style/color/size propagation as well as the default + // path, without multiplying the number of PNG artifacts. + let inline_options = LayoutOptions::default() + .with_style(MathStyle::Text) + .with_color(Color::new(0.2, 0.4, 0.6, 0.75)) + .with_inter_glyph_kern(0.02); + record["inline_display"] = + serde_json::to_value(to_display_list(&layout(&ast, &inline_options))).unwrap(); + match render_to_png(&display, &options) { + Ok(png) => { + fs::write(directory.join(format!("{index:04}.png")), png).unwrap(); + record["status"] = json!("rendered"); + } + Err(error) => { + record["status"] = json!("render_error"); + record["error"] = json!(error); + errors.push_str(&format!("ERR {index:4} {formula} — {error}\n")); + } + } + } + } + records.push(record); + } + fs::write(directory.join("errors.log"), errors).unwrap(); + fs::write( + directory.join("records.json"), + serde_json::to_vec(&records).unwrap(), + ) + .unwrap(); + println!("captured {}: {} formulas", name, records.len()); +} + +fn compare_suite(baseline: &Path, output: &Path, name: &str) { + let before = baseline.join(name); + let after = output.join(name); + let old: Vec = + serde_json::from_slice(&fs::read(before.join("records.json")).unwrap()).unwrap(); + let new: Vec = + serde_json::from_slice(&fs::read(after.join("records.json")).unwrap()).unwrap(); + assert_eq!(old.len(), new.len(), "{name}: corpus size changed"); + for (old, new) in old.iter().zip(&new) { + let index = new["index"].as_u64().unwrap(); + assert_eq!( + old, new, + "{name}/{index:04}: AST, geometry, display items or status changed" + ); + if new["status"] == "rendered" { + let png = format!("{index:04}.png"); + // The same encoder/options make byte equality a stricter check + // than decoded dimensions and pixel equality. + assert_eq!( + fs::read(before.join(&png)).unwrap(), + fs::read(after.join(&png)).unwrap(), + "{name}/{png}: PNG changed" + ); + } + } +} + +#[test] +#[ignore = "set RATEX_COMPAT_OUTPUT; optionally RATEX_COMPAT_BASELINE to compare two captures"] +fn capture_and_compare_corpus() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap(); + let output = + PathBuf::from(std::env::var_os("RATEX_COMPAT_OUTPUT").expect("set RATEX_COMPAT_OUTPUT")); + fs::create_dir_all(output.parent().unwrap()).unwrap(); + fs::create_dir(&output).expect("output must be a new directory; never overwrite a baseline"); + for (name, corpus, size, dpr) in [ + ("main", "test_cases.txt", 40.0, 1.0), + ("mhchem", "test_case_ce.txt", 40.0, 2.0), + ("prooftree", "test_cases_prooftree.txt", 36.0, 1.0), + ] { + capture_suite(&root, &output, name, corpus, size, dpr); + if let Some(baseline) = std::env::var_os("RATEX_COMPAT_BASELINE") { + compare_suite(Path::new(&baseline), &output, name); + } + } +} diff --git a/docs/CORE_INTERNALS.md b/docs/CORE_INTERNALS.md new file mode 100644 index 00000000..ccc98ad2 --- /dev/null +++ b/docs/CORE_INTERNALS.md @@ -0,0 +1,46 @@ +# Core implementation boundaries + +This guide describes private implementation modules. Public Rust import paths, +AST and layout types, C ABI and DisplayList JSON remain unchanged. + +## Same-machine refactor acceptance + +The ignored `refactor_compatibility` integration test captures all three golden +corpora. Each record includes the complete AST, unrounded box dimensions, +DisplayList, an inline/color/tracking variant and the render/error result. +Successful cases also produce PNGs using the normal golden size and DPR. + +Before modifying production code: + +```bash +RATEX_COMPAT_OUTPUT="$PWD/target/refactor-compat/before" \ + cargo test --release -p ratex-render --test refactor_compatibility -- --ignored --nocapture +``` + +After the refactor, on the same machine with the same fonts and build features: + +```bash +RATEX_COMPAT_OUTPUT="$PWD/target/refactor-compat/after" \ +RATEX_COMPAT_BASELINE="$PWD/target/refactor-compat/before" \ + cargo test --release -p ratex-render --test refactor_compatibility -- --ignored --nocapture +``` + +Output directories must be new; the test refuses to overwrite a baseline. JSON +records are compared structurally, including array order. Identical PNG bytes +also guarantee identical dimensions and pixels with the same encoder. Known +unsupported formulas must preserve their error result, not disappear. + +For authoritative visual scoring, use `tools/golden_compare/compare_golden.py` +and the workflow in `GOLDEN_BASELINE.md`. Capture directories have `errors.log` +files accepted by `build_render_manifest.py`; use the original corpus and DPR +to create each `render-manifest.json`. Compare both captures against the same +reference images, then pass the before report as `--baseline-report` with +`--max-case-regression 0`. Capture artifacts do not replace committed reference +images or `tests/golden/baseline.json`. + +Pass `--ce` for mhchem even when specifying custom output directories. For +prooftree, pass `--prooftree-tolerant` and an explicit policy file containing +`{"cases": {}}`; the main suite's indexed exclusions do not apply to that corpus. +Use `--require-manifests` with the main references. Existing mhchem/prooftree +reference images may lack generation manifests: the scorer still checks image +index coverage, and both refactor captures must use those same references. diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 44ae21b1..2c86b27d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -2,6 +2,9 @@ Current layout as of the codebase. RA (Rust) + TeX. +For private layout/parser/font module responsibilities and same-machine refactor +acceptance, see [Core implementation boundaries](CORE_INTERNALS.md). + --- ## Root Layout