//! Integration tests for the `cph-check` orchestrator. //! //! These exercise phase gating and the public API against the shared `mini` //! fixture (`crates/cph-typst/tests/fixtures/mini/`) and small throwaway //! fixtures built in a temp dir. The typst Engine is pointed at the repo's real //! `render/` package via `Engine::with_render_dir`. use std::path::{Path, PathBuf}; use cph_diag::DiagCode; use cph_typst::Engine; /// `/../cph-typst/tests/fixtures/mini` — the known-good lesson. fn mini_fixture() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("cph-typst") .join("tests") .join("fixtures") .join("mini") } /// `/../../render` — the repo render package. fn render_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("render") } fn engine() -> Engine { Engine::with_render_dir(render_dir()) } #[test] fn good_fixture_has_no_errors() { let report = cph_check::check(&mini_fixture(), &engine()); assert!(report.lesson_loaded, "mini fixture should load"); assert_eq!( report.error_count(), 0, "mini fixture should have zero errors, got: {:#?}", report.diagnostics ); assert!(!report.has_errors()); } #[test] fn unknown_kind_is_an_error() { // Build a throwaway lesson whose part declares kind "frob". let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "broken" name = "broken" [info] title = "broken" [[parts]] kind = "frob" path = "elements/widget" "#, ) .unwrap(); let part_dir = tmp.join("elements").join("widget"); std::fs::create_dir_all(&part_dir).unwrap(); std::fs::write(part_dir.join("element.toml"), "kind = \"frob\"\n").unwrap(); let report = cph_check::check(&tmp, &engine()); assert!(report.lesson_loaded); assert!(report.has_errors()); assert!( report .diagnostics .iter() .any(|d| d.code == DiagCode::UnknownKind && d.message.contains("frob")), "expected an UnknownKind error mentioning 'frob', got: {:#?}", report.diagnostics ); cleanup(&tmp); } #[test] fn hard_manifest_failure_sets_lesson_not_loaded() { let tmp = tempdir(); // No manifest.toml at all → cph_model::load returns None. let report = cph_check::check(&tmp, &engine()); assert!(!report.lesson_loaded, "missing manifest is a hard failure"); assert!(report.has_errors()); cleanup(&tmp); } #[test] fn build_returns_pdf_on_good_fixture() { let (pdf, report) = cph_check::build(&mini_fixture(), &engine(), "student"); assert!(report.lesson_loaded); assert_eq!( report.error_count(), 0, "no check errors expected, got: {:#?}", report.diagnostics ); let pdf = pdf.expect("expected PDF bytes for the good fixture"); assert!(pdf.starts_with(b"%PDF"), "output should be a PDF"); assert!(pdf.len() > 1000, "PDF should be non-trivial"); } #[test] fn build_returns_none_on_broken_fixture() { // A part folder is missing → cph-model flags PartPathMissing (an error), // so build refuses before ever compiling. let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "broken" name = "broken" [info] title = "broken" [[parts]] kind = "segment" path = "segments/does-not-exist" "#, ) .unwrap(); let (pdf, report) = cph_check::build(&tmp, &engine(), "student"); assert!(pdf.is_none(), "broken lesson must not produce a PDF"); assert!(report.has_errors()); cleanup(&tmp); } /// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]` /// body spliced in. Returns nothing; the caller runs `check`. fn write_segment_lesson_with_target(tmp: &Path, target_body: &str) { std::fs::write( tmp.join("manifest.toml"), format!( r#" [project] id = "cov" name = "cov" [info] title = "cov" [[parts]] kind = "segment" path = "segments/intro" {target_body} "# ), ) .unwrap(); let part_dir = tmp.join("segments").join("intro"); std::fs::create_dir_all(&part_dir).unwrap(); std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(part_dir.join("textbook.typ"), "Hello.\n").unwrap(); } #[test] fn covers_omitting_used_kind_warns_render_ignored() { // A target that declares `covers = ["example"]` does NOT cover the lesson's // `segment` part → exactly one RenderIgnored warning (non-blocking). let tmp = tempdir(); write_segment_lesson_with_target( &tmp, r#"[targets.handout] artifact = { type = "single-file", filepath = "build/handout.pdf" } covers = ["example"] [[targets.handout.steps]] type = "typst-compile" template = "exports/handout.typ" "#, ); let report = cph_check::check(&tmp, &engine()); assert!(report.lesson_loaded); let render_ignored: Vec<_> = report .diagnostics .iter() .filter(|d| d.code == DiagCode::RenderIgnored) .collect(); assert_eq!( render_ignored.len(), 1, "expected exactly one RenderIgnored warning, got: {:#?}", report.diagnostics ); // It is a non-blocking warning, so legality is unaffected by it. assert_eq!( report.warning_count(), 1, "the coverage gap is a warning: {:#?}", report.diagnostics ); cleanup(&tmp); } #[test] fn covers_including_used_kind_has_no_render_ignored() { // A target that covers `segment` (the only used kind) → no RenderIgnored. let tmp = tempdir(); write_segment_lesson_with_target( &tmp, r#"[targets.handout] artifact = { type = "single-file", filepath = "build/handout.pdf" } covers = ["segment"] [[targets.handout.steps]] type = "typst-compile" template = "exports/handout.typ" "#, ); let report = cph_check::check(&tmp, &engine()); assert!(report.lesson_loaded); assert!( !report .diagnostics .iter() .any(|d| d.code == DiagCode::RenderIgnored), "a covering target should yield no RenderIgnored, got: {:#?}", report.diagnostics ); cleanup(&tmp); } #[test] fn absent_covers_defaults_to_full_coverage() { // No `covers` key at all → the orchestrator defaults to all known kinds, so // even a non-"student"/"teacher" target name covers `segment`. (This is the // behavior change: coverage now follows the declaration/default, not a // hardcoded target-name allowlist.) let tmp = tempdir(); write_segment_lesson_with_target( &tmp, r#"[targets.handout] artifact = { type = "single-file", filepath = "build/handout.pdf" } [[targets.handout.steps]] type = "typst-compile" template = "exports/handout.typ" "#, ); let report = cph_check::check(&tmp, &engine()); assert!(report.lesson_loaded); assert!( !report .diagnostics .iter() .any(|d| d.code == DiagCode::RenderIgnored), "an absent `covers` defaults to full coverage, got: {:#?}", report.diagnostics ); cleanup(&tmp); } /// Write a one-segment lesson with a `file-tree` shell target whose `run` is the /// given command. Returns nothing; the caller runs `run_shell_target`. fn write_shell_target_lesson(tmp: &Path, run: &str) { std::fs::write( tmp.join("manifest.toml"), format!( r#" [project] id = "sh" name = "sh" [info] title = "sh" [[parts]] kind = "segment" path = "segments/intro" [targets.assets] artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }} [[targets.assets.steps]] type = "shell" run = "{run}" "# ), ) .unwrap(); let part_dir = tmp.join("segments").join("intro"); std::fs::create_dir_all(&part_dir).unwrap(); std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap(); } #[test] fn target_is_shell_detects_shape() { let tmp = tempdir(); write_shell_target_lesson(&tmp, "true"); assert!(cph_check::target_is_shell(&tmp, "assets")); assert!(!cph_check::target_is_shell(&tmp, "nonexistent")); cleanup(&tmp); } #[test] fn run_shell_target_executes_in_engineering_root() { // The command writes a file under the engineering root; cwd must be the root. let tmp = tempdir(); write_shell_target_lesson( &tmp, "mkdir -p build/assets && printf ok > build/assets/marker.txt", ); let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); assert!(report.ok, "shell target should succeed: {:#?}", report); assert_eq!(report.outcomes.len(), 1); assert!(report.outcomes[0].ok()); // The side effect landed in the engineering root. let marker = tmp.join("build").join("assets").join("marker.txt"); assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok"); cleanup(&tmp); } #[test] fn run_shell_target_reports_nonzero_exit() { let tmp = tempdir(); write_shell_target_lesson(&tmp, "exit 3"); let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); assert!(!report.ok, "non-zero exit must fail the run"); assert_eq!(report.outcomes.len(), 1); assert_eq!(report.outcomes[0].status, Some(3)); cleanup(&tmp); } #[test] fn run_shell_target_refuses_broken_lesson() { // A part path that doesn't exist → check error → shell steps never run. let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "sh" name = "sh" [info] title = "sh" [[parts]] kind = "segment" path = "segments/missing" [targets.assets] artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" } [[targets.assets.steps]] type = "shell" run = "printf SHOULD_NOT_RUN > build/ran.txt" "#, ) .unwrap(); let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); assert!(!report.ok); assert!( report.outcomes.is_empty(), "no command should run on a broken lesson" ); assert!(report.check.has_errors()); assert!( !tmp.join("build").join("ran.txt").exists(), "the command must not have executed" ); cleanup(&tmp); } // --- assemble-markdown target (ADR-0015) --------------------------------------- /// Write a two-segment lesson where each segment carries a `slides.md` markdown /// content file, plus a `slides` target that assembles them. The `which` slice /// controls which segments get a `slides.md` (so the "skip absent" path can be /// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`. fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) { let mut parts = String::new(); for (i, (name, _)) in slides.iter().enumerate() { if i > 0 { parts.push('\n'); } parts.push_str(&format!( "[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" )); } std::fs::write( tmp.join("manifest.toml"), format!( r#" [project] id = "md" name = "md" [info] title = "md" {parts} [targets.slides] artifact = {{ type = "single-file", filepath = "build/slides.md" }} [[targets.slides.steps]] type = "assemble-markdown" field = "slides" "# ), ) .unwrap(); for (name, body) in slides { let dir = tmp.join("segments").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); std::fs::write(dir.join("slides.md"), body).unwrap(); } } #[test] fn target_is_markdown_assemble_detects_shape() { let tmp = tempdir(); write_markdown_assemble_target_lesson(&tmp, &[("a", "# A"), ("b", "# B")]); assert!(cph_check::target_is_markdown_assemble(&tmp, "slides")); // The student target doesn't exist here → falls through to typst path → false. assert!(!cph_check::target_is_markdown_assemble(&tmp, "student")); cleanup(&tmp); } #[test] fn run_markdown_assemble_target_concatenates_in_parts_order() { let tmp = tempdir(); write_markdown_assemble_target_lesson( &tmp, &[ ("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$"), ], ); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); assert!(report.ok, "assembly should succeed: {:#?}", report); assert_eq!(report.outcomes.len(), 1); let o = &report.outcomes[0]; assert!(o.ok()); assert_eq!(o.parts_read, 2); assert_eq!(o.field, "slides"); let out = tmp.join("build").join("slides.md"); let body = std::fs::read_to_string(&out).unwrap(); // Both parts present, in declared order, joined by a blank line. let intro_idx = body.find("规则一").unwrap(); let rule2_idx = body.find("规则二").unwrap(); assert!(intro_idx < rule2_idx, "parts must keep manifest order"); assert!(body.contains("$8\\times$"), "KaTeX source survives as text"); cleanup(&tmp); } #[test] fn run_markdown_assemble_target_skips_parts_without_the_field() { // Only the first segment has a slides.md; the second is skipped (optional). let tmp = tempdir(); let mut parts = String::new(); parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); std::fs::write( tmp.join("manifest.toml"), format!( r#" [project] id = "md" name = "md" [info] title = "md" {parts} [targets.slides] artifact = {{ type = "single-file", filepath = "build/slides.md" }} [[targets.slides.steps]] type = "assemble-markdown" field = "slides" "# ), ) .unwrap(); for name in ["a", "b"] { let dir = tmp.join("segments").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); } // Only `a` gets a slides.md. std::fs::write(tmp.join("segments/a/slides.md"), "# A only\n").unwrap(); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); assert!(report.ok, "{:#?}", report); assert_eq!(report.outcomes[0].parts_read, 1); let body = std::fs::read_to_string(tmp.join("build/slides.md")).unwrap(); // The assembler prepends the course title (info.title = "md") as an h1, // then the single part's slides.md. assert_eq!(body, "# md\n\n# A only\n"); cleanup(&tmp); } #[test] fn run_markdown_assemble_target_copies_referenced_images() { // A slides.md that references two local images; both must be copied from // the engineering root into the build root (same relative path) so the // build dir is self-contained (ADR-0015). let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "md" name = "md" [info] title = "md" [[parts]] kind = "segment" path = "segments/a" [targets.slides] artifact = { type = "single-file", filepath = "build/slides.md" } [[targets.slides.steps]] type = "assemble-markdown" field = "slides" "#, ) .unwrap(); let dir = tmp.join("segments").join("a"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); std::fs::write( dir.join("slides.md"), "## A\n\n![one](assets/img/one.png)\n\n![two](assets/img/two.png)\n", ) .unwrap(); // The two referenced images exist at the engineering root. std::fs::create_dir_all(tmp.join("assets/img")).unwrap(); std::fs::write(tmp.join("assets/img/one.png"), b"PNG1").unwrap(); std::fs::write(tmp.join("assets/img/two.png"), b"PNG2").unwrap(); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); assert!(report.ok, "{:#?}", report); // Both images copied into the build root, preserving the assets/img/ path. assert_eq!( std::fs::read(tmp.join("build/assets/img/one.png")).unwrap(), b"PNG1" ); assert_eq!( std::fs::read(tmp.join("build/assets/img/two.png")).unwrap(), b"PNG2" ); cleanup(&tmp); } #[test] fn run_markdown_assemble_target_fails_on_missing_referenced_image() { // A slides.md references an image that does NOT exist at the engineering // root → a build-process error (self-contained build would be broken), // not a lesson diagnostic. let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "md" name = "md" [info] title = "md" [[parts]] kind = "segment" path = "segments/a" [targets.slides] artifact = { type = "single-file", filepath = "build/slides.md" } [[targets.slides.steps]] type = "assemble-markdown" field = "slides" "#, ) .unwrap(); let dir = tmp.join("segments").join("a"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); std::fs::write( dir.join("slides.md"), "## A\n\n![ghost](assets/img/ghost.png)\n", ) .unwrap(); // No image created → reference is dangling. let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); assert!(!report.ok, "a missing referenced image must fail the build"); let o = &report.outcomes[0]; assert!(o.error.as_deref().unwrap_or("").contains("ghost.png")); // The markdown was still written, but the image was not (it doesn't exist). assert!(tmp.join("build/slides.md").is_file()); cleanup(&tmp); } #[test] fn run_markdown_assemble_target_refuses_broken_lesson() { // A part path that doesn't exist → check error → assembly never runs. let tmp = tempdir(); std::fs::write( tmp.join("manifest.toml"), r#" [project] id = "md" name = "md" [info] title = "md" [[parts]] kind = "segment" path = "segments/missing" [targets.slides] artifact = { type = "single-file", filepath = "build/slides.md" } [[targets.slides.steps]] type = "assemble-markdown" field = "slides" "#, ) .unwrap(); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); assert!(!report.ok); assert!(report.outcomes.is_empty(), "no assembly on a broken lesson"); assert!(report.check.has_errors()); assert!( !tmp.join("build").join("slides.md").exists(), "nothing should have been written" ); cleanup(&tmp); } // --- tiny temp-dir helpers (no external dev-dep) ------------------------------ fn tempdir() -> PathBuf { let mut p = std::env::temp_dir(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); p.push(format!( "cph-check-test-{nanos}-{:?}", std::thread::current().id() )); std::fs::create_dir_all(&p).unwrap(); p } fn cleanup(p: &Path) { let _ = std::fs::remove_dir_all(p); }