forked from EduCraft/curriculum-project-hub
feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
interleaved with section headings at their DFS-open position); rejects
ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
byte-identical element order; smoke-verified via cph check/build + pdftotext
ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
--target for an explicit subset); any target failure => non-zero exit,
per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
headings, depth-shifts their own section headings, resets example/lemma
counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
compile), smoke-verified via a real 2-lesson merged PDF
Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
//! Integration tests for the bundle build path (ADR-0030): compiling a bundle
|
||||
//! target's template (`exports/<target>.typ` under a `bundle.toml` root) as
|
||||
//! main, injecting the augmented **bundle** manifest, against the real
|
||||
//! `render/` package.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Severity;
|
||||
use cph_typst::{build_augmented_bundle_manifest, Engine};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle")
|
||||
}
|
||||
|
||||
fn real_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
}
|
||||
|
||||
fn load_bundle() -> cph_model::Bundle {
|
||||
let (bundle, diags) = cph_model::load_bundle(&fixture_root());
|
||||
let bundle = bundle.expect("bundle fixture loads into a Bundle");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
|
||||
bundle
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest
|
||||
/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with
|
||||
/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0030).
|
||||
#[test]
|
||||
fn augmented_bundle_manifest_prefixes_member_paths() {
|
||||
let bundle = load_bundle();
|
||||
let src = build_augmented_bundle_manifest(&bundle);
|
||||
|
||||
assert!(
|
||||
src.contains("测试合集"),
|
||||
"bundle info.title present:\n{src}"
|
||||
);
|
||||
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
|
||||
let lessons = doc
|
||||
.get("lessons")
|
||||
.and_then(|l| l.as_array())
|
||||
.expect("lessons array present");
|
||||
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
|
||||
|
||||
// lesson-a: target defaults to its own first declared target ("student"),
|
||||
// outline paths are prefixed with "lesson-a/".
|
||||
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_target, "student");
|
||||
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
|
||||
// segment + section heading + lemma = 3 outline entries.
|
||||
assert_eq!(a_outline.len(), 3);
|
||||
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_seg_path, "lesson-a/segments/a");
|
||||
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_section_path, "lesson-a/小节");
|
||||
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
|
||||
|
||||
// lesson-b: bundle.toml overrides `target = "teacher"`.
|
||||
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_target, "teacher");
|
||||
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
|
||||
assert_eq!(b_outline.len(), 1);
|
||||
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_seg_path, "lesson-b/segments/b");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
|
||||
/// the bundle's `merged` target as main with the injected augmented bundle
|
||||
/// manifest is clean.
|
||||
#[test]
|
||||
fn compile_check_clean_through_bundle_template() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "merged");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
|
||||
/// produced from the two member lessons through the real bundle template.
|
||||
#[test]
|
||||
fn build_bundle_pdf_through_template_offline() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
|
||||
let pdf = engine
|
||||
.build_bundle_pdf(&bundle, "merged")
|
||||
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"bundle PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
|
||||
/// like a lesson's own unknown-target path.
|
||||
#[test]
|
||||
fn unknown_bundle_target_is_blocking() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "nonexistent");
|
||||
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
|
||||
assert_eq!(diags[0].severity, Severity::Error);
|
||||
assert!(
|
||||
diags[0].message.contains("not declared"),
|
||||
"expected an undeclared-target error: {diags:#?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user