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:
@@ -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