Files
curriculum-project-hub/crates/cph-check/tests/pipeline.rs
T
sjfhsjfh c684d25d50 chore(spec+checker): cleanup pass — trim docs, reorg Courseware, covers-as-data, fold DanglingReference (ADR-0012)
Spec母本噪音清理 + 一处 spec↔impl 对齐 + 一处契约自洽修正。

Part A — spec doc 瘦身:每条 doc 收到"语义点 + 标签 + ADR ref + 承载性 why",
把跨文件复读的方法论(element-kind 开放性对比、likec4 画不出、分歧点测试)上移到
module header。OPEN 框架保持清晰(RunState/Capability 完整性仍明示须 surface)。

Part B — Courseware/ 由 10 文件平铺重组为 Model/ Export/ Check/ Open/ 四子命名空间
(namespace Spec.Courseware 不变,零引用改动)+ 四个子 aggregator。lake build 绿(24 jobs)。

Part C — 渲染覆盖落为数据(ADR-0011 对齐):去掉 cph-check 里硬编码的
COVERED_TARGETS,改读 TargetConfig.covers。cph-model 新增 covers: Option<Vec<String>>
(None=未声明,由 cph-check 默认为全部 known kinds;显式 [] 表示不覆盖任何 kind)。
新增 3 个覆盖行为测试。

ADR-0012 — DanglingReference 退役(诊断 7→6 类):其两种情形(未解析 @ref、越界/缺失
相对 import)都是 typst 编译期失败,归 typstCompile。同步移除 Oracle.refsResolve
(被 compiles 蕴含),Legal 少一个合取项。impl 删去从未被发射的 DiagCode::DanglingReference,
闭合 named-but-unemitted 的 spec↔impl 缝。ADR-0010 加修订指针。

OPEN 点(RunState/Capability 完整性、QuestionBank、Course)按既定纪律保持 OPEN,本次
只改善其框架措辞,不决策。

验证:spec lake build 绿;cargo test 全绿(含新增覆盖测试);clippy 静默;
TH-141 check 0 errors/0 warnings 无回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:59:32 +08:00

284 lines
7.6 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::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: &PathBuf, 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);
}
// --- 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: &PathBuf) {
let _ = std::fs::remove_dir_all(p);
}