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,83 @@
|
||||
//! Integration tests for `cph_model::load_bundle` (ADR-0030): an ordered
|
||||
//! arrangement of self-contained lessons, loaded from `bundle.toml`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load_bundle;
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_bundle_loads_lessons_in_order_with_overrides() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-valid"));
|
||||
let bundle = bundle.expect("valid bundle fixture must produce a Bundle");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"valid bundle fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
assert_eq!(bundle.info.title, "测试合集");
|
||||
assert_eq!(
|
||||
bundle.info.authors,
|
||||
vec!["张老师".to_string(), "李老师".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(bundle.lessons.len(), 2);
|
||||
|
||||
// lesson-a: no explicit `target` in bundle.toml -> falls back to the
|
||||
// lesson's own first declared target ("student").
|
||||
assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a"));
|
||||
assert_eq!(bundle.lessons[0].target, "student");
|
||||
assert_eq!(bundle.lessons[0].lesson.info.title, "课时A");
|
||||
|
||||
// lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the
|
||||
// lesson's own single declared target (also "teacher" here, but the point
|
||||
// is the bundle entry's `target` wins regardless).
|
||||
assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b"));
|
||||
assert_eq!(bundle.lessons[1].target, "teacher");
|
||||
assert_eq!(bundle.lessons[1].lesson.info.title, "课时B");
|
||||
|
||||
// The bundle's own targets are collected exactly like a lesson's.
|
||||
assert_eq!(bundle.target_names(), vec!["merged"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson"));
|
||||
let bundle = bundle.expect("must still produce a best-effort Bundle");
|
||||
assert!(
|
||||
bundle.lessons.is_empty(),
|
||||
"the missing lesson is skipped, not placeholder'd"
|
||||
);
|
||||
|
||||
let missing: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::PartPathMissing)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing.len(),
|
||||
1,
|
||||
"exactly one PartPathMissing expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-malformed"));
|
||||
assert!(bundle.is_none(), "malformed bundle.toml is a hard failure");
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all"));
|
||||
assert!(bundle.is_none());
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-both"
|
||||
name = "both"
|
||||
|
||||
[info]
|
||||
title = "文件夹既是容器又是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
+1
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
+1
@@ -0,0 +1 @@
|
||||
[[children]]
|
||||
@@ -0,0 +1,2 @@
|
||||
[info
|
||||
this is broken
|
||||
@@ -0,0 +1,5 @@
|
||||
[info]
|
||||
title = "缺失课时的合集"
|
||||
|
||||
[[lessons]]
|
||||
path = "does-not-exist"
|
||||
@@ -0,0 +1,16 @@
|
||||
[info]
|
||||
title = "测试合集"
|
||||
author = ["张老师", "李老师"]
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-a"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-b"
|
||||
target = "teacher"
|
||||
|
||||
[targets.merged]
|
||||
artifact = { type = "single-file", filepath = "build/merged.pdf" }
|
||||
[[targets.merged.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/merged.typ"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-a"
|
||||
name = "lesson-a"
|
||||
|
||||
[info]
|
||||
title = "课时A"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
A.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-b"
|
||||
name = "lesson-b"
|
||||
|
||||
[info]
|
||||
title = "课时B"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/b"
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
B.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-container-root-tables"
|
||||
name = "container-root-tables"
|
||||
|
||||
[info]
|
||||
title = "容器错误声明了根级表"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "section"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,5 @@
|
||||
[project]
|
||||
id = "should-not-be-here"
|
||||
name = "should-not-be-here"
|
||||
|
||||
children = []
|
||||
@@ -5,7 +5,7 @@ name = "kind-mismatch"
|
||||
[info]
|
||||
title = "kind 不一致测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ name = "missing-part"
|
||||
[info]
|
||||
title = "缺部件测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/does-not-exist"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-neither"
|
||||
name = "neither"
|
||||
|
||||
[info]
|
||||
title = "文件夹既不是容器也不是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
id = "fixture-nested"
|
||||
name = "nested"
|
||||
|
||||
[info]
|
||||
title = "嵌套结构测试"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/开场白"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "导言簇"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
开场白。
|
||||
@@ -0,0 +1,14 @@
|
||||
[group]
|
||||
title = "导言簇"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段一"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "嵌套子节"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段二"
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
子段一。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
子段二。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1 @@
|
||||
子引理陈述。
|
||||
@@ -0,0 +1,3 @@
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/子引理"
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
id = "fixture-root-group"
|
||||
name = "root-group"
|
||||
|
||||
[info]
|
||||
title = "根级 manifest 错误声明了 group"
|
||||
|
||||
[group]
|
||||
title = "不该在根级"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
a.
|
||||
+2
-2
@@ -6,11 +6,11 @@ name = "valid-2-part"
|
||||
title = "测试课:两个部件"
|
||||
author = "范式教育教研组"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/young"
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user