//! Integration tests for `cph-typst` under the ADR-0011 model: the engine //! compiles a **template file** (`exports/.typ`) as main, injecting an //! **augmented manifest** (per-part `fields` array of on-disk content fields) //! served as a virtual file, and resolving `@local/cph-render` + vendored //! `@preview/numbly` from the repo `render/` directory. //! //! There is no render-stub any more: the stock templates import the real //! `cph-render` package's full API (`render-lesson`, `part-fields`, //! `default-heading-numbering`), so the tests point the [`Engine`] at the real //! repo `render/` package. These tests require system CJK fonts (present on dev //! and CI via fonts-noto-cjk) and the vendored numbly (committed under //! `render/vendor/`), so they run fully offline. use std::path::PathBuf; use cph_diag::Severity; use cph_typst::{build_augmented_manifest, Engine}; fn fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini") } 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 augmented manifest carries /// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the /// content fields present on disk. #[test] fn augmented_manifest_has_per_part_fields() { let lesson = load_mini(); let src = build_augmented_manifest(&lesson); // Info round-trips. assert!(src.contains("迷你示例课时"), "info.title present:\n{src}"); assert!(src.contains("测试作者"), "info.author present:\n{src}"); // Parse it back to inspect the per-part fields precisely. let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML"); let parts = doc .get("parts") .and_then(|p| p.as_array()) .expect("parts array present"); assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); // `fields` is a presence SET (the template tests membership), so order is // not load-bearing; sort for a stable assertion. let fields_of = |idx: usize| -> Vec { let mut v: Vec = parts[idx] .get("fields") .and_then(|f| f.as_array()) .expect("part has a fields array") .iter() .map(|v| v.as_str().unwrap().to_string()) .collect(); v.sort(); v }; let path_of = |idx: usize| { parts[idx] .get("path") .unwrap() .as_str() .unwrap() .to_string() }; let kind_of = |idx: usize| { parts[idx] .get("kind") .unwrap() .as_str() .unwrap() .to_string() }; // Order preserved: segment, lemma (w/ proof), lemma (no proof), example. assert_eq!(kind_of(0), "segment"); assert_eq!(kind_of(1), "lemma"); assert_eq!(kind_of(2), "lemma"); assert_eq!(kind_of(3), "example"); // Paths kept as forward-slash UTF-8. assert_eq!(path_of(0), "segments/开场对照导言"); assert_eq!(path_of(2), "lemmas/无证明引理"); // segment: only `textbook` exists. assert_eq!(fields_of(0), vec!["textbook"]); // lemma WITH proof.typ: both stmt + proof present (sorted). assert_eq!(fields_of(1), vec!["proof", "stmt"]); // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path). assert_eq!( fields_of(2), vec!["stmt"], "proof must be omitted when absent" ); // example: problem + solution present (source is a scalar, not a content field). assert_eq!(fields_of(3), vec!["problem", "solution"]); } /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the /// student template as main with the injected augmented manifest is clean (zero /// Error-severity diagnostics). This proves the template → manifest → include /// loop → cph-render path, including the optional-content gate (the second lemma /// has no proof.typ and must NOT cause a missing-include error). #[test] fn compile_check_clean_through_template() { let lesson = load_mini(); let engine = Engine::with_render_dir(real_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:#?}"); } /// THROUGH-TEMPLATE PDF export, fully offline (vendored numbly + @local/cph-render): /// a non-trivial PDF is produced for both targets through the real templates. /// This is also the offline-`@preview/numbly` proof — `render/src/style.typ` /// imports `@preview/numbly:0.1.0`, resolved from the vendored dir with no /// network access; a failure there would surface as a package-not-found error. #[test] fn build_pdf_through_template_offline() { 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() ); 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"), "{target} output is a PDF"); assert!( pdf.len() > 1024, "{target} PDF is non-trivial (got {} bytes)", pdf.len() ); eprintln!( "build_pdf_through_template_offline: {target} PDF = {} bytes (numbly resolved offline)", pdf.len() ); let out = std::env::temp_dir().join(format!("cph-typst-mini-{target}.pdf")); std::fs::write(&out, &pdf).expect("write pdf"); } } /// An undeclared target name (when the lesson declares at least one target) is a /// blocking `SchemaViolation`, not a compile attempt. #[test] fn unknown_target_is_blocking() { let lesson = load_mini(); let engine = Engine::with_render_dir(real_render_dir()); let diags = engine.compile_check(&lesson, "nonexistent"); assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}"); assert_eq!(diags[0].severity, Severity::Error); assert!( diags[0].message.contains("not declared"), "expected an undeclared-target error: {diags:#?}" ); } /// A `file-tree` artifact is deferred for MVP: a clear "not yet implemented" /// blocking diagnostic rather than a wrong build. #[test] fn file_tree_artifact_is_deferred() { use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig}; let lesson = Lesson { project: Project { id: "x".into(), name: "x".into(), }, info: Info { title: "t".into(), authors: vec![], }, parts: vec![], targets: vec![TargetConfig { name: "web".into(), artifact: Artifact::FileTree { root: PathBuf::from("build/web"), outputs: "**/*.html".into(), }, steps: vec![Step::TypstCompile { template: PathBuf::from("exports/web.typ"), }], covers: None, }], root: fixture_root(), }; let engine = Engine::with_render_dir(real_render_dir()); let diags = engine.compile_check(&lesson, "web"); assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}"); assert!( diags[0].message.contains("file-tree"), "expected a file-tree deferral: {diags:#?}" ); } /// A target whose only step is a `shell` step (no typst-compile) is deferred for /// MVP with a clear blocking diagnostic. #[test] fn shell_only_target_is_deferred() { use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig}; let lesson = Lesson { project: Project { id: "x".into(), name: "x".into(), }, info: Info { title: "t".into(), authors: vec![], }, parts: vec![], targets: vec![TargetConfig { name: "packaged".into(), artifact: Artifact::SingleFile { filepath: PathBuf::from("build/packaged.zip"), }, steps: vec![Step::Shell { run: "echo hi".into(), }], covers: None, }], root: fixture_root(), }; let engine = Engine::with_render_dir(real_render_dir()); let diags = engine.compile_check(&lesson, "packaged"); assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}"); assert!( diags[0].message.contains("no typst-compile step"), "expected a no-typst-compile-step deferral: {diags:#?}" ); }