use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_rustyfi"))
}
fn repo_lib_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi")
}
fn repo_lib_root_v01_only() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi/dist-v01/packages")
}
fn phase2_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/phase2.saty")
}
fn itemize_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v01-itemize.saty")
}
fn tmpdir(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!(
"rustyfi-format-html-reflow-{tag}-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
n
));
std::fs::create_dir_all(&p).unwrap();
p
}
fn assert_ok(out: &Output, ctx: &str) {
assert!(
out.status.success(),
"{ctx}: compile failed (code {:?})\nstdout:\n{}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
fn compile(fixture: &Path, work: &Path, fmt: &str, out_ext: &str) -> PathBuf {
let out = work.join(format!("out.{out_ext}"));
let result = Command::new(bin())
.arg(fixture)
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), repo_lib_root().as_os_str()])
.args(["--cache-dir".as_ref(), work.join("cache").as_os_str()])
.args(["--format", fmt])
.output()
.expect("spawn rustyfi");
assert_ok(&result, &format!("compile --format {fmt}"));
out
}
fn compile_v01(fixture: &Path, work: &Path, fmt: &str, out_ext: &str) -> PathBuf {
let out = work.join(format!("out.{out_ext}"));
let result = Command::new(bin())
.arg(fixture)
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), repo_lib_root_v01_only().as_os_str()])
.args(["--cache-dir".as_ref(), work.join("cache").as_os_str()])
.args(["--format", fmt])
.args(["--lang", "0.1"])
.output()
.expect("spawn rustyfi");
assert_ok(&result, &format!("compile --format {fmt} --lang 0.1"));
out
}
fn rendered_text(html: &str) -> String {
let mut out = String::new();
let mut depth = 0usize;
for ch in html.chars() {
match ch {
'<' => depth += 1,
'>' => depth = depth.saturating_sub(1),
c if depth == 0 && !c.is_whitespace() => out.push(c),
_ => {}
}
}
out
}
fn assert_no_positioned_offsets(full: &str) {
let html = body_of_doc(full);
assert!(
!html.contains("position:absolute") && !html.contains("position: absolute"),
"reflow content must never use position:absolute:\n{html}"
);
for prop in ["top:", "left:"] {
for (idx, _) in html.match_indices(prop) {
let before = &html[..idx];
assert!(
["margin-", "border-", "padding-", "-"]
.iter()
.any(|p| before.ends_with(p)),
"found a bare `{prop}` CSS declaration at byte {idx}:\n{html}"
);
}
}
}
#[test]
fn format_html_writes_flowing_paragraphs_in_reading_order() {
let work = tmpdir("basic");
let out = compile(&phase2_fixture(), &work, "html", "html");
let html = std::fs::read_to_string(&out).expect("--format html must write the output file");
assert!(
!html.contains("class=\"page\""),
"the reflowed document must have no pages at all:\n{html}"
);
assert!(
html.starts_with("<!doctype html>"),
"missing doctype:\n{html}"
);
let para_count = html.matches("<p class=\"para\"").count();
assert!(
para_count >= 3,
"expected at least 3 <p> paragraphs (one per +p), got {para_count}:\n{html}"
);
let text = rendered_text(&html);
for word in ["Bracketed", "text", "via", "let-inline."] {
assert!(
text.contains(word),
"missing word {word:?} from the first paragraph:\n{html}"
);
}
for word in ["Announced", "lightweight", "let-inline", "form."] {
assert!(
text.contains(word),
"missing word {word:?} from the second paragraph:\n{html}"
);
}
for word in ["Countdown", "complete."] {
assert!(
text.contains(word),
"missing word {word:?} from the third paragraph:\n{html}"
);
}
let pos_bracket = text
.find("Bracketed")
.expect("missing first paragraph's text");
let pos_announce = text
.find("Announced")
.expect("missing second paragraph's text");
let pos_chosen = text
.find("Countdown")
.expect("missing third paragraph's (match-computed) text");
assert!(
pos_bracket < pos_announce && pos_announce < pos_chosen,
"paragraphs are out of reading order:\n{html}"
);
assert_no_positioned_offsets(&html);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn default_pdf_format_is_unaffected_by_the_new_reflow_format() {
let work = tmpdir("pdf");
let out = compile(&phase2_fixture(), &work, "pdf", "pdf");
let bytes = std::fs::read(&out).expect("--format pdf must write the output file");
assert!(
bytes.starts_with(b"%PDF-"),
"default --format must still produce a PDF"
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn format_html_reflow_renders_nested_lists_and_emphasis_for_itemize() {
let work = tmpdir("itemize-reflow");
let out = compile_v01(&itemize_fixture(), &work, "html-reflow", "html");
let html =
std::fs::read_to_string(&out).expect("--format html-reflow must write the output file");
assert!(
html.starts_with("<!doctype html>"),
"missing doctype:\n{html}"
);
assert_eq!(
html.matches("<ul").count(),
2,
"expected outer + one nested <ul>:\n{html}"
);
assert_eq!(
html.matches("</ul>").count(),
2,
"expected outer + one nested </ul>:\n{html}"
);
assert_eq!(
html.matches("<ol").count(),
1,
"expected exactly one <ol>:\n{html}"
);
assert_eq!(
html.matches("</ol>").count(),
1,
"expected exactly one </ol>:\n{html}"
);
assert_eq!(
html.matches("<li").count(),
4,
"expected 4 <li>s total:\n{html}"
);
let text = rendered_text(&html);
for word in ["nested", "item", "first", "entry", "second"] {
assert!(text.contains(word), "missing item word {word:?}:\n{html}");
}
assert_eq!(
text.matches("item").count(),
2,
"expected \"item\" exactly twice (the top item + the nested item):\n{html}"
);
assert!(
html.contains("<em>") && html.contains("</em>"),
"missing <em>:\n{html}"
);
assert!(
!html.contains("<strong>"),
"must not render <strong> for \\emph:\n{html}"
);
assert!(
text.contains("emphasized"),
"missing emphasized text:\n{html}"
);
assert_no_positioned_offsets(&html);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn itemize_fixture_still_produces_a_valid_pdf() {
let work = tmpdir("itemize-pdf");
let out = compile_v01(&itemize_fixture(), &work, "pdf", "pdf");
let bytes = std::fs::read(&out).expect("--format pdf must write the output file");
assert!(
bytes.starts_with(b"%PDF-"),
"itemize fixture must still produce a valid PDF"
);
assert!(
bytes.len() > 200,
"PDF unexpectedly tiny ({} bytes)",
bytes.len()
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn html_reflow_is_still_accepted_as_an_alias_of_html() {
let work = tmpdir("alias");
let via_alias =
std::fs::read_to_string(compile(&phase2_fixture(), &work, "html-reflow", "html"))
.expect("--format html-reflow must still write the output file");
assert!(
via_alias.contains("<p class=\"para\"") && !via_alias.contains("class=\"page\""),
"the alias must select the REFLOW backend:\n{via_alias}"
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn an_unknown_format_is_rejected_by_name() {
let work = tmpdir("badfmt");
let result = Command::new(bin())
.arg(phase2_fixture())
.args(["-o".as_ref(), work.join("out.html").as_os_str()])
.args(["--lib-root".as_ref(), repo_lib_root().as_os_str()])
.args(["--format", "htlm"])
.output()
.expect("spawn rustyfi");
assert!(!result.status.success(), "a bogus --format must fail");
let msg = String::from_utf8_lossy(&result.stderr);
assert!(
msg.contains("pdf") && msg.contains("html"),
"the rejection should name the available formats:\n{msg}"
);
assert!(
!msg.contains("html-fixed"),
"the removed faithful backend must not be offered:\n{msg}"
);
std::fs::remove_dir_all(&work).ok();
}
fn body_of_doc(html: &str) -> &str {
html.split("<body>").nth(1).unwrap_or(html)
}