forked from EduCraft/curriculum-project-hub
bd1699a4c0
Integrate the WU-B (structured targets) and WU-C (numbly, config) changes. cph-typst: - World resolves any @preview/* package from the in-repo vendored dir (<render_dir>/vendor/typst-packages/preview/<name>/<version>/), fully offline — no typst-kit download. Proven: build_pdf_with_real_render compiles the real render package (which imports @preview/numbly:0.1.0) through the embedded engine, PDF out. - Driver threads each target's presentation config: looks up the TargetConfig by name, emits `config: (numbering: (heading: (...)))` (escaped) when an override is present, else `config: (:)` (render default). - target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is single-file, deferred per ADR-0009); --target not declared → blocking diagnostic; no-targets lesson still defaults through. cph-check: - Migrated to Lesson.targets: Vec<TargetConfig> (target_names() / target.name at the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic). Workspace: fmt + clippy -D warnings + all tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
9.8 KiB
Rust
256 lines
9.8 KiB
Rust
//! 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");
|
|
}
|
|
}
|