forked from bai/curriculum-project-hub
676 lines
19 KiB
Rust
676 lines
19 KiB
Rust
//! 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;
|
|
|
|
/// `<this crate>/../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")
|
|
}
|
|
|
|
/// `<this crate>/../../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\n\n\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\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);
|
|
}
|