//! Integration tests for `cph-typst`: driver generation, World, compile-check, //! and PDF export over the `tests/fixtures/mini` lesson. //! //! Render-package dependency: most tests point the [`Engine`] at the bundled //! **render-stub** (`tests/fixtures/render-stub`) so the World/compile/PDF path //! is proven independently of WU-2. One `#[ignore]`d test points at the real //! repo `render/` package for when it is ready / for manual runs. use std::path::PathBuf; use cph_diag::Severity; use cph_typst::{generate_driver, Engine}; fn fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini") } fn stub_render_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/render-stub") } fn real_render_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("render") } fn load_mini() -> cph_model::Lesson { let (lesson, diags) = cph_model::load(&fixture_root()); let lesson = lesson.expect("mini fixture loads into a Lesson"); let errors: Vec<_> = diags .iter() .filter(|d| d.severity == Severity::Error) .collect(); assert!(errors.is_empty(), "fixture has loader errors: {errors:?}"); lesson } /// PURE UNIT TEST (no fonts, no render package): the generated driver string /// has the expected shape, order, includes, and scalar. #[test] fn driver_gen_shape_and_order() { let lesson = load_mini(); let src = generate_driver(&lesson, "student"); // Imports the render package and calls display. assert!(src.contains("#import \"@local/cph-render:0.1.0\": display")); assert!(src.contains("#display(")); assert!(src.contains("target: \"student\"")); assert!(src.contains("title: \"迷你示例课时\"")); assert!(src.contains("author: \"测试作者\"")); // Root-relative includes with UTF-8 folder names, one per content field. assert!(src.contains("include \"/segments/开场对照导言/textbook.typ\"")); assert!(src.contains("include \"/lemmas/量纲分析估计/stmt.typ\"")); assert!(src.contains("include \"/lemmas/量纲分析估计/proof.typ\"")); assert!(src.contains("include \"/examples/自由落体/problem.typ\"")); assert!(src.contains("include \"/examples/自由落体/solution.typ\"")); // Example scalar `source` is emitted from element.toml. assert!(src.contains("source: \"自拟\"")); // Parts appear in manifest order: segment, then lemma, then example. let seg = src.find("kind: \"segment\"").expect("segment present"); let lem = src.find("kind: \"lemma\"").expect("lemma present"); let exa = src.find("kind: \"example\"").expect("example present"); assert!(seg < lem && lem < exa, "parts must keep manifest order"); } /// Optional `proof.typ` is only included when it exists on disk. (It exists in /// the mini fixture, so we assert presence; absence is covered by construction /// — a kind without the file would simply omit the binding.) #[test] fn optional_proof_included_when_present() { let lesson = load_mini(); let src = generate_driver(&lesson, "teacher"); assert!(src.contains("_p1_proof = include")); } /// The mini fixture's targets carry no `numbering.heading` override, so the /// driver emits an empty `config: (:)` (render package applies its default). #[test] fn driver_emits_empty_config_when_no_override() { let lesson = load_mini(); let src = generate_driver(&lesson, "student"); assert!( src.contains("config: (:)"), "expected empty config for a target with no numbering override:\n{src}" ); } /// CONFIG THREADING: a target declaring a `numbering.heading` override produces /// a driver that passes `config: (numbering: (heading: (...)))` with the /// patterns as escaped typst string literals, in order. #[test] fn driver_threads_numbering_heading_config() { use cph_model::{ArtifactKind, Info, Lesson, NumberingConfig, Project, TargetConfig}; use std::path::PathBuf; let lesson = Lesson { project: Project { id: "x".into(), name: "x".into(), }, info: Info { title: "t".into(), author: None, }, parts: vec![], targets: vec![TargetConfig { name: "student".into(), artifact: ArtifactKind::SingleFile, numbering: Some(NumberingConfig { heading: Some(vec!["{1:一}、".into(), "{1:1}.{2:1}".into()]), }), }], root: PathBuf::from("/tmp/does-not-matter"), }; let src = generate_driver(&lesson, "student"); assert!( src.contains(r#"config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}",)))"#), "expected threaded numbering config in driver:\n{src}" ); } /// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should /// produce zero Error-severity diagnostics. If this fails purely because the /// test host lacks fonts, the failure will be a typst font error — see the /// note in the crate docs. #[test] fn compile_check_clean_with_stub() { let lesson = load_mini(); let engine = Engine::with_render_dir(stub_render_dir()); let diags = engine.compile_check(&lesson, "student"); let errors: Vec<_> = diags .iter() .filter(|d| d.severity == Severity::Error) .collect(); assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}"); } /// FONT-DEPENDENT: a real PDF is produced through the render-stub and is /// non-trivial in size. #[test] fn build_pdf_with_stub() { let lesson = load_mini(); let engine = Engine::with_render_dir(stub_render_dir()); let pdf = engine .build_pdf(&lesson, "student") .unwrap_or_else(|d| panic!("PDF build failed: {d:#?}")); assert!(pdf.starts_with(b"%PDF"), "output is a PDF"); assert!( pdf.len() > 1024, "PDF is non-trivial (got {} bytes)", pdf.len() ); let out = std::env::temp_dir().join("cph-typst-mini-student.pdf"); std::fs::write(&out, &pdf).expect("write pdf"); assert!(std::fs::metadata(&out).unwrap().len() > 1024); } /// A diagnostic that lands inside the generated driver is re-pointed at the /// driver path with a "generated driver" hint, not dropped. We force this by /// compiling against a render dir whose `display` does not exist, so the /// driver's `#display(...)` call is unknown — the error span is in the driver. #[test] fn driver_internal_span_is_repointed() { let lesson = load_mini(); // Point at a render dir with an EMPTY lib.typ (no `display`), so calling // `#display(...)` errors inside the driver. let empty = std::env::temp_dir().join("cph-typst-empty-render"); std::fs::create_dir_all(&empty).unwrap(); std::fs::write( empty.join("typst.toml"), "[package]\nname=\"cph-render\"\nversion=\"0.1.0\"\nentrypoint=\"lib.typ\"\n\ authors=[\"t\"]\nlicense=\"MIT\"\ndescription=\"empty\"\n", ) .unwrap(); std::fs::write(empty.join("lib.typ"), "// no display defined\n").unwrap(); let engine = Engine::with_render_dir(empty); let diags = engine.compile_check(&lesson, "student"); let errors: Vec<_> = diags .iter() .filter(|d| d.severity == Severity::Error) .collect(); assert!(!errors.is_empty(), "expected an unknown-`display` error"); // At least one error should point at the driver and carry the driver hint. let driver_err = errors.iter().find(|d| { d.span .as_ref() .is_some_and(|s| s.file.to_string_lossy().contains(".cph/driver-")) }); assert!( driver_err.is_some(), "an error span should be re-pointed at the generated driver: {errors:#?}" ); assert!( driver_err .unwrap() .hint .as_deref() .is_some_and(|h| h.contains("generated driver")), "driver-internal error should carry the generated-driver hint" ); } /// Full end-to-end against the REAL repo `render/` package: load the mini /// fixture, compile through `cph-render`'s `display`, export a PDF for both /// targets. This proves the whole World → driver → render-package → PDF path, /// not just the stub. Requires system CJK fonts (present on dev + CI via /// fonts-noto-cjk). /// /// **This is also the offline-`@preview/numbly` proof (Task 1).** The real /// render package's `src/style.typ` does `#import "@preview/numbly:0.1.0"`, so a /// clean PDF here means the embedded `World` resolved numbly from the vendored /// dir (`render/vendor/typst-packages/preview/numbly/0.1.0/`) with no network / /// package download — had it failed, `build_pdf` would have returned a /// package-not-found error and this test would panic. #[test] fn build_pdf_with_real_render() { let lesson = load_mini(); let render_dir = real_render_dir(); assert!( render_dir.join("lib.typ").is_file(), "real render package missing at {}", render_dir.display() ); // Sanity: the numbly the render package imports is actually vendored here. assert!( render_dir .join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ") .is_file(), "vendored numbly missing under {}", render_dir.display() ); let engine = Engine::with_render_dir(render_dir); for target in ["student", "teacher"] { let pdf = engine .build_pdf(&lesson, target) .unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}")); assert!(pdf.starts_with(b"%PDF")); assert!(pdf.len() > 1024, "{target} PDF too small"); eprintln!( "build_pdf_with_real_render: {target} PDF = {} bytes (numbly resolved offline)", pdf.len() ); let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf")); std::fs::write(&out, &pdf).expect("write pdf"); } }