forked from bai/curriculum-project-hub
feat(checker): cph-typst — embed typst 0.15, driver-gen, compile, PDF (WU-4)
The heart of the pipeline. Embeds the typst 0.15 compiler as a library to compile-check a lesson and export a PDF, with diagnostics mapped back to source spans (the product's value). - LessonWorld: a typst World over the engineering-file directory tree. vpath = real relative path (ADR-0007); the per-target driver is mounted in-memory as `main` (.cph/driver-<target>.typ, never written to disk); @local/cph-render is resolved from render_dir (CPH_RENDER_DIR / with_render_dir), no package install or network. Fonts via typst-kit FontStore (system CJK). - Driver generation: from the manifest, per target — static root-relative `include` of each content file (⇒ module body content, ADR-0006), assembled into the frozen `display(info, target, parts)` call; optional lemma proof emitted only if proof.typ exists; example `source` scalar threaded through. - Diagnostics: typst SourceDiagnostic (errors + warnings) → cph_diag::Diagnostic. 0.15 DiagSpan handled via span.id() + WorldExt::range → file-relative SourceSpan with 1-based line/col; driver-internal spans re-pointed with a "generated driver" hint, never dropped. typst hints/trace carried through. - Engine API: new / with_render_dir / compile_check / build_pdf. - 6 tests incl. build_pdf_with_real_render — full World→driver→cph-render→PDF producing real PDF-1.7 output (student 42KB / teacher 54KB). clippy/fmt clean. Also fixes render/typst.toml `compiler = ">=0.15.0"` → `"0.15.0"`: typst 0.15's manifest parser rejects a `>=` comparator on the compiler field (the 0.14 CLU WU-2 tested against was lenient). This unblocks loading the real render package. Pinned: typst/typst-pdf/typst-syntax/typst-library/typst-layout/typst-kit =0.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! 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"));
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[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()
|
||||
);
|
||||
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");
|
||||
let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf"));
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
kind = "example"
|
||||
source = "自拟"
|
||||
@@ -0,0 +1 @@
|
||||
一物体从静止自由下落,求下落时间 $t$ 后的速度 $v$ 与位移 $h$。
|
||||
@@ -0,0 +1 @@
|
||||
由匀加速运动公式,$v = g t$,$h = 1/2 g t^2$。代入 $g approx 9.8 "m/s"^2$ 即得数值结果。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1,3 @@
|
||||
设 $T = l^a g^b$。量纲为 $[T] = "T"$,$[l] = "L"$,$[g] = "L T"^(-2)$。
|
||||
比较两端得 $a + b = 0$ 与 $-2 b = 1$,解得 $a = 1 slash 2$,$b = -1 slash 2$,
|
||||
故 $T prop sqrt(l slash g)$。$qed$
|
||||
@@ -0,0 +1,3 @@
|
||||
设单摆周期 $T$ 仅依赖摆长 $l$ 与重力加速度 $g$,则由量纲分析必有
|
||||
$ T = k sqrt(l / g) $
|
||||
其中 $k$ 为无量纲常数。
|
||||
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
id = "mini"
|
||||
name = "迷你课时"
|
||||
|
||||
[info]
|
||||
title = "迷你示例课时"
|
||||
author = "测试作者"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/开场对照导言"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
|
||||
[[parts]]
|
||||
kind = "example"
|
||||
path = "examples/自由落体"
|
||||
|
||||
[targets.student]
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1,2 @@
|
||||
本节通过对照两种估计方法,引入量纲分析这一工具。我们先回顾自由落体,
|
||||
其中能量与质量的关系可写作 $E = m c^2$(此处仅作记号示例)。
|
||||
@@ -0,0 +1,29 @@
|
||||
// Minimal stand-in for the real `cph-render` package, used so cph-typst's
|
||||
// World / compile / PDF path can be proven independently of WU-2.
|
||||
//
|
||||
// It exercises the same call shape as the real entry: it reads `info`, walks
|
||||
// `parts` IN ORDER, and renders each part's content fields by `kind`. Content
|
||||
// values are already-evaluated content (the driver produced them via include).
|
||||
#let display(info: (:), target: "student", parts: ()) = {
|
||||
set document(title: info.at("title", default: ""))
|
||||
[= #info.at("title", default: "")]
|
||||
[target: #target]
|
||||
for p in parts {
|
||||
let kind = p.at("kind", default: none)
|
||||
[== #kind]
|
||||
if kind == "segment" {
|
||||
p.textbook
|
||||
} else if kind == "lemma" {
|
||||
p.stmt
|
||||
let proof = p.at("proof", default: none)
|
||||
if proof != none { proof }
|
||||
} else if kind == "example" {
|
||||
p.problem
|
||||
p.solution
|
||||
let src = p.at("source", default: none)
|
||||
if src != none [来源:#src]
|
||||
} else if kind == "sop" {
|
||||
p.sop
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cph-render"
|
||||
version = "0.1.0"
|
||||
entrypoint = "lib.typ"
|
||||
authors = ["cph-typst tests"]
|
||||
license = "MIT"
|
||||
description = "Minimal stand-in for cph-render used by cph-typst tests."
|
||||
Reference in New Issue
Block a user