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:
2026-08-04 20:31:05 +08:00
committed by 洪佳荣
parent e0bd6120ec
commit 9927d38c18
165 changed files with 2638 additions and 691 deletions
+160 -6
View File
@@ -1,11 +1,12 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008
//! on-disk format.
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029
//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
//! `[[parts]]`).
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_model::load;
use cph_model::{load, OutlineEntry};
/// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf {
@@ -39,6 +40,16 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
// The outline is a flat sequence of elements-by-index when there are no
// containers.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element { part_index: 0 },
OutlineEntry::Element { part_index: 1 },
]
);
// `source` scalar survives on the lemma descriptor; `kind` is removed.
let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!(
@@ -74,6 +85,58 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
);
}
#[test]
fn nested_sections_flatten_depth_first_with_correct_depths() {
let (lesson, diags) = load(&fixture("nested"));
let lesson = lesson.expect("nested fixture must produce a Lesson");
assert!(
diags.is_empty(),
"nested fixture must have no diagnostics, got: {diags:?}"
);
// DFS pre-order element sequence (ADR-0029): containers contribute no
// element of their own.
let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect();
assert_eq!(
paths,
vec![
PathBuf::from("segments/开场白"),
PathBuf::from("导言簇/segments/子段一"),
PathBuf::from("导言簇/嵌套子节/lemmas/子引理"),
PathBuf::from("导言簇/segments/子段二"),
],
"root-relative paths must accumulate through every nesting level"
);
// The outline interleaves section headings at their DFS-open position,
// with depth 1 for a section directly under the root and depth 2 for one
// nested inside another section.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element { part_index: 0 }, // segments/开场白
OutlineEntry::Section {
kind: "section".to_string(),
title: "导言簇".to_string(),
depth: 1,
path: PathBuf::from("导言簇"),
},
OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一
OutlineEntry::Section {
kind: "section".to_string(),
title: "嵌套子节".to_string(),
depth: 2,
path: PathBuf::from("导言簇/嵌套子节"),
},
OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理
OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二
]
);
// The outer section declares [group].title = "导言簇"; the inner section
// has no [group] at all, so its title falls back to the folder basename.
}
#[test]
fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part"));
@@ -126,7 +189,7 @@ fn malformed_manifest_is_a_hard_failure() {
"malformed manifest must be a hard failure (None)"
);
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
assert_eq!(diags[0].severity, cph_diag::Severity::Error);
}
@@ -136,7 +199,98 @@ fn missing_manifest_is_a_hard_failure() {
let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
#[test]
fn folder_with_both_manifest_and_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("both-manifest-and-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the ambiguous child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("both manifest.toml and element.toml"),
"message should explain the ambiguity, got: {}",
malformed[0].message
);
}
#[test]
fn folder_with_neither_manifest_nor_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("neither-manifest-nor-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the incomplete child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("neither manifest.toml nor element.toml"),
"message should explain the gap, got: {}",
malformed[0].message
);
}
#[test]
fn container_declaring_root_only_tables_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("container-root-tables"));
assert!(
lesson.is_some(),
"a container misplacing root tables is non-fatal"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-only-table diagnostic expected, got: {diags:?}"
);
}
#[test]
fn root_manifest_declaring_group_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("root-group-declared"));
assert!(lesson.is_some(), "the root declaring [group] is non-fatal");
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-[group] diagnostic expected, got: {diags:?}"
);
}
#[test]
@@ -290,7 +444,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let p = tmp.path();
std::fs::write(
p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
)
.unwrap();
let seg = p.join("segments").join("a");