747 lines
24 KiB
Rust
747 lines
24 KiB
Rust
//! 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`] 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);
|
|
}
|
|
}
|