forked from EduCraft/curriculum-project-hub
feat(model): author 是有序列表;Info(canonical) vs RawInfo(授权 surface) 入契约
一节课可多人署名(教研组),故 canonical author 是**有序列表**而非单值。on-disk 形式接受裸字符串(单作者)或数组(多作者),但此"字符串或数组"二态**只活在加载边界**: RawInfo 经归一化折叠成 canonical Info,其后系统只见 List。 Lean 母本(ADR-0008 范围):新增 Model/Info.lean,把 Info(canonical,authors: List String)与 RawInfo/RawAuthor(授权便利 surface)+ 归一化 RawInfo.toInfo 钉死—— canonical 接收端恒为列表,raw 形式不泄漏。ADR-0008 [info] 块补 author-as-list 决策。 impl 对齐:cph-model Info.author: Option<String> → authors: Vec<String>;RawAuthor untagged enum(One|Many)+ into_vec 归一化;augmented manifest 发射 author 数组 (typst document(author:) 与模板已接受 string/array)。doc 两端互引 spec。 测试:load.rs 单作者→单元素列表;target-configs fixture 加数组作者断言多作者。 验证:lake build 绿(25 jobs);cargo test 全绿;clippy 静默;TH-141(单字符串作者) check+build PDF 无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,12 +211,23 @@ pub struct Project {
|
||||
}
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
|
||||
/// `authors` is always a list. The authoring-surface form (string-or-array
|
||||
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
|
||||
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
|
||||
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Info {
|
||||
/// Lesson title.
|
||||
pub title: String,
|
||||
/// Author, optional.
|
||||
pub author: Option<String>,
|
||||
/// Authors, in declared order. A lesson can have several (a teaching group),
|
||||
/// so this is a list, not a single name. Empty when `[info]` declares no
|
||||
/// `author`. The on-disk `author` accepts either a bare string (one author)
|
||||
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
|
||||
/// Mirrors Lean `Info.authors : List String`.
|
||||
pub authors: Vec<String>,
|
||||
}
|
||||
|
||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
||||
@@ -265,10 +276,36 @@ struct RawProject {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
|
||||
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
|
||||
/// fill-in convenience, normalized into the canonical [`Info`] at the load
|
||||
/// boundary. Not the form the rest of the model traffics in.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawInfo {
|
||||
title: String,
|
||||
author: Option<String>,
|
||||
author: Option<RawAuthor>,
|
||||
}
|
||||
|
||||
/// On-disk `author`: either a single name (`author = "…"`) or a list
|
||||
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
|
||||
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
|
||||
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawAuthor {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
|
||||
impl RawAuthor {
|
||||
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
|
||||
/// name becomes a one-element list; a list passes through verbatim.
|
||||
fn into_vec(self) -> Vec<String> {
|
||||
match self {
|
||||
RawAuthor::One(s) => vec![s],
|
||||
RawAuthor::Many(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -361,7 +398,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
let info = match raw.info {
|
||||
Some(i) => Info {
|
||||
title: i.title,
|
||||
author: i.author,
|
||||
authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(),
|
||||
},
|
||||
None => {
|
||||
diags.push(
|
||||
|
||||
@@ -4,6 +4,8 @@ name = "target-configs"
|
||||
|
||||
[info]
|
||||
title = "测试课:导出目标的结构化 build 配置"
|
||||
# Array form: a lesson authored by several people.
|
||||
author = ["张老师", "李老师"]
|
||||
|
||||
# Full v2 config: explicit single-file artifact + an ordered typst-compile step.
|
||||
[targets.student]
|
||||
|
||||
@@ -27,7 +27,8 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(lesson.project.id, "fixture-valid");
|
||||
assert_eq!(lesson.project.name, "valid-2-part");
|
||||
assert_eq!(lesson.info.title, "测试课:两个部件");
|
||||
assert_eq!(lesson.info.author.as_deref(), Some("范式教育教研组"));
|
||||
// A single-string `author` loads as a one-element list.
|
||||
assert_eq!(lesson.info.authors, vec!["范式教育教研组".to_string()]);
|
||||
|
||||
// Parts preserve declared order: segment first, lemma second.
|
||||
assert_eq!(lesson.parts.len(), 2);
|
||||
@@ -149,6 +150,12 @@ fn structured_target_configs_parse_into_typed_builds() {
|
||||
"well-formed target configs must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
// An array `author` loads as the ordered author list.
|
||||
assert_eq!(
|
||||
lesson.info.authors,
|
||||
vec!["张老师".to_string(), "李老师".to_string()]
|
||||
);
|
||||
|
||||
// Declared order is preserved.
|
||||
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user