feat: improve typst handling
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
//! What gets emitted, and in what shape.
|
||||
//!
|
||||
//! This module holds the knobs that used to be `format!` calls. The split is
|
||||
//! deliberate: a template decides how a question *looks*, and this config decides
|
||||
//! what the template is *told*. Anything you can express by rearranging boxes
|
||||
//! belongs in the template, not here.
|
||||
//!
|
||||
//! The one setting that is not cosmetic is [`Reveal`]. It controls whether the
|
||||
//! payload contains the answer at all, and the reason it is a config value rather
|
||||
//! than a template concern is that a template cannot be trusted with it. If the
|
||||
//! exam paper's payload carries `correct: true`, then every future edit to that
|
||||
//! template is one `if` statement away from printing the key, and the failure mode
|
||||
//! is discovered by a room full of students. Withholding the field is the only
|
||||
//! version of this that stays correct under editing.
|
||||
//!
|
||||
//! Config is resolved in three layers, each overriding the last: the built-in
|
||||
//! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and
|
||||
//! that file's `variants:` block. `coursebank template config` writes the whole
|
||||
//! resolved thing out so there is no guessing about what applied.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Which document is being produced.
|
||||
///
|
||||
/// A variant is not a style. It is a different set of facts: the paper withholds
|
||||
/// the key, the key withholds the questions, and the answer sheet needs only the
|
||||
/// option counts. Each has its own template and its own default [`Reveal`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Variant {
|
||||
/// The question paper a student writes on.
|
||||
Exam,
|
||||
/// The grader's answer key.
|
||||
Key,
|
||||
/// A bubble sheet matching the form.
|
||||
AnswerSheet,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Every variant, in the order `export` writes them.
|
||||
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
|
||||
|
||||
/// The token used on the command line, in config keys, and in file names.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Exam => "exam",
|
||||
Variant::Key => "key",
|
||||
Variant::AnswerSheet => "answer-sheet",
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant for a token.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - the token, e.g. `"answer-sheet"`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||
pub fn parse(s: &str) -> Result<Variant> {
|
||||
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
|
||||
Variant::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| v.as_str() == normalized)
|
||||
.ok_or_else(|| {
|
||||
Error::usage(format!(
|
||||
"unknown template variant `{s}`; expected one of {}",
|
||||
Variant::ALL
|
||||
.iter()
|
||||
.map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The file name this variant's template is looked up under.
|
||||
pub fn template_file(self) -> String {
|
||||
format!("{}.typ", self.as_str())
|
||||
}
|
||||
|
||||
/// The suffix appended to an exported file's stem.
|
||||
///
|
||||
/// The paper gets no suffix because it is the thing you print most often and
|
||||
/// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Exam => "",
|
||||
Variant::Key => "-key",
|
||||
Variant::AnswerSheet => "-answer-sheet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the answer side of an item reaches the payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Reveal {
|
||||
/// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a
|
||||
/// student form must use.
|
||||
Nothing,
|
||||
/// Which options are correct, what credit each earns, and any recorded
|
||||
/// partial-credit overrides. Enough to grade with.
|
||||
Key,
|
||||
/// Everything, including instructor rationale, targeted misconceptions, and
|
||||
/// the record's private notes. For a review copy that never leaves your desk.
|
||||
Everything,
|
||||
}
|
||||
|
||||
impl Reveal {
|
||||
/// Whether keyed letters, `correct`, and `credit` are emitted.
|
||||
pub fn shows_key(self) -> bool {
|
||||
self != Reveal::Nothing
|
||||
}
|
||||
|
||||
/// Whether rationale, misconceptions, and private notes are emitted.
|
||||
pub fn shows_rationale(self) -> bool {
|
||||
self == Reveal::Everything
|
||||
}
|
||||
}
|
||||
|
||||
/// How option labels are generated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LetterStyle {
|
||||
/// `A`, `B`, `C`.
|
||||
Upper,
|
||||
/// `a`, `b`, `c`.
|
||||
Lower,
|
||||
/// `1`, `2`, `3`.
|
||||
Numeric,
|
||||
/// `i`, `ii`, `iii`.
|
||||
Roman,
|
||||
/// No label; the template supplies its own, usually via `numbering`.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl LetterStyle {
|
||||
/// The label for a zero-based printed position.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `position` - the printed position, counting from zero.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The label, or an empty string for [`LetterStyle::Nothing`].
|
||||
pub fn label(self, position: usize) -> String {
|
||||
match self {
|
||||
LetterStyle::Upper => alphabetic(position, true),
|
||||
LetterStyle::Lower => alphabetic(position, false),
|
||||
LetterStyle::Numeric => (position + 1).to_string(),
|
||||
LetterStyle::Roman => roman(position + 1),
|
||||
LetterStyle::Nothing => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spreadsheet-style label: `A`..`Z`, then `AA`.
|
||||
///
|
||||
/// Eight options is the schema's ceiling, so the second character is unreachable
|
||||
/// in practice. It is here so that raising that ceiling does not silently produce
|
||||
/// `[` as an option label, which is what `b'A' + 26` gives you.
|
||||
fn alphabetic(position: usize, upper: bool) -> String {
|
||||
let base = if upper { b'A' } else { b'a' };
|
||||
let mut n = position;
|
||||
let mut letters = Vec::new();
|
||||
loop {
|
||||
letters.push((base + (n % 26) as u8) as char);
|
||||
if n < 26 {
|
||||
break;
|
||||
}
|
||||
n = n / 26 - 1;
|
||||
}
|
||||
letters.iter().rev().collect()
|
||||
}
|
||||
|
||||
/// A lowercase Roman numeral for a one-based position.
|
||||
fn roman(mut n: usize) -> String {
|
||||
const TABLE: [(usize, &str); 13] = [
|
||||
(1000, "m"),
|
||||
(900, "cm"),
|
||||
(500, "d"),
|
||||
(400, "cd"),
|
||||
(100, "c"),
|
||||
(90, "xc"),
|
||||
(50, "l"),
|
||||
(40, "xl"),
|
||||
(10, "x"),
|
||||
(9, "ix"),
|
||||
(5, "v"),
|
||||
(4, "iv"),
|
||||
(1, "i"),
|
||||
];
|
||||
let mut out = String::new();
|
||||
for (value, numeral) in TABLE {
|
||||
while n >= value {
|
||||
out.push_str(numeral);
|
||||
n -= value;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How markup-bearing fields are emitted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContentMode {
|
||||
/// As content blocks, `[...]`, which Typst parses at compile time. Errors
|
||||
/// point at the generated file, and no `eval` is needed.
|
||||
Content,
|
||||
/// As quoted strings, which a template evaluates with
|
||||
/// `eval(q.stem, mode: "markup")`. Required if the same payload is also
|
||||
/// consumed as JSON, since JSON has no content type.
|
||||
Str,
|
||||
}
|
||||
|
||||
impl ContentMode {
|
||||
/// Whether markup becomes a content block rather than a string.
|
||||
pub fn is_content(self) -> bool {
|
||||
self == ContentMode::Content
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a shared stimulus travels with each item that uses it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum StimulusMode {
|
||||
/// Repeat the body with every item that references it. Wasteful on paper, but
|
||||
/// a student should never have to turn a page to find the passage a question
|
||||
/// is about.
|
||||
Inline,
|
||||
/// Emit only the id on the item, and the bodies once in the metadata under
|
||||
/// `stimuli`. For a template that prints a testlet header above its group.
|
||||
Shared,
|
||||
/// Leave stimuli out.
|
||||
Omit,
|
||||
}
|
||||
|
||||
/// Which optional per-question fields are emitted.
|
||||
///
|
||||
/// Everything here defaults on except the two heavy ones. Turning a field off is
|
||||
/// about payload noise, not secrecy — [`Reveal`] is what governs secrecy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Fields {
|
||||
/// The item's global id, `bank::item`. Useful printed in small grey type on a
|
||||
/// review copy; never wanted on a student form.
|
||||
#[serde(default = "yes")]
|
||||
pub uid: bool,
|
||||
/// The item's short title.
|
||||
#[serde(default = "yes")]
|
||||
pub title: bool,
|
||||
/// Point value.
|
||||
#[serde(default = "yes")]
|
||||
pub points: bool,
|
||||
/// Cognitive level, as both a number and a name.
|
||||
#[serde(default = "yes")]
|
||||
pub level: bool,
|
||||
/// Learning objective ids.
|
||||
#[serde(default = "yes")]
|
||||
pub objectives: bool,
|
||||
/// Topic tags.
|
||||
#[serde(default = "yes")]
|
||||
pub topics: bool,
|
||||
/// Figures and data files attached to the item.
|
||||
#[serde(default = "yes")]
|
||||
pub assets: bool,
|
||||
/// The letter the option carries in the bank, before shuffling. On a key this
|
||||
/// is what lets you find the option in the YAML.
|
||||
#[serde(default = "yes")]
|
||||
pub source_letters: bool,
|
||||
/// The authored predictions in the item's `design` block.
|
||||
#[serde(default = "no")]
|
||||
pub design: bool,
|
||||
/// Pooled statistics from previous administrations.
|
||||
#[serde(default = "no")]
|
||||
pub calibration: bool,
|
||||
}
|
||||
|
||||
impl Default for Fields {
|
||||
fn default() -> Fields {
|
||||
Fields {
|
||||
uid: true,
|
||||
title: true,
|
||||
points: true,
|
||||
level: true,
|
||||
objectives: true,
|
||||
topics: true,
|
||||
assets: true,
|
||||
source_letters: true,
|
||||
design: false,
|
||||
calibration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved configuration for rendering one variant.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderConfig {
|
||||
/// A template path that overrides the usual lookup.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<PathBuf>,
|
||||
|
||||
/// The Typst function the `questions` slot calls, once per item, with a
|
||||
/// single dictionary argument. The template defines it.
|
||||
#[serde(default = "default_question_fn")]
|
||||
pub question_fn: String,
|
||||
|
||||
/// The binding the `meta` slot declares.
|
||||
#[serde(default = "default_meta_binding")]
|
||||
pub meta_binding: String,
|
||||
|
||||
/// The binding the `data` slot declares.
|
||||
#[serde(default = "default_data_binding")]
|
||||
pub data_binding: String,
|
||||
|
||||
/// How much of the answer side to include.
|
||||
#[serde(default = "default_reveal")]
|
||||
pub reveal: Reveal,
|
||||
|
||||
/// How option labels are generated.
|
||||
#[serde(default = "default_letters")]
|
||||
pub letters: LetterStyle,
|
||||
|
||||
/// How markup is emitted.
|
||||
#[serde(default = "default_content")]
|
||||
pub content: ContentMode,
|
||||
|
||||
/// How shared stimuli are handled.
|
||||
#[serde(default = "default_stimulus")]
|
||||
pub stimulus: StimulusMode,
|
||||
|
||||
/// Which optional fields to include.
|
||||
#[serde(default)]
|
||||
pub fields: Fields,
|
||||
|
||||
/// Whether questions carry the number recorded in the assessment record
|
||||
/// rather than their printed position.
|
||||
///
|
||||
/// Keep this on. The recorded number is the join key to every grading export
|
||||
/// and response row; renumbering after a drop breaks that join silently, and
|
||||
/// the symptom is item statistics attributed to the wrong question.
|
||||
#[serde(default = "yes")]
|
||||
pub number_from_record: bool,
|
||||
|
||||
/// Anything else you want the template to see, carried through untouched.
|
||||
///
|
||||
/// This is the escape hatch that keeps the crate out of your layout
|
||||
/// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark
|
||||
/// string: put it here and read it from `extra` in the template.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
/// The built-in configuration for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A configuration matching what the bundled template for that variant
|
||||
/// expects.
|
||||
pub fn for_variant(variant: Variant) -> RenderConfig {
|
||||
let mut config = RenderConfig {
|
||||
template: None,
|
||||
question_fn: default_question_fn(),
|
||||
meta_binding: default_meta_binding(),
|
||||
data_binding: default_data_binding(),
|
||||
reveal: Reveal::Nothing,
|
||||
letters: LetterStyle::Upper,
|
||||
content: ContentMode::Content,
|
||||
stimulus: StimulusMode::Inline,
|
||||
fields: Fields::default(),
|
||||
number_from_record: true,
|
||||
extra: BTreeMap::new(),
|
||||
};
|
||||
match variant {
|
||||
Variant::Exam => {
|
||||
// The paper must not carry the key in any form.
|
||||
config.reveal = Reveal::Nothing;
|
||||
config.fields.uid = false;
|
||||
config.fields.source_letters = false;
|
||||
config.fields.objectives = false;
|
||||
}
|
||||
Variant::Key => {
|
||||
config.reveal = Reveal::Everything;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
}
|
||||
Variant::AnswerSheet => {
|
||||
config.reveal = Reveal::Nothing;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
config.fields = Fields {
|
||||
uid: false,
|
||||
title: false,
|
||||
points: true,
|
||||
level: false,
|
||||
objectives: false,
|
||||
topics: false,
|
||||
assets: false,
|
||||
source_letters: false,
|
||||
design: false,
|
||||
calibration: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Renders the configuration as YAML.
|
||||
///
|
||||
/// For `coursebank template config --resolved`, which is the answer to "which
|
||||
/// layer won?" — a question that is otherwise answered by reading three files
|
||||
/// and guessing.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// YAML text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`](crate::error::Error::Other) if serialization
|
||||
/// fails.
|
||||
pub fn to_yaml(&self) -> Result<String> {
|
||||
serde_yaml_ng::to_string(self).map_err(Error::other)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a course's Typst configuration lives in, under `templates/`.
|
||||
pub const CONFIG_FILE: &str = "typst.yaml";
|
||||
|
||||
/// A course's Typst export configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFile {
|
||||
/// Schema version this file targets.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema_version: Option<String>,
|
||||
/// Overrides applied to every variant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub defaults: Option<Overrides>,
|
||||
/// Overrides applied to one variant, on top of `defaults`.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub variants: BTreeMap<Variant, Overrides>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
/// Resolves the configuration for one variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The built-in defaults with the file's `defaults` and then its
|
||||
/// variant-specific block applied.
|
||||
pub fn resolve(&self, variant: Variant) -> RenderConfig {
|
||||
let mut config = RenderConfig::for_variant(variant);
|
||||
if let Some(defaults) = &self.defaults {
|
||||
defaults.apply(&mut config);
|
||||
}
|
||||
if let Some(specific) = self.variants.get(&variant) {
|
||||
specific.apply(&mut config);
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial [`RenderConfig`]: every field optional, so a config file can say one
|
||||
/// thing without restating the rest.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
/// See [`RenderConfig::template`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<PathBuf>,
|
||||
/// See [`RenderConfig::question_fn`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub question_fn: Option<String>,
|
||||
/// See [`RenderConfig::meta_binding`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta_binding: Option<String>,
|
||||
/// See [`RenderConfig::data_binding`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_binding: Option<String>,
|
||||
/// See [`RenderConfig::reveal`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reveal: Option<Reveal>,
|
||||
/// See [`RenderConfig::letters`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub letters: Option<LetterStyle>,
|
||||
/// See [`RenderConfig::content`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<ContentMode>,
|
||||
/// See [`RenderConfig::stimulus`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stimulus: Option<StimulusMode>,
|
||||
/// See [`RenderConfig::fields`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fields: Option<Fields>,
|
||||
/// See [`RenderConfig::number_from_record`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub number_from_record: Option<bool>,
|
||||
/// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so
|
||||
/// a variant can add one flag without repeating the shared block.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||
}
|
||||
|
||||
impl Overrides {
|
||||
/// Applies these overrides in place.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - the configuration to modify.
|
||||
pub fn apply(&self, config: &mut RenderConfig) {
|
||||
if let Some(v) = &self.template {
|
||||
config.template = Some(v.clone());
|
||||
}
|
||||
if let Some(v) = &self.question_fn {
|
||||
config.question_fn = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.meta_binding {
|
||||
config.meta_binding = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.data_binding {
|
||||
config.data_binding = v.clone();
|
||||
}
|
||||
if let Some(v) = self.reveal {
|
||||
config.reveal = v;
|
||||
}
|
||||
if let Some(v) = self.letters {
|
||||
config.letters = v;
|
||||
}
|
||||
if let Some(v) = self.content {
|
||||
config.content = v;
|
||||
}
|
||||
if let Some(v) = self.stimulus {
|
||||
config.stimulus = v;
|
||||
}
|
||||
if let Some(v) = &self.fields {
|
||||
config.fields = v.clone();
|
||||
}
|
||||
if let Some(v) = self.number_from_record {
|
||||
config.number_from_record = v;
|
||||
}
|
||||
for (key, value) in &self.extra {
|
||||
config.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The commented starter config written by `coursebank template config`.
|
||||
///
|
||||
/// Written as text rather than serialized from [`ConfigFile`] because the comments
|
||||
/// are the useful part, and a serializer drops them.
|
||||
pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration.
|
||||
#
|
||||
# Layers, each overriding the last:
|
||||
# 1. the built-in defaults for the variant
|
||||
# 2. `defaults:` below
|
||||
# 3. `variants:` below
|
||||
#
|
||||
# `coursebank template config --resolved` prints what a variant actually ends up
|
||||
# with, which is the quickest way to check whether a key landed where you meant.
|
||||
schema_version: "1.0"
|
||||
|
||||
defaults:
|
||||
# The Typst function the `questions` slot calls once per item, with one
|
||||
# dictionary argument. Your template defines it.
|
||||
question_fn: render-question
|
||||
|
||||
# `content` emits stems as `[...]`, which Typst parses directly.
|
||||
# `str` emits them as strings for a template that calls `eval`, and is what you
|
||||
# want if the same payload is also read as JSON.
|
||||
content: content
|
||||
|
||||
# upper | lower | numeric | roman | nothing
|
||||
letters: upper
|
||||
|
||||
# Anything here is passed through untouched and shows up as `extra` in the
|
||||
# payload. This is where layout decisions belong.
|
||||
#
|
||||
# Note the spelling shift: keys above are coursebank's and are snake_case like
|
||||
# every other coursebank YAML file, but keys under `extra` are yours and reach
|
||||
# Typst verbatim, so they use Typst's hyphens.
|
||||
extra:
|
||||
accent: '#017ab9'
|
||||
font: 'Libertinus Serif'
|
||||
|
||||
variants:
|
||||
exam:
|
||||
# Leave this alone unless you are certain. `nothing` is what keeps the answer
|
||||
# out of the student's copy; a template cannot leak a field it was never given.
|
||||
reveal: nothing
|
||||
extra:
|
||||
show-solutions: false
|
||||
|
||||
key:
|
||||
# nothing | key | everything
|
||||
reveal: everything
|
||||
extra:
|
||||
show-solutions: true
|
||||
|
||||
answer-sheet:
|
||||
reveal: nothing
|
||||
"#;
|
||||
|
||||
/// Serde default: `true`.
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Serde default: `false`.
|
||||
fn no() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::question_fn`].
|
||||
fn default_question_fn() -> String {
|
||||
"render-question".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::meta_binding`].
|
||||
fn default_meta_binding() -> String {
|
||||
"cb-meta".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::data_binding`].
|
||||
fn default_data_binding() -> String {
|
||||
"cb-data".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::reveal`].
|
||||
fn default_reveal() -> Reveal {
|
||||
Reveal::Nothing
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::letters`].
|
||||
fn default_letters() -> LetterStyle {
|
||||
LetterStyle::Upper
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::content`].
|
||||
fn default_content() -> ContentMode {
|
||||
ContentMode::Content
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::stimulus`].
|
||||
fn default_stimulus() -> StimulusMode {
|
||||
StimulusMode::Inline
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variant_tokens_round_trip() {
|
||||
for variant in Variant::ALL {
|
||||
assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant);
|
||||
}
|
||||
// Underscores are tolerated because people type them.
|
||||
assert_eq!(
|
||||
Variant::parse("answer_sheet").unwrap(),
|
||||
Variant::AnswerSheet
|
||||
);
|
||||
assert!(Variant::parse("bubbles").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exam_variant_never_reveals_the_key_by_default() {
|
||||
// This is the one default in this module that is a correctness property
|
||||
// rather than a preference.
|
||||
let config = RenderConfig::for_variant(Variant::Exam);
|
||||
assert_eq!(config.reveal, Reveal::Nothing);
|
||||
assert!(!config.reveal.shows_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_are_generated_per_style() {
|
||||
assert_eq!(LetterStyle::Upper.label(0), "A");
|
||||
assert_eq!(LetterStyle::Upper.label(3), "D");
|
||||
assert_eq!(LetterStyle::Lower.label(1), "b");
|
||||
assert_eq!(LetterStyle::Numeric.label(4), "5");
|
||||
assert_eq!(LetterStyle::Roman.label(3), "iv");
|
||||
assert_eq!(LetterStyle::Nothing.label(2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_past_z_do_not_run_off_the_alphabet() {
|
||||
// `b'A' + 26` is `[`. Anything that produced that would be a silent
|
||||
// corruption of an option label rather than an error.
|
||||
assert_eq!(LetterStyle::Upper.label(26), "AA");
|
||||
assert_eq!(LetterStyle::Upper.label(25), "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_layers_override_in_order() {
|
||||
let file: ConfigFile = serde_yaml_ng::from_str(
|
||||
"defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \
|
||||
numeric\n extra:\n b: 2\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let exam = file.resolve(Variant::Exam);
|
||||
assert_eq!(exam.letters, LetterStyle::Lower);
|
||||
|
||||
let key = file.resolve(Variant::Key);
|
||||
assert_eq!(key.letters, LetterStyle::Numeric);
|
||||
// `extra` merges rather than replacing, so `a` survives.
|
||||
assert!(key.extra.contains_key("a"));
|
||||
assert!(key.extra.contains_key("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_config_file_changes_nothing() {
|
||||
let file = ConfigFile::default();
|
||||
for variant in Variant::ALL {
|
||||
assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_starter_config_parses_and_keeps_the_exam_closed() {
|
||||
let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap();
|
||||
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
|
||||
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user