Compare commits
5
Commits
main
...
prelim-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd33f1768
|
||
|
|
1cc8137be9
|
||
|
|
327ac371e4
|
||
|
|
f475c630e0
|
||
|
|
468af3a815
|
@@ -1,4 +1,5 @@
|
||||
preview
|
||||
scratch
|
||||
|
||||
/dist/
|
||||
/THIRD-PARTY-LICENSES.txt
|
||||
|
||||
+8
-7
@@ -17,10 +17,6 @@ path = "src/main.rs"
|
||||
name = "coursebank"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["parquet"]
|
||||
parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
csv = "1"
|
||||
@@ -29,9 +25,14 @@ serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
thiserror = "2"
|
||||
|
||||
arrow-array = { version = "55", optional = true }
|
||||
arrow-schema = { version = "55", optional = true }
|
||||
parquet = { version = "55", optional = true }
|
||||
arrow-array = { version = "55"}
|
||||
arrow-schema = { version = "55"}
|
||||
parquet = { version = "55"}
|
||||
aes-gcm = { version = "0.10"}
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]}
|
||||
sha2 = { version = "0.10"}
|
||||
base64 = { version = "0.22"}
|
||||
getrandom = { version = "0.2"}
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# An assignment on the web
|
||||
|
||||
This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
|
||||
It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
|
||||
|
||||
The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
|
||||
The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
|
||||
|
||||
## Assemble the homework
|
||||
|
||||
Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
|
||||
|
||||
```console
|
||||
$ coursebank assemble a1.1 \
|
||||
--title "Homework 1" \
|
||||
--kind homework --platform other \
|
||||
--levels 2=1,3=1 --lectures L1.1 --seed 20260210
|
||||
```
|
||||
|
||||
What lands in `assessments/a1.1.yaml` is a record of the draw.
|
||||
The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
|
||||
|
||||
A single unshuffled form is fine for homework.
|
||||
To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
|
||||
|
||||
## Export for the web
|
||||
|
||||
```console
|
||||
$ coursebank export site a1.1 --out build/a1.1 --assets build/static
|
||||
password for a1.1: k7m4-9p2q-r8tx-3wn6
|
||||
wrote build/a1.1/_questions.qmd
|
||||
wrote build/a1.1/a1.1-solutions.json
|
||||
wrote build/static/questions.css
|
||||
wrote build/static/solutions.js
|
||||
|
||||
Include in the page with: {{< include _questions.qmd >}}
|
||||
```
|
||||
|
||||
That is four files and a password.
|
||||
|
||||
`_questions.qmd` is the partial you include from the page.
|
||||
`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
|
||||
The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
|
||||
|
||||
The password is printed once and written nowhere.
|
||||
Record it now.
|
||||
It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
|
||||
|
||||
## What the partial holds
|
||||
|
||||
The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
|
||||
|
||||
```text
|
||||
::: {.solutions-gate data-bundle="a1.1-solutions.json"}
|
||||
:::
|
||||
|
||||
::: {.q #q-enthalpy-qp-001}
|
||||
:::: {.q-head}
|
||||
[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
|
||||
::::
|
||||
|
||||
:::: {.q-stem}
|
||||
A reaction is run in an open flask, so the system stays at the constant
|
||||
pressure of the room. The heat the reaction exchanges with its surroundings
|
||||
equals the change in which quantity?
|
||||
::::
|
||||
|
||||
:::: {.q-choices}
|
||||
1. Enthalpy, $\Delta H$
|
||||
2. Internal energy, $\Delta U$
|
||||
3. Gibbs free energy, $\Delta G$
|
||||
4. Entropy, $\Delta S$
|
||||
::::
|
||||
|
||||
:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
|
||||
::::
|
||||
:::
|
||||
```
|
||||
|
||||
The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
|
||||
There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
|
||||
The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
|
||||
|
||||
Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
|
||||
|
||||
## What the bundle holds
|
||||
|
||||
Every solution is rendered to HTML, then encrypted:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"page": "a1.1",
|
||||
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
|
||||
"cipher": "AES-GCM",
|
||||
"items": {
|
||||
"q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
|
||||
"q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
|
||||
Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
|
||||
The plaintext HTML never leaves your machine.
|
||||
Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
|
||||
|
||||
## Wire it into the site
|
||||
|
||||
Install the two assets once in `_quarto.yml`:
|
||||
|
||||
```yaml
|
||||
format:
|
||||
html:
|
||||
css:
|
||||
- static/questions.css
|
||||
include-after-body:
|
||||
- static/solutions.js
|
||||
```
|
||||
|
||||
Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Homework 1"
|
||||
---
|
||||
|
||||
{{< include _questions.qmd >}}
|
||||
```
|
||||
|
||||
The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
|
||||
If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
|
||||
|
||||
Render with `quarto render`.
|
||||
A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
|
||||
|
||||
## Hand out the password, and rotate it
|
||||
|
||||
Give the password through a channel students already have, such as the course LMS, rather than the site itself.
|
||||
|
||||
Be clear-eyed about what the lock does.
|
||||
It keeps solutions off a public page until someone has the password.
|
||||
It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
|
||||
Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
|
||||
|
||||
Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
|
||||
|
||||
```console
|
||||
$ coursebank export site a1.1 --out build/a1.1
|
||||
password for a1.1: 2h9k-w4rq-8mnp-x6tv
|
||||
...
|
||||
```
|
||||
|
||||
To set a password yourself instead of generating one, pass `--password`.
|
||||
Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
|
||||
|
||||
## Doing this from Rust
|
||||
|
||||
The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
|
||||
The example below needs `--features site` to build, so it is not run as a doctest:
|
||||
|
||||
```rust,ignore
|
||||
use std::path::Path;
|
||||
|
||||
use coursebank::assessment::{AssessmentFile, Form};
|
||||
use coursebank::site::{self, Options};
|
||||
use coursebank::Catalog;
|
||||
|
||||
fn main() -> coursebank::Result<()> {
|
||||
let catalog = Catalog::load(Path::new("."))?;
|
||||
let path = catalog.layout.assessments().join("a1.1.yaml");
|
||||
let record = AssessmentFile::load(&path)?;
|
||||
|
||||
// An unshuffled form A; declare a form with a seed to shuffle.
|
||||
let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
|
||||
|
||||
// `password: None` generates a fresh one; pass `Some(_)` to set your own.
|
||||
let rendered = site::render(&catalog, &record, Options { form, password: None })?;
|
||||
|
||||
std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
|
||||
std::fs::write(
|
||||
format!("build/a1.1/{}-solutions.json", rendered.page),
|
||||
&rendered.solutions_json,
|
||||
)?;
|
||||
// The password is the one thing not on disk; print it now.
|
||||
println!("password for {}: {}", rendered.page, rendered.password);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
|
||||
@@ -26,8 +26,75 @@ That gives you:
|
||||
`build/` and `reports/` are in the generated `.gitignore`.
|
||||
The other four are the repository's content and belong in review.
|
||||
|
||||
If the directory already has a `.gitignore`, `init` keeps it and inserts only the patterns it was missing at the top, so running this inside an existing repository costs you nothing.
|
||||
|
||||
Add `--with-examples` if you want a filled-in bank to read rather than an empty directory to stare at.
|
||||
|
||||
## Declare your texts once
|
||||
|
||||
Every work the course cites goes in `references`, keyed by the citation key you would use in a `.bib` file.
|
||||
|
||||
```yaml
|
||||
references:
|
||||
kuriyan2013molecules:
|
||||
label: KKW
|
||||
kind: book
|
||||
role: required
|
||||
title: 'The molecules of life: Physical and chemical principles'
|
||||
authors: ['Kuriyan, John', 'Konforti, Boyana', 'Wemmer, David']
|
||||
year: 2013
|
||||
publisher: W. W. Norton & Company
|
||||
base_url: https://library.scient.ing/kuriyan2013molecules/
|
||||
note: On reserve at the Bevier Engineering Library.
|
||||
```
|
||||
|
||||
`label` is the short form a reading list shows, and it has to name one work, because reports print it instead of the key.
|
||||
`base_url` is what a reading's `path` is joined to, so the key appears once in the file rather than once per reading.
|
||||
|
||||
## Point readings at objectives
|
||||
|
||||
A reading names a location inside a reference and lists the objectives it serves.
|
||||
|
||||
```yaml
|
||||
lectures:
|
||||
L1.1:
|
||||
title: Enthalpy
|
||||
readings:
|
||||
- ref: kuriyan2013molecules
|
||||
locator: '§1.3'
|
||||
path: '1/A/#3'
|
||||
objectives: [lo-water-attenuation, lo-coulomb-estimate]
|
||||
summary: >-
|
||||
Ionic interactions: favorable in vacuum, attenuated ~80-fold by water.
|
||||
focus: >-
|
||||
The two magnitudes and the factor of 80.
|
||||
skip: >-
|
||||
Skip the unit-conversion derivation.
|
||||
```
|
||||
|
||||
The three prose fields answer three different questions, and each has a different reader.
|
||||
`summary` says what the section contains, `focus` says what to take from it, and `skip` says what to ignore.
|
||||
A student report quotes `focus` at somebody who missed the objective; a lecture page prints all three.
|
||||
|
||||
The mapping lives on the reading rather than on the objective because objectives outlive editions.
|
||||
When a textbook renumbers its sections, one block of `readings` changes and `learning_objectives` does not.
|
||||
Going the other way is a scan: `coursebank lecture coverage` lists the readings behind each objective and flags the ones with none.
|
||||
|
||||
Set `order` on each objective if you want a lecture page to number them in teaching order.
|
||||
The registry is a map, so declaration order is lost on load, and sorting by id would put `lo-enthalpy` ahead of `lo-first-law`.
|
||||
|
||||
A reading written as a plain string, which is what this field held before, still loads and is written back out unchanged.
|
||||
|
||||
## Generate the reading list
|
||||
|
||||
```console
|
||||
$ coursebank lecture readings L1.1 --out lectures/l1_1-readings.qmd
|
||||
wrote lectures/l1_1-readings.qmd
|
||||
```
|
||||
|
||||
Objective numbers in the generated page (`_(LO 4, 7)_`) are positional, so they are computed at render time rather than written down.
|
||||
Insert an objective and everything after it renumbers on the next build.
|
||||
|
||||
## Point your editor at the schemas
|
||||
|
||||
The schemas are the difference between authoring items and looking up field names.
|
||||
|
||||
+4
-4
@@ -444,7 +444,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
for iteration in 0..opts.max_iterations {
|
||||
iterations = iteration + 1;
|
||||
|
||||
// ---- E step: expected counts at each quadrature point ----
|
||||
// --- E step: expected counts at each quadrature point ----
|
||||
// Counts are accumulated per item rather than globally, so an item
|
||||
// administered to only some examinees is not charged for the others.
|
||||
let mut n_kj = vec![vec![0.0f64; j_count]; n_quad];
|
||||
@@ -473,7 +473,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- M step: one two-parameter Newton solve per item ----
|
||||
// --- M step: one two-parameter Newton solve per item ----
|
||||
let mut delta = 0.0f64;
|
||||
for j in 0..j_count {
|
||||
let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect();
|
||||
@@ -529,7 +529,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
));
|
||||
}
|
||||
|
||||
// ---- Standard errors and per-item notes ----
|
||||
// --- Standard errors and per-item notes ----
|
||||
let (grid_final, weight_final) = (grid.clone(), base_weight.clone());
|
||||
let mut p_grid = vec![vec![0.0f64; j_count]; n_quad];
|
||||
for k in 0..n_quad {
|
||||
@@ -601,7 +601,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Abilities, expected a posteriori ----
|
||||
// --- Abilities, expected a posteriori ----
|
||||
let mut abilities = Vec::with_capacity(n);
|
||||
let mut log_likelihood = 0.0f64;
|
||||
for i in 0..n {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/* ============================================================================
|
||||
questions.css — worksheet items + gated solutions
|
||||
Sits beside editor-notes.css: same left-border-accent language, same
|
||||
small-caps auto-headers, same .quarto-dark dark-mode hook.
|
||||
|
||||
Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so
|
||||
inline math is $ … $ and paragraphs/lists render normally. Choice letters
|
||||
(A, B, C, D) are drawn by a CSS counter, so a choice is just a list item.
|
||||
|
||||
Semantic color coding (information, not decoration):
|
||||
indigo = a solution / answer region
|
||||
green = the correct choice / accepted answer
|
||||
red = a named misconception
|
||||
==========================================================================*/
|
||||
|
||||
:root {
|
||||
--q-ink: #1f2328;
|
||||
--q-muted: #5a5a5a;
|
||||
--q-line: #e4e4e4;
|
||||
--q-card-bg: #ffffff;
|
||||
|
||||
--sol-accent: #4b4f9a; /* indigo: "here be answers" */
|
||||
--sol-bg: #f5f5fb;
|
||||
--sol-rule: #dedcef;
|
||||
|
||||
--ok-ink: #2f6249; /* correct / accepted (matches editorial green) */
|
||||
--ok-bg: #e7f2ec;
|
||||
--mis-ink: #a13d2e; /* misconception (matches editorial red) */
|
||||
}
|
||||
|
||||
/* ---- Question card ---- */
|
||||
.q {
|
||||
border: 1px solid var(--q-line);
|
||||
border-radius: 6px;
|
||||
background: var(--q-card-bg);
|
||||
padding: 1.1rem 1.3rem 1.25rem;
|
||||
margin: 1.6rem 0;
|
||||
}
|
||||
|
||||
/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */
|
||||
.q-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.7rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--q-line);
|
||||
}
|
||||
.q-head > p { display: contents; margin: 0; }
|
||||
.q-num { font-weight: 700; letter-spacing: 0.01em; }
|
||||
.q-kind {
|
||||
font-variant: small-caps; letter-spacing: 0.06em;
|
||||
font-size: 0.78rem; color: var(--q-muted);
|
||||
}
|
||||
.q-points {
|
||||
margin-left: auto; font-size: 0.8rem; color: var(--q-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.q-stem { margin: 0 0 0.9rem; }
|
||||
.q-stem > p:first-child { margin-top: 0; }
|
||||
.q-stem > p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */
|
||||
.q-choices > ol {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: grid; gap: 0.5rem;
|
||||
counter-reset: choice;
|
||||
}
|
||||
.q-choices > ol > li {
|
||||
position: relative;
|
||||
padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */
|
||||
border: 1px solid var(--q-line);
|
||||
border-radius: 5px;
|
||||
counter-increment: choice;
|
||||
}
|
||||
.q-choices > ol > li::before {
|
||||
content: counter(choice, upper-alpha); /* A, B, C, D … */
|
||||
position: absolute;
|
||||
left: 0.55rem; top: 0.5rem;
|
||||
display: grid; place-items: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border: 1px solid var(--sol-accent);
|
||||
border-radius: 50%;
|
||||
font-weight: 700; font-size: 0.9rem;
|
||||
color: var(--sol-accent);
|
||||
}
|
||||
|
||||
/* ---- Free-response writing space (prints with room to write) ----- */
|
||||
.q-response {
|
||||
min-height: 6.5rem;
|
||||
border: 1px dashed var(--q-line);
|
||||
border-radius: 5px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom, transparent, transparent 1.55rem,
|
||||
var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px));
|
||||
background-position: 0 0.9rem;
|
||||
}
|
||||
.q-response::before {
|
||||
content: "Your answer";
|
||||
display: block;
|
||||
font-variant: small-caps; letter-spacing: 0.06em;
|
||||
font-size: 0.72rem; color: var(--q-muted);
|
||||
padding: 0.3rem 0.6rem 0;
|
||||
}
|
||||
|
||||
/* ---- Solution slot (filled by solutions.js on unlock) ---- */
|
||||
.qsol {
|
||||
border-left: 3px solid var(--sol-accent);
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--sol-bg);
|
||||
padding: 0.9rem 1.15rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.95rem; line-height: 1.55;
|
||||
}
|
||||
.qsol::before {
|
||||
content: "Solution";
|
||||
display: block;
|
||||
font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600;
|
||||
color: var(--sol-accent);
|
||||
padding-bottom: 0.35rem; margin-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--sol-rule);
|
||||
}
|
||||
.qsol > p:first-of-type { margin-top: 0; }
|
||||
.qsol > *:last-child { margin-bottom: 0; }
|
||||
|
||||
@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
|
||||
.qsol.is-unlocked { animation: sol-in 180ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } }
|
||||
|
||||
/* pieces inside a solution */
|
||||
.sol-answer { font-size: 1.02rem; }
|
||||
.sol-model {
|
||||
border-left: 2px solid var(--ok-ink);
|
||||
background: var(--ok-bg);
|
||||
padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0;
|
||||
}
|
||||
.sol-model > p:first-child { margin-top: 0; }
|
||||
.sol-model > p:last-child { margin-bottom: 0; }
|
||||
.sol-explain { margin: 0.7rem 0; }
|
||||
.sol-feedback-title {
|
||||
font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600;
|
||||
color: var(--q-muted); margin: 0.9rem 0 0.4rem;
|
||||
}
|
||||
|
||||
/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */
|
||||
.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
|
||||
.sol-feedback > li {
|
||||
display: grid;
|
||||
grid-template-columns: 1.9rem 1fr; /* badge | body */
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
}
|
||||
.sol-feedback .opt {
|
||||
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 50%; font-weight: 700; font-size: 0.8rem;
|
||||
color: var(--mis-ink); border: 1px solid var(--mis-ink);
|
||||
}
|
||||
.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */
|
||||
display: grid; gap: 0.25rem;
|
||||
}
|
||||
.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; }
|
||||
.sol-feedback .opt-why { color: var(--q-ink); }
|
||||
|
||||
/* rubric table */
|
||||
.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; }
|
||||
.sol-rubric caption {
|
||||
text-align: left; font-variant: small-caps; letter-spacing: 0.05em;
|
||||
font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem;
|
||||
}
|
||||
.sol-rubric th, .sol-rubric td {
|
||||
border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.sol-rubric th:first-child, .sol-rubric td:first-child {
|
||||
width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink);
|
||||
}
|
||||
|
||||
.sol-accepted { margin: 0.7rem 0; }
|
||||
.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); }
|
||||
|
||||
/* badges */
|
||||
.badge {
|
||||
display: inline-block; font-variant: small-caps; letter-spacing: 0.05em;
|
||||
font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em;
|
||||
border-radius: 999px; margin-right: 0.35em;
|
||||
background: var(--sol-rule); color: var(--sol-accent);
|
||||
}
|
||||
.badge-correct { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
|
||||
/* ---- The password gate --- */
|
||||
.solutions-gate { margin: 1.4rem 0; }
|
||||
.gate-inner {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent);
|
||||
border-radius: 0 5px 5px 0; background: var(--sol-bg);
|
||||
}
|
||||
.gate-lock {
|
||||
width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent);
|
||||
border-radius: 2px; position: relative; flex: none;
|
||||
}
|
||||
.gate-lock::before {
|
||||
content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%);
|
||||
width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent);
|
||||
border-bottom: none; border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.gate-lock.is-open::before { left: 20%; }
|
||||
.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); }
|
||||
.gate-input {
|
||||
flex: 1 1 12rem; min-width: 9rem;
|
||||
padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule);
|
||||
border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink);
|
||||
}
|
||||
.gate-btn {
|
||||
padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px;
|
||||
background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.gate-btn:hover { filter: brightness(1.08); }
|
||||
.gate-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
.gate-btn-ghost { background: transparent; color: var(--sol-accent); }
|
||||
.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); }
|
||||
.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); }
|
||||
.solutions-gate.is-error .gate-status { color: var(--mis-ink); }
|
||||
.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; }
|
||||
|
||||
@media print {
|
||||
.solutions-gate { display: none; }
|
||||
.qsol[hidden] { display: none; }
|
||||
.q { break-inside: avoid; border-color: #bbb; }
|
||||
}
|
||||
|
||||
/* ============================ Dark mode ================================= */
|
||||
.quarto-dark {
|
||||
--q-ink: #dfe2e7;
|
||||
--q-muted: #a7adb6;
|
||||
--q-line: #333a44;
|
||||
--q-card-bg: #1b1f26;
|
||||
|
||||
--sol-accent: #9aa0e6;
|
||||
--sol-bg: #20222e;
|
||||
--sol-rule: #343755;
|
||||
|
||||
--ok-ink: #9dd3b4;
|
||||
--ok-bg: #1b241f;
|
||||
--mis-ink: #e0a498;
|
||||
}
|
||||
.quarto-dark .gate-btn { color: #14161c; }
|
||||
@@ -0,0 +1,160 @@
|
||||
/* ============================================================================
|
||||
* solutions.js — per-page, password-gated solutions for the course site.
|
||||
*
|
||||
* How a page opts in:
|
||||
* 1. Put one gate element somewhere on the page:
|
||||
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
|
||||
* `data-bundle` is resolved relative to the page URL, so each page points
|
||||
* at its own bundle and therefore has its own password. Nothing else is
|
||||
* shared between pages.
|
||||
* 2. For every gated item, leave an empty, hidden slot where the solution
|
||||
* should appear:
|
||||
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
|
||||
* The id in data-solution-for must match a key in the bundle's `items`.
|
||||
*
|
||||
* What happens on unlock:
|
||||
* The typed password is run through PBKDF2 (same params as the bundle's kdf
|
||||
* block) to derive an AES-256-GCM key. The first item is decrypted as a
|
||||
* probe: because GCM authenticates, a wrong password throws, and we report
|
||||
* "wrong password" without revealing anything. On success every slot is
|
||||
* filled, un-hidden, and MathJax re-typesets the injected math.
|
||||
*
|
||||
* The ciphertext ships in the page, so this stops a student
|
||||
* from reading answers in "View source" or the Network tab, and a wrong
|
||||
* password reveals nothing. Anyone who has the password can decrypt, and the
|
||||
* bundle can be brute-forced offline against a weak password. Use a real,
|
||||
* non-guessable per-page password, and rotate it if a key deadline has passed.
|
||||
* ==========================================================================*/
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const b64ToBytes = (s) =>
|
||||
Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
|
||||
async function deriveKey(password, kdf) {
|
||||
const base = await crypto.subtle.importKey(
|
||||
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: b64ToBytes(kdf.salt),
|
||||
iterations: kdf.iterations, hash: kdf.hash },
|
||||
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||
}
|
||||
|
||||
async function decryptItem(key, item) {
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct));
|
||||
return new TextDecoder().decode(pt);
|
||||
}
|
||||
|
||||
function typeset(nodes) {
|
||||
if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
|
||||
window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ });
|
||||
}
|
||||
}
|
||||
|
||||
function buildGate(gate) {
|
||||
gate.classList.add("is-locked");
|
||||
gate.innerHTML = `
|
||||
<div class="gate-inner">
|
||||
<span class="gate-lock" aria-hidden="true"></span>
|
||||
<label class="gate-label" for="gate-pw">Solutions are locked</label>
|
||||
<input class="gate-input" id="gate-pw" type="password"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="Enter the page password" />
|
||||
<button class="gate-btn" type="button">Unlock</button>
|
||||
<p class="gate-status" role="status" aria-live="polite"></p>
|
||||
</div>`;
|
||||
return {
|
||||
input: gate.querySelector(".gate-input"),
|
||||
button: gate.querySelector(".gate-btn"),
|
||||
status: gate.querySelector(".gate-status"),
|
||||
};
|
||||
}
|
||||
|
||||
async function unlock(gate, ui) {
|
||||
const url = gate.getAttribute("data-bundle");
|
||||
const pw = ui.input.value;
|
||||
if (!pw) { ui.input.focus(); return; }
|
||||
|
||||
gate.classList.remove("is-error");
|
||||
ui.button.disabled = true;
|
||||
ui.status.textContent = "Checking\u2026";
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`bundle ${res.status}`);
|
||||
bundle = await res.json();
|
||||
} catch (e) {
|
||||
ui.button.disabled = false;
|
||||
ui.status.textContent = "Could not load the solutions file for this page.";
|
||||
return;
|
||||
}
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = await deriveKey(pw, bundle.kdf);
|
||||
// Probe with the first item so a wrong password fails before we touch DOM.
|
||||
const firstId = Object.keys(bundle.items)[0];
|
||||
await decryptItem(key, bundle.items[firstId]);
|
||||
} catch (e) {
|
||||
gate.classList.add("is-error");
|
||||
ui.button.disabled = false;
|
||||
ui.status.textContent = "That password didn\u2019t work. Try again.";
|
||||
ui.input.select();
|
||||
return;
|
||||
}
|
||||
|
||||
const filled = [];
|
||||
for (const slot of document.querySelectorAll("[data-solution-for]")) {
|
||||
const id = slot.getAttribute("data-solution-for");
|
||||
const item = bundle.items[id];
|
||||
if (!item) continue;
|
||||
try {
|
||||
slot.innerHTML = await decryptItem(key, item);
|
||||
slot.hidden = false;
|
||||
slot.classList.add("is-unlocked");
|
||||
filled.push(slot);
|
||||
} catch (e) { /* skip an item that fails; others still unlock */ }
|
||||
}
|
||||
typeset(filled);
|
||||
|
||||
gate.classList.remove("is-locked");
|
||||
gate.classList.add("is-unlocked");
|
||||
gate.innerHTML = `
|
||||
<div class="gate-inner">
|
||||
<span class="gate-lock is-open" aria-hidden="true"></span>
|
||||
<span class="gate-label">Solutions unlocked</span>
|
||||
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
|
||||
</div>`;
|
||||
gate.querySelector(".gate-btn").addEventListener("click", () => {
|
||||
for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); }
|
||||
buildAndWire(gate); // relock the UI; content stays in memory only
|
||||
});
|
||||
}
|
||||
|
||||
function buildAndWire(gate) {
|
||||
const ui = buildGate(gate);
|
||||
const go = () => unlock(gate, ui);
|
||||
ui.button.addEventListener("click", go);
|
||||
ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||
}
|
||||
|
||||
function init() {
|
||||
const gate = document.querySelector(".solutions-gate[data-bundle]");
|
||||
if (!gate) return;
|
||||
if (!window.crypto || !crypto.subtle) {
|
||||
gate.textContent =
|
||||
"This browser can\u2019t decrypt solutions (no Web Crypto over http/file).";
|
||||
return;
|
||||
}
|
||||
buildAndWire(gate);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+184
-5
@@ -291,7 +291,12 @@ fn lecture_schema() -> Value {
|
||||
"date": date("Date delivered."),
|
||||
"unit": { "type": "string", "description": "Unit id." },
|
||||
"slides_url": { "type": "string" },
|
||||
"readings": string_array("Readings assigned with this lecture.")
|
||||
"readings": {
|
||||
"type": "array",
|
||||
"description": "Readings assigned with this lecture, in the order you assign \
|
||||
them. A plain string is the pre-schema form and still loads.",
|
||||
"items": reading_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -306,6 +311,13 @@ fn objective_schema() -> Value {
|
||||
"text": text("The objective as a student would read it. Start with a verb."),
|
||||
"unit": { "type": "string" },
|
||||
"lectures": string_array("Lecture ids that cover this."),
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position in teaching order, low first. A lecture page numbers \
|
||||
objectives by this; without it they sort by id, which puts \
|
||||
an objective before its own prerequisite."
|
||||
},
|
||||
"level_ceiling": level(),
|
||||
"prerequisites": string_array(
|
||||
"Objective ids that must come first. Cycles are rejected."
|
||||
@@ -320,6 +332,93 @@ fn objective_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one cited work.
|
||||
fn reference_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": text("Short form a reading list shows, such as KKW. One work per label."),
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": strings(&[
|
||||
"book", "chapter", "article", "preprint", "thesis",
|
||||
"website", "software", "dataset", "video", "other"
|
||||
]),
|
||||
"description": "Kind of work, following BibTeX entry types."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": strings(&["required", "supplemental"]),
|
||||
"description": "required for a course text; supplemental for background."
|
||||
},
|
||||
"title": text("Full title."),
|
||||
"authors": string_array("Authors as `Family, Given`, in printed order."),
|
||||
"year": { "type": "integer", "description": "Year of publication." },
|
||||
"edition": { "type": "string", "description": "Edition as printed: 7th." },
|
||||
"publisher": { "type": "string" },
|
||||
"container": { "type": "string", "description": "Journal, edited volume, or series." },
|
||||
"volume": { "type": "string" },
|
||||
"issue": { "type": "string" },
|
||||
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
|
||||
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
|
||||
"isbn": { "type": "string" },
|
||||
"url": { "type": "string", "description": "Canonical URL for the whole work." },
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Prefix a reading's `path` is joined to, so the citation key \
|
||||
appears once instead of once per reading."
|
||||
},
|
||||
"note": { "type": "string", "description": "Access notes: reserve shelf, license." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one reading: a location inside a reference, and what it is for.
|
||||
fn reading_schema() -> Value {
|
||||
json!({
|
||||
"oneOf": [
|
||||
{ "type": "string", "description": "The pre-schema form: a citation, unparsed." },
|
||||
reading_mapping_schema()
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The mapping form of a reading.
|
||||
fn reading_mapping_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": text("Citation key into `references`."),
|
||||
"locator": text("Where inside the work: §6.1, pp. 212-219, ch. 3."),
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Joined to the reference's base_url to reach this location."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Full URL, when base_url does not cover the location."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": strings(&["assigned", "supplemental"]),
|
||||
"description": "supplemental means offered but not separately assessed."
|
||||
},
|
||||
"objectives": string_array(
|
||||
"Objective ids this reading serves. A student who misses one of these is \
|
||||
pointed here, so the list is what makes study guidance specific."
|
||||
),
|
||||
"summary": text("What the section contains."),
|
||||
"focus": text("What to take from it. This is the sentence a student report quotes."),
|
||||
"skip": text("What to gloss, and why it is out of scope."),
|
||||
"text": text("A pre-schema citation string, held unparsed.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one shared stimulus.
|
||||
fn stimulus_schema() -> Value {
|
||||
json!({
|
||||
@@ -373,6 +472,12 @@ fn course_schema() -> Value {
|
||||
"type": "object",
|
||||
"description": "Shared passages, figures, or data that several items refer to.",
|
||||
"additionalProperties": stimulus_schema()
|
||||
},
|
||||
"references": {
|
||||
"type": "object",
|
||||
"description": "Works the course cites, by citation key. Readings point in \
|
||||
here, so an edition change is one edit.",
|
||||
"additionalProperties": reference_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -466,6 +571,77 @@ fn asset_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The worked solution, and for an open-response item how it is graded.
|
||||
fn solution_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "The answer, the reasoning, and the rubric. Rendered in the solutions \
|
||||
document and the answer key, never in a question paper.",
|
||||
"properties": {
|
||||
"model_answer": {
|
||||
"type": "string",
|
||||
"description": "For an open-response item, the response a full-credit student \
|
||||
writes; for a choice item, an optional one-line statement of the key."
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string",
|
||||
"description": "The worked reasoning a student learns from. The body of the \
|
||||
solutions entry."
|
||||
},
|
||||
"rubric": { "type": "array", "items": rubric_criterion_schema() },
|
||||
"accepted": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Responses a short constructed answer is accepted as."
|
||||
},
|
||||
"review": {
|
||||
"type": "array",
|
||||
"items": citation_schema(),
|
||||
"description": "Where to look again after missing this item."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One rubric line for an open-response item.
|
||||
fn rubric_criterion_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["description"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": text("What earns the points on this line."),
|
||||
"points": { "type": "number", "minimum": 0.0 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A citation into the reference registry, written as an object or a bare string.
|
||||
fn citation_schema() -> Value {
|
||||
json!({
|
||||
"oneOf": [
|
||||
{ "type": "string", "description": "A citation, unparsed." },
|
||||
citation_mapping_schema()
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The object form of a citation.
|
||||
fn citation_mapping_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": text("Citation key into `references`."),
|
||||
"locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." },
|
||||
"path": { "type": "string", "description": "Joined to the reference base_url." },
|
||||
"url": { "type": "string" },
|
||||
"text": { "type": "string" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for authored design intent.
|
||||
fn design_schema() -> Value {
|
||||
json!({
|
||||
@@ -633,9 +809,10 @@ fn item_identity_properties() -> Value {
|
||||
"cognitive_process": cognitive_process(),
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["single_best_answer", "multiple_response", "true_false"],
|
||||
"description": "single_best_answer requires exactly one keyed option; \
|
||||
multiple_response requires at least two."
|
||||
"enum": ["single_best_answer", "multiple_response", "true_false", "open_response"],
|
||||
"description": "single_best_answer keys exactly one option; multiple_response keys \
|
||||
two or more; open_response takes no options and is graded from its \
|
||||
solution."
|
||||
},
|
||||
"bonus": { "type": "boolean" },
|
||||
"points": { "type": "number", "exclusiveMinimum": 0.0 },
|
||||
@@ -662,8 +839,10 @@ fn item_content_properties() -> Value {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 8,
|
||||
"description": "Absent for an open_response item; at least two for any choice format.",
|
||||
"items": option_schema()
|
||||
},
|
||||
"solution": solution_schema(),
|
||||
"learning_objectives": string_array(
|
||||
"Objective ids this item measures. Reports aggregate on these, so an item with none \
|
||||
contributes to nothing."
|
||||
@@ -702,7 +881,7 @@ fn item_schema() -> Value {
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id", "level", "stem", "options"],
|
||||
"required": ["id", "level", "stem"],
|
||||
"additionalProperties": false,
|
||||
"properties": Value::Object(properties)
|
||||
})
|
||||
|
||||
+10
-4
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clarity
|
||||
// --- clarity
|
||||
let stem = it.stem.trim();
|
||||
let stem_lower = stem.to_lowercase();
|
||||
let words: Vec<&str> = stem.split_whitespace().collect();
|
||||
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- cueing
|
||||
// --- cueing
|
||||
let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect();
|
||||
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
|
||||
|
||||
@@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- completeness
|
||||
// --- completeness
|
||||
// These are only worth insisting on once an item is meant to be used.
|
||||
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
|
||||
if is_ready {
|
||||
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- evidence
|
||||
// --- evidence
|
||||
if !it.calibration_is_current() {
|
||||
push(
|
||||
Rule::StaleCalibration,
|
||||
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
|
||||
if e.item.status == Status::Retired {
|
||||
continue;
|
||||
}
|
||||
// An open-response item carries no options, so it is neither part of the
|
||||
// count norm nor able to deviate from it. Leaving it out keeps a bank of
|
||||
// four-option questions from reporting every essay as an odd count.
|
||||
if !e.item.has_options() {
|
||||
continue;
|
||||
}
|
||||
by_bank.entry(e.bank.as_str()).or_default().push(e);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn select(
|
||||
let seed = blueprint.seed.unwrap_or(0);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// ------------------------------------------------------------------ pool
|
||||
// --- pool
|
||||
let eligible: Vec<&crate::catalog::Entry> = catalog
|
||||
.assemblable()
|
||||
.into_iter()
|
||||
@@ -101,7 +101,7 @@ pub fn select(
|
||||
let mut chosen: Vec<String> = Vec::new();
|
||||
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
|
||||
|
||||
// ---------------------------------------------------- objective minimums
|
||||
// --- objective minimums
|
||||
// Placed first, because a coverage requirement is the constraint most likely
|
||||
// to become unsatisfiable once the level quotas are full.
|
||||
for (objective, needed) in &blueprint.objective_minimums {
|
||||
@@ -140,7 +140,7 @@ pub fn select(
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- level quotas
|
||||
// --- level quotas
|
||||
let mut scored: Vec<String> = Vec::new();
|
||||
for (level, want) in &blueprint.level_counts {
|
||||
if *want == 0 {
|
||||
@@ -175,7 +175,7 @@ pub fn select(
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ bonus items
|
||||
// --- bonus items
|
||||
let mut bonus: Vec<String> = Vec::new();
|
||||
for (level, want) in &blueprint.bonus_counts {
|
||||
if *want == 0 {
|
||||
|
||||
+115
@@ -23,6 +23,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use coursebank::assessment::{Kind as AssessmentKind, Platform};
|
||||
use coursebank::catalog::Severity;
|
||||
use coursebank::item::IrtModel;
|
||||
use coursebank::lecture::Style as PageStyle;
|
||||
use coursebank::store;
|
||||
|
||||
/// Manage course item banks, assessments, and the analysis that comes back.
|
||||
@@ -55,6 +56,9 @@ pub(crate) enum Command {
|
||||
Lint(LintArgs),
|
||||
/// Summarize the item pool and objective coverage.
|
||||
Catalog(CatalogArgs),
|
||||
/// Render a lecture's reading list, or check what backs each objective.
|
||||
#[command(subcommand)]
|
||||
Lecture(LectureCommand),
|
||||
/// Work with item banks.
|
||||
#[command(subcommand)]
|
||||
Bank(BankCommand),
|
||||
@@ -123,6 +127,64 @@ pub(crate) struct LintArgs {
|
||||
}
|
||||
|
||||
/// CLI mirror of [`coursebank::catalog::Severity`].
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum LectureCommand {
|
||||
/// Write the readings block for one lecture.
|
||||
///
|
||||
/// The course file is the source of truth for what a lecture assigns and why,
|
||||
/// so the list on the website is generated from it. Objective numbers are
|
||||
/// positional and are resolved here rather than authored.
|
||||
Readings {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
id: String,
|
||||
/// Which flavour of Markdown to write.
|
||||
#[arg(long, value_enum, default_value = "quarto")]
|
||||
style: StyleArg,
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Write the objectives block for one lecture, grouped by level.
|
||||
///
|
||||
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
|
||||
/// `readings`, so generating one and hand-writing the other is what this
|
||||
/// exists to prevent.
|
||||
Objectives {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
id: String,
|
||||
/// Which flavour of Markdown to write.
|
||||
#[arg(long, value_enum, default_value = "quarto")]
|
||||
style: StyleArg,
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Show the readings behind each objective, and which objectives have none.
|
||||
Coverage {
|
||||
/// Only this lecture's objectives.
|
||||
#[arg(long)]
|
||||
lecture: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum StyleArg {
|
||||
/// Pandoc definition lists, as a Quarto lecture page wants them.
|
||||
Quarto,
|
||||
/// Plain Markdown bullets.
|
||||
Plain,
|
||||
}
|
||||
|
||||
impl StyleArg {
|
||||
/// Converts the CLI value into the library's [`PageStyle`].
|
||||
pub(crate) fn as_style(self) -> PageStyle {
|
||||
match self {
|
||||
StyleArg::Quarto => PageStyle::Quarto,
|
||||
StyleArg::Plain => PageStyle::Plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum SeverityArg {
|
||||
Low,
|
||||
@@ -309,6 +371,11 @@ pub(crate) enum ExportCommand {
|
||||
/// Leave per-option feedback out of the package.
|
||||
#[arg(long)]
|
||||
no_feedback: bool,
|
||||
/// Canvas attempt limit; -1 for unlimited. Overrides the assessment's
|
||||
/// `attempts` field. More than one attempt also makes per-option feedback
|
||||
/// show the hint rather than the misconception.
|
||||
#[arg(long)]
|
||||
attempts: Option<i64>,
|
||||
},
|
||||
/// Render a printable exam, answer key, and answer sheet.
|
||||
///
|
||||
@@ -350,6 +417,54 @@ pub(crate) enum ExportCommand {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Write a Quarto worksheet and a matching solutions document.
|
||||
///
|
||||
/// The worksheet holds the questions and nothing else; the solutions document
|
||||
/// adds the key, the worked reasoning, the rubric, and the readings to revisit.
|
||||
/// This is the path that does not go through Canvas, so a student can practice
|
||||
/// from the `.qmd` and check themselves against the solutions. Render each with
|
||||
/// `quarto render <file>.qmd`.
|
||||
Practice {
|
||||
/// Assessment id.
|
||||
id: String,
|
||||
/// Which form's ordering to use.
|
||||
#[arg(long, default_value = "A")]
|
||||
form: String,
|
||||
/// Which documents to write; defaults to both. Values: worksheet, solutions.
|
||||
#[arg(long, value_name = "DOC")]
|
||||
variant: Vec<String>,
|
||||
/// Output directory; defaults to build/.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Do not leave written-answer space after open-response questions.
|
||||
#[arg(long)]
|
||||
no_answer_space: bool,
|
||||
},
|
||||
/// Write a Quarto questions partial and an encrypted, password-gated
|
||||
/// solutions bundle for the course website.
|
||||
///
|
||||
/// Needs the `site` feature (`cargo build --features site`). Writes
|
||||
/// `_questions.qmd` and `<id>-solutions.json` into the output directory, and
|
||||
/// prints a fresh password that the files do not store.
|
||||
Site {
|
||||
/// Assessment id.
|
||||
id: String,
|
||||
/// Which form's option order to print.
|
||||
#[arg(long, default_value = "A")]
|
||||
form: String,
|
||||
/// Output directory; defaults to build/. Point it at the page's own
|
||||
/// directory so the browser fetches the bundle beside the page.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Encrypt with this password instead of a generated one. Use only to
|
||||
/// re-encrypt a page with a known password.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Also write questions.css and solutions.js into this directory, e.g.
|
||||
/// the site's static assets folder. They install once, not per page.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
assets: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//!
|
||||
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
|
||||
//! `lint`, `catalog`.
|
||||
//! - [`lectures`] — render a lecture's reading list and check what backs each
|
||||
//! objective: `lecture`.
|
||||
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
|
||||
//! `assemble`, `usage`.
|
||||
//! - [`export`] — turn an assessment into deliverables: `export`, `template`.
|
||||
@@ -22,6 +24,7 @@
|
||||
pub(crate) mod analysis;
|
||||
pub(crate) mod banks;
|
||||
pub(crate) mod export;
|
||||
pub(crate) mod lectures;
|
||||
pub(crate) mod project;
|
||||
|
||||
use coursebank::error::Result;
|
||||
@@ -56,6 +59,7 @@ pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
|
||||
Command::Validate => project::validate(cli),
|
||||
Command::Lint(args) => project::lint(cli, args),
|
||||
Command::Catalog(args) => project::catalog(cli, args),
|
||||
Command::Lecture(sub) => lectures::lecture(cli, sub),
|
||||
Command::Bank(sub) => banks::bank(cli, sub),
|
||||
Command::Assessment(sub) => banks::assessment(cli, sub),
|
||||
Command::Assemble(args) => banks::assemble(cli, args),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use coursebank::assessment::Form;
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::layout::Layout;
|
||||
use coursebank::practice;
|
||||
use coursebank::qti;
|
||||
use coursebank::typst;
|
||||
use coursebank::yaml;
|
||||
@@ -31,6 +32,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
||||
form,
|
||||
out,
|
||||
no_feedback,
|
||||
attempts: _,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let form = pick_form(&record, form)?;
|
||||
@@ -168,9 +170,114 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ExportCommand::Practice {
|
||||
id,
|
||||
form,
|
||||
variant,
|
||||
out,
|
||||
no_answer_space,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let form = pick_form(&record, form)?;
|
||||
let dir = out.clone().unwrap_or(build);
|
||||
for v in pick_practice_variants(variant)? {
|
||||
let opts = practice::Options {
|
||||
form: form.clone(),
|
||||
variant: v,
|
||||
answer_space: !no_answer_space,
|
||||
};
|
||||
let text = practice::render(&catalog, &record, &opts)?;
|
||||
let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix()));
|
||||
yaml::write_text(&path, &text)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
if !cli.quiet {
|
||||
println!("\nRender with: quarto render <file>.qmd");
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ExportCommand::Site {
|
||||
id,
|
||||
form,
|
||||
out,
|
||||
password,
|
||||
assets,
|
||||
} => {
|
||||
{
|
||||
use coursebank::site;
|
||||
let record = load_record(&catalog, id)?;
|
||||
let form = pick_form(&record, form)?;
|
||||
let dir = out.clone().unwrap_or(build);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let rendered = site::render(
|
||||
&catalog,
|
||||
&record,
|
||||
site::Options {
|
||||
form,
|
||||
password: password.clone(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let qmd = dir.join("_questions.qmd");
|
||||
yaml::write_text(&qmd, &rendered.questions_qmd)?;
|
||||
let json = dir.join(format!("{}-solutions.json", rendered.page));
|
||||
yaml::write_text(&json, &rendered.solutions_json)?;
|
||||
|
||||
if let Some(asset_dir) = assets {
|
||||
std::fs::create_dir_all(asset_dir)?;
|
||||
for (name, body) in site::assets() {
|
||||
let path = asset_dir.join(name);
|
||||
yaml::write_text(&path, body)?;
|
||||
if !cli.quiet {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password cannot be recovered from the files; always print it.
|
||||
println!("password for {}: {}", rendered.page, rendered.password);
|
||||
if !cli.quiet {
|
||||
println!("wrote {}", qmd.display());
|
||||
println!("wrote {}", json.display());
|
||||
println!("\nInclude in the page with: {{{{< include _questions.qmd >}}}}");
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the `--variant` flags for `export practice`, defaulting to both.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `names` - the raw flag values, possibly empty.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The documents to write, deduplicated and in canonical order (worksheet first).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||
fn pick_practice_variants(names: &[String]) -> Result<Vec<practice::Variant>> {
|
||||
if names.is_empty() {
|
||||
return Ok(practice::Variant::ALL.to_vec());
|
||||
}
|
||||
let mut wanted = Vec::new();
|
||||
for name in names {
|
||||
let variant = practice::Variant::parse(name)?;
|
||||
if !wanted.contains(&variant) {
|
||||
wanted.push(variant);
|
||||
}
|
||||
}
|
||||
Ok(practice::Variant::ALL
|
||||
.into_iter()
|
||||
.filter(|v| wanted.contains(v))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolves the `--variant` flags, defaulting to every document.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering lecture pages, and checking what backs each objective.
|
||||
//!
|
||||
//! Both handlers here read the course file and nothing else, so neither needs a
|
||||
//! bank or a single response. That is deliberate: a reading list is useful in week
|
||||
//! one, before any item exists.
|
||||
|
||||
use coursebank::course::CourseFile;
|
||||
use coursebank::error::Result;
|
||||
use coursebank::lecture::{objectives_markdown, readings_markdown};
|
||||
use coursebank::yaml;
|
||||
|
||||
use crate::cli::{Cli, LectureCommand};
|
||||
use crate::commands::Outcome;
|
||||
|
||||
/// `lecture`: render a reading list, or report reading coverage.
|
||||
pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
|
||||
let course = CourseFile::load_dir(&cli.course)?;
|
||||
|
||||
match sub {
|
||||
LectureCommand::Readings { id, style, out } => emit(
|
||||
readings_markdown(&course, id, style.as_style())?,
|
||||
out.as_deref(),
|
||||
),
|
||||
LectureCommand::Objectives { id, style, out } => emit(
|
||||
objectives_markdown(&course, id, style.as_style())?,
|
||||
out.as_deref(),
|
||||
),
|
||||
LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes rendered Markdown to a file, or to stdout when no path was given.
|
||||
fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
|
||||
match out {
|
||||
Some(path) => {
|
||||
yaml::write_text(path, &markdown)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
None => print!("{markdown}"),
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Prints the readings behind each objective.
|
||||
///
|
||||
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
|
||||
/// that is the case where a student report can name what was missed but not where
|
||||
/// to go and read about it.
|
||||
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
|
||||
let ids: Vec<String> = match lecture {
|
||||
Some(l) => course
|
||||
.lecture_objectives(l)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
None => course.objectives_in_order(),
|
||||
};
|
||||
|
||||
for id in &ids {
|
||||
let readings = course.readings_for_objective(id);
|
||||
println!("{id}");
|
||||
if readings.is_empty() {
|
||||
println!(" (no reading)");
|
||||
continue;
|
||||
}
|
||||
for (lecture_id, reading) in readings {
|
||||
let Some(key) = reading.reference.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let reference = course.reference(key, lecture_id)?;
|
||||
let supplemental = match reading.role {
|
||||
coursebank::course::ReadingRole::Supplemental => " (supplemental)",
|
||||
coursebank::course::ReadingRole::Assigned => "",
|
||||
};
|
||||
println!(
|
||||
" {lecture_id} {}{supplemental}",
|
||||
reading.cite(key, reference)
|
||||
);
|
||||
if let Some(focus) = &reading.focus {
|
||||
println!(
|
||||
" {}",
|
||||
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gaps = course.objectives_without_readings();
|
||||
if gaps.is_empty() {
|
||||
if !quiet {
|
||||
println!("\nevery assessed objective has a reading behind it");
|
||||
}
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\n{} assessed objective(s) with no reading, so a student report cannot say \
|
||||
where to go back to:",
|
||||
gaps.len()
|
||||
);
|
||||
for id in gaps {
|
||||
println!(" - {id}");
|
||||
}
|
||||
Ok(Outcome::Findings)
|
||||
}
|
||||
+224
-2
@@ -9,7 +9,9 @@
|
||||
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
|
||||
//! item-writing guidance. [`catalog`] summarizes the pool that results.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use coursebank::assessment::AssessmentFile;
|
||||
use coursebank::bank::BankFile;
|
||||
@@ -44,6 +46,9 @@ reports/
|
||||
*.swp
|
||||
";
|
||||
|
||||
/// Marks the block `init` prepends to a `.gitignore` that was already there.
|
||||
const GITIGNORE_HEADER: &str = "# Added by coursebank init.";
|
||||
|
||||
/// `init`: create a new course directory, refusing to clobber an existing one.
|
||||
pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
@@ -70,7 +75,7 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
println!("wrote {}", bank_path.display());
|
||||
}
|
||||
|
||||
yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
|
||||
write_gitignore(&cli.course.join(".gitignore"))?;
|
||||
println!(
|
||||
"\nNext: edit {} to add your learning objectives and lectures, then\n \
|
||||
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
|
||||
@@ -79,6 +84,153 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// What reconciling [`GITIGNORE`] against a file already on disk would do.
|
||||
struct GitignoreMerge {
|
||||
/// The file to write. Identical to the input when nothing was missing.
|
||||
text: String,
|
||||
/// The patterns that were missing, in the order [`GITIGNORE`] lists them.
|
||||
added: Vec<String>,
|
||||
/// Patterns the file un-ignores with a `!` rule, which are left out. Git
|
||||
/// applies the last matching rule, so a line added at the top would lose.
|
||||
negated: Vec<String>,
|
||||
}
|
||||
|
||||
/// The comparison key for one `.gitignore` line, or `None` for a blank or comment.
|
||||
///
|
||||
/// `build`, `build/`, and `/build/` are one pattern spelled three ways, so the key
|
||||
/// drops the slashes. A leading `!` stays, because `!build` is the opposite of
|
||||
/// `build` rather than a restatement of it.
|
||||
fn pattern_key(line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let (bang, rest) = match trimmed.strip_prefix('!') {
|
||||
Some(rest) => ("!", rest.trim_start()),
|
||||
None => ("", trimmed),
|
||||
};
|
||||
let rest = rest.trim_start_matches('/').trim_end_matches('/');
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{bang}{rest}"))
|
||||
}
|
||||
|
||||
/// Reconciles [`GITIGNORE`] against a `.gitignore` that is already on disk.
|
||||
///
|
||||
/// Patterns the file already has are skipped, and a block of [`GITIGNORE`] left
|
||||
/// with no patterns loses its comment too, so nobody ends up with a heading over
|
||||
/// nothing. What survives goes above the existing content, which is copied
|
||||
/// through unchanged, including its line endings.
|
||||
fn merge_gitignore(existing: &str) -> GitignoreMerge {
|
||||
let keys: BTreeSet<String> = existing.lines().filter_map(pattern_key).collect();
|
||||
let crlf = existing.contains("\r\n");
|
||||
let newline = if crlf { "\r\n" } else { "\n" };
|
||||
|
||||
let mut block: Vec<&str> = Vec::new();
|
||||
let mut pending: Vec<&str> = Vec::new();
|
||||
let mut added: Vec<String> = Vec::new();
|
||||
let mut negated: Vec<String> = Vec::new();
|
||||
let mut kept_in_block = false;
|
||||
|
||||
for line in GITIGNORE.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
// A blank line starts a new block, so any comment still waiting for a
|
||||
// pattern belonged to a block that was dropped entirely.
|
||||
pending.clear();
|
||||
kept_in_block = false;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('#') {
|
||||
pending.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
let Some(key) = pattern_key(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
if keys.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if keys.contains(&format!("!{key}")) {
|
||||
negated.push(trimmed.to_string());
|
||||
continue;
|
||||
}
|
||||
if !kept_in_block && !block.is_empty() {
|
||||
block.push("");
|
||||
}
|
||||
block.append(&mut pending);
|
||||
kept_in_block = true;
|
||||
block.push(trimmed);
|
||||
added.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
let mut text = String::new();
|
||||
if !block.is_empty() {
|
||||
for line in std::iter::once(GITIGNORE_HEADER).chain(block).chain([""]) {
|
||||
text.push_str(line);
|
||||
text.push_str(newline);
|
||||
}
|
||||
}
|
||||
text.push_str(existing);
|
||||
|
||||
GitignoreMerge {
|
||||
text,
|
||||
added,
|
||||
negated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the `.gitignore`, merging into one that is already there.
|
||||
///
|
||||
/// `init` is often run in a repository that already has a `.gitignore`, and the
|
||||
/// first version of this overwrote it. An existing file now keeps everything it
|
||||
/// had and gains only the patterns it was missing, at the top where they are easy
|
||||
/// to see in the diff.
|
||||
fn write_gitignore(path: &Path) -> Result<()> {
|
||||
let existing = match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
// Nothing to merge with. Stay quiet about it, the way this always has.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return yaml::write_text(path, GITIGNORE);
|
||||
}
|
||||
// A file that is there but unreadable, or not UTF-8, is not one to
|
||||
// replace on a guess.
|
||||
Err(e) => return Err(Error::io(path, e)),
|
||||
};
|
||||
|
||||
let merge = merge_gitignore(&existing);
|
||||
if merge.added.is_empty() {
|
||||
println!(
|
||||
"{} already has every pattern init would add",
|
||||
path.display()
|
||||
);
|
||||
} else {
|
||||
yaml::write_text(path, &merge.text)?;
|
||||
println!(
|
||||
"added {} pattern(s) to the top of {}: {}",
|
||||
merge.added.len(),
|
||||
path.display(),
|
||||
merge.added.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
for pattern in &merge.negated {
|
||||
println!(
|
||||
"note: {} un-ignores `{pattern}`, and the last matching rule wins, \
|
||||
so init did not add it",
|
||||
path.display()
|
||||
);
|
||||
if pattern.contains("salt") {
|
||||
println!(
|
||||
" that rule will commit the pseudonymization salt; \
|
||||
remove it before you push"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML.
|
||||
pub(crate) fn schema(cli: &Cli) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
@@ -271,4 +423,74 @@ mod tests {
|
||||
assert!(GITIGNORE.contains(".coursebank-salt"));
|
||||
assert!(GITIGNORE.contains("build/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_keeps_the_existing_file_and_adds_only_what_was_missing() {
|
||||
let existing = "# rules I wrote\ntarget/\nbuild/\n*.pdf\n";
|
||||
let merge = merge_gitignore(existing);
|
||||
|
||||
assert!(
|
||||
merge.text.ends_with(existing),
|
||||
"the existing file must survive byte for byte:\n{}",
|
||||
merge.text
|
||||
);
|
||||
assert!(merge.text.starts_with(GITIGNORE_HEADER));
|
||||
assert!(!merge.added.iter().any(|p| p == "build/" || p == "*.pdf"));
|
||||
assert!(merge.added.iter().any(|p| p == "reports/"));
|
||||
assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_nothing_left_to_add_loses_its_comment() {
|
||||
let merge = merge_gitignore("build/\nreports/\n");
|
||||
assert!(!merge.text.contains("# Generated output"));
|
||||
assert!(merge.text.contains("# Typst and PDF artifacts."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slashes_do_not_make_a_pattern_look_new() {
|
||||
let merge = merge_gitignore("/build\nreports\n/data/\n");
|
||||
assert!(
|
||||
!merge.added.iter().any(|p| p.contains("build")),
|
||||
"`/build` already covers `build/`, so it must not be added again"
|
||||
);
|
||||
assert!(!merge.added.iter().any(|p| p.contains("reports")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_file_is_left_exactly_as_it_was() {
|
||||
let merge = merge_gitignore(GITIGNORE);
|
||||
assert!(merge.added.is_empty());
|
||||
assert_eq!(merge.text, GITIGNORE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negated_pattern_is_reported_instead_of_reinserted() {
|
||||
let merge = merge_gitignore("*.pdf\n!*.salt\n");
|
||||
assert_eq!(merge.negated, vec!["*.salt".to_string()]);
|
||||
assert!(!merge.added.iter().any(|p| p == "*.salt"));
|
||||
// The un-ignore covers one spelling of the salt, not the other.
|
||||
assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_follow_the_file_being_merged_into() {
|
||||
let merge = merge_gitignore("target/\r\n");
|
||||
assert_eq!(
|
||||
merge.text.matches('\n').count(),
|
||||
merge.text.matches("\r\n").count(),
|
||||
"a CRLF file must not gain bare LF lines:\n{:?}",
|
||||
merge.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_blank_lines_are_not_patterns() {
|
||||
assert_eq!(pattern_key(" build/ ").as_deref(), Some("build"));
|
||||
assert_eq!(pattern_key("/build/").as_deref(), Some("build"));
|
||||
assert_eq!(pattern_key("!build").as_deref(), Some("!build"));
|
||||
assert_eq!(pattern_key("# build/"), None);
|
||||
assert_eq!(pattern_key(" "), None);
|
||||
assert_eq!(pattern_key("/"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,4 @@ pub mod canvas;
|
||||
pub mod gradescope;
|
||||
pub mod responses;
|
||||
pub mod store;
|
||||
#[cfg(feature = "parquet")]
|
||||
pub mod store_parquet;
|
||||
|
||||
+2
-31
@@ -49,21 +49,14 @@ impl Format {
|
||||
///
|
||||
/// Parquet when the `parquet` feature is on, CSV otherwise.
|
||||
pub fn preferred() -> Format {
|
||||
#[cfg(feature = "parquet")]
|
||||
{
|
||||
Format::Parquet
|
||||
}
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
{
|
||||
Format::Csv
|
||||
}
|
||||
Format::Parquet
|
||||
}
|
||||
|
||||
/// Whether this format can be written by the current build.
|
||||
pub fn is_available(self) -> bool {
|
||||
match self {
|
||||
Format::Csv => true,
|
||||
Format::Parquet => cfg!(feature = "parquet"),
|
||||
Format::Parquet => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result<ResponseSet> {
|
||||
///
|
||||
/// * `path` - the destination.
|
||||
/// * `rows` - the rows.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::FeatureDisabled`] when the feature is off.
|
||||
#[cfg(feature = "parquet")]
|
||||
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
|
||||
crate::store_parquet::write(path, rows)
|
||||
}
|
||||
|
||||
/// Stub for builds without Parquet support.
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
|
||||
Err(Error::FeatureDisabled("Parquet", "parquet"))
|
||||
}
|
||||
|
||||
/// Reads flat responses from Parquet.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::FeatureDisabled`] when the feature is off.
|
||||
#[cfg(feature = "parquet")]
|
||||
fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
||||
let rows = crate::store_parquet::read(path)?;
|
||||
let mut set = ResponseSet::new();
|
||||
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Stub for builds without Parquet support.
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
||||
Err(Error::Other(format!(
|
||||
"{} is a Parquet file, but this build has Parquet support compiled out; \
|
||||
rebuild with `--features parquet`, or re-ingest with `--format csv`",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
/// Makes an administration id usable as a file name.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! |:--|:--|:--|
|
||||
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
|
||||
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
|
||||
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
|
||||
//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
|
||||
//! | [`report`] | Markdown and HTML | students, and yourself |
|
||||
//! | [`lecture`] | Markdown | the reading list on the course website |
|
||||
//!
|
||||
//! [`qti`] and [`typst`] share one rule that is easy to get wrong: a form's answer
|
||||
//! key must be generated from the same permutation that produced its question
|
||||
@@ -20,6 +23,9 @@
|
||||
//! answers, other students' data, and any numeric rank.
|
||||
//! The instructor report answers "what should I fix?" and holds the item statistics.
|
||||
|
||||
pub mod lecture;
|
||||
pub mod practice;
|
||||
pub mod qti;
|
||||
pub mod report;
|
||||
pub mod site;
|
||||
pub mod typst;
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering a lecture's objectives and readings as Markdown.
|
||||
//!
|
||||
//! The course file is the source of truth for what a lecture assigns and why, so
|
||||
//! the reading list on the course website is generated rather than kept in step by
|
||||
//! hand. Two copies of the same prose drift within a term; one copy and a build
|
||||
//! step do not.
|
||||
//!
|
||||
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
|
||||
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
|
||||
//! Those numbers are positional and so cannot be authored: inserting an objective
|
||||
//! renumbers everything after it. They are computed here and never stored.
|
||||
//!
|
||||
//! What this module does not do is invent prose. Everything printed comes from
|
||||
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
|
||||
//! none of the three renders as a bare citation.
|
||||
|
||||
use crate::course::{CourseFile, Reading, ReadingRole, Reference};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::taxonomy::Level;
|
||||
|
||||
/// Which flavour of Markdown to emit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Style {
|
||||
/// Pandoc definition lists with `<br>` before the objective line, which is
|
||||
/// what a Quarto lecture page uses.
|
||||
#[default]
|
||||
Quarto,
|
||||
/// Plain Markdown bullets, for a report or a README.
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// Renders the objectives for one lecture, grouped by level.
|
||||
///
|
||||
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
|
||||
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
|
||||
/// disagree. Generating one half of the page and hand-writing the other is how you
|
||||
/// get a note pointing at LO 8 when LO 8 has become LO 9.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
|
||||
/// grouped last under no heading.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
|
||||
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
|
||||
course.lecture(lecture, "lecture page")?;
|
||||
let ids = course.lecture_objectives(lecture);
|
||||
|
||||
let mut out = String::from("## Learning objectives\n\n");
|
||||
out.push_str("After this lecture, you should be able to do the following.\n\n");
|
||||
|
||||
// Levels in taxonomy order, then whatever declares no ceiling.
|
||||
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
|
||||
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
|
||||
groups.push((None, Vec::new()));
|
||||
for id in &ids {
|
||||
let ceiling = course.learning_objectives[*id].level_ceiling;
|
||||
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
|
||||
slot.1.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (level, members) in &groups {
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(level) = level {
|
||||
out.push_str(&format!("### {}\n\n", level.name()));
|
||||
}
|
||||
for id in members {
|
||||
let text = course.objective_text(id);
|
||||
out.push_str(&match style {
|
||||
Style::Quarto => format!("(@) {text}\n"),
|
||||
Style::Plain => format!("1. {text}\n"),
|
||||
});
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders the readings for one lecture.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id, such as `L1.1`.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Markdown, ending in a newline. Supplemental readings follow the assigned
|
||||
/// ones under their own subheading, and are omitted entirely when there are none.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the lecture id or a cited reference is not
|
||||
/// registered.
|
||||
pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
|
||||
let lec = course.lecture(lecture, "lecture page")?;
|
||||
|
||||
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
|
||||
// `_(LO ...)_` agree with the objective list printed above them.
|
||||
let order = course.lecture_objectives(lecture);
|
||||
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
|
||||
|
||||
let mut out = String::from("## Readings\n\n");
|
||||
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
|
||||
let group: Vec<&Reading> = lec.readings.iter().filter(|r| r.role == role).collect();
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if role == ReadingRole::Supplemental {
|
||||
out.push_str("### Supplemental\n\n");
|
||||
}
|
||||
for reading in group {
|
||||
out.push_str(&entry(course, reading, style, &number)?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders one reading.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the course, for resolving references and placeholders.
|
||||
/// * `reading` - the reading.
|
||||
/// * `style` - which flavour to emit.
|
||||
/// * `number` - the position of an objective on this page, if it has one.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The entry, followed by a blank line.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the cited reference is not registered.
|
||||
fn entry(
|
||||
course: &CourseFile,
|
||||
reading: &Reading,
|
||||
style: Style,
|
||||
number: &impl Fn(&str) -> Option<usize>,
|
||||
) -> Result<String> {
|
||||
// A reading carried over from the old string form has nothing to resolve.
|
||||
if let (None, Some(text)) = (&reading.reference, &reading.text) {
|
||||
return Ok(match style {
|
||||
Style::Quarto => format!("{text}\n\n"),
|
||||
Style::Plain => format!("- {text}\n"),
|
||||
});
|
||||
}
|
||||
let key = reading
|
||||
.reference
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::other("reading has neither a reference nor text"))?;
|
||||
let reference = course.reference(key, "lecture page")?;
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&heading(reading, key, reference, style));
|
||||
|
||||
// The three prose fields in the order a reader wants them: what it is, what to
|
||||
// take from it, what to leave.
|
||||
let body: Vec<String> = [&reading.summary, &reading.focus, &reading.skip]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|prose| {
|
||||
course.expand_objective_refs(prose, |id| match number(id) {
|
||||
Some(n) => format!("LO {n}"),
|
||||
None => course.objective_text(id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
match style {
|
||||
Style::Quarto => {
|
||||
if !body.is_empty() {
|
||||
out.push_str(&format!(": {}\n", body.join("\n")));
|
||||
}
|
||||
let mut numbers: Vec<usize> = reading
|
||||
.objectives
|
||||
.iter()
|
||||
.filter_map(|o| number(o))
|
||||
.collect();
|
||||
numbers.sort_unstable();
|
||||
if !numbers.is_empty() {
|
||||
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
|
||||
out.push_str(&format!("<br>\n_(LO {})_\n", list.join(", ")));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Style::Plain => {
|
||||
if !body.is_empty() {
|
||||
out.push_str(&format!(" {}\n", body.join(" ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The citation line that opens an entry.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reading` - the reading.
|
||||
/// * `key` - its citation key.
|
||||
/// * `reference` - the cited work.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A linked citation when the location has a URL, and a plain one when it does not.
|
||||
fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let locator = reading.locator.as_deref().unwrap_or("");
|
||||
let linked = match reading.resolve_url(reference) {
|
||||
Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
|
||||
Some(url) => format!("[{}]({url})", reference.title),
|
||||
None if !locator.is_empty() => locator.to_string(),
|
||||
None => reference.title.clone(),
|
||||
};
|
||||
match style {
|
||||
Style::Quarto => format!("`{label}` {linked}\n"),
|
||||
Style::Plain => format!("- **{label}** {linked}\n"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::course::{Lecture, Objective, ReferenceRole};
|
||||
|
||||
/// A course with one lecture, two objectives, and one reference.
|
||||
fn course() -> CourseFile {
|
||||
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
|
||||
c.lectures.clear();
|
||||
c.learning_objectives.clear();
|
||||
|
||||
c.references.insert(
|
||||
"kuriyan2013molecules".into(),
|
||||
Reference {
|
||||
label: Some("KKW".into()),
|
||||
role: ReferenceRole::Required,
|
||||
title: "The molecules of life".into(),
|
||||
base_url: Some("https://example.org/kkw/".into()),
|
||||
..Reference::default()
|
||||
},
|
||||
);
|
||||
for (id, order) in [("lo-second", 2), ("lo-first", 1)] {
|
||||
c.learning_objectives.insert(
|
||||
id.into(),
|
||||
Objective {
|
||||
text: format!("objective {id}"),
|
||||
lectures: vec!["L1.1".into()],
|
||||
order: Some(order),
|
||||
..objective_defaults()
|
||||
},
|
||||
);
|
||||
}
|
||||
c.lectures.insert(
|
||||
"L1.1".into(),
|
||||
Lecture {
|
||||
title: "Enthalpy".into(),
|
||||
date: None,
|
||||
unit: None,
|
||||
slides_url: None,
|
||||
readings: vec![
|
||||
Reading {
|
||||
reference: Some("kuriyan2013molecules".into()),
|
||||
locator: Some("§6.1".into()),
|
||||
path: Some("6/A/#1".into()),
|
||||
objectives: vec!["lo-second".into(), "lo-first".into()],
|
||||
summary: Some("What a system is.".into()),
|
||||
focus: Some("A worked instance of {lo-first}.".into()),
|
||||
..Reading::default()
|
||||
},
|
||||
Reading {
|
||||
reference: Some("kuriyan2013molecules".into()),
|
||||
locator: Some("§1.9".into()),
|
||||
path: Some("1/B/#9".into()),
|
||||
role: ReadingRole::Supplemental,
|
||||
objectives: vec!["lo-second".into()],
|
||||
summary: Some("Background.".into()),
|
||||
..Reading::default()
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
/// The non-defaulted half of an objective, so the fixtures stay short.
|
||||
fn objective_defaults() -> Objective {
|
||||
Objective {
|
||||
text: String::new(),
|
||||
unit: None,
|
||||
lectures: Vec::new(),
|
||||
order: None,
|
||||
level_ceiling: None,
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
assessed: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quarto_form_matches_the_page_it_replaces() {
|
||||
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
let expected = "\
|
||||
## Readings
|
||||
|
||||
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
|
||||
: What a system is.
|
||||
A worked instance of LO 1.
|
||||
<br>
|
||||
_(LO 1, 2)_
|
||||
|
||||
### Supplemental
|
||||
|
||||
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
|
||||
: Background.
|
||||
<br>
|
||||
_(LO 2)_
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_numbers_follow_teaching_order_not_id_order() {
|
||||
// `lo-second` sorts first alphabetically and second by `order`.
|
||||
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("_(LO 1, 2)_"));
|
||||
assert!(md.contains("A worked instance of LO 1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objectives_group_by_level_in_taxonomy_order() {
|
||||
let mut c = course();
|
||||
c.learning_objectives
|
||||
.get_mut("lo-first")
|
||||
.expect("fixture")
|
||||
.level_ceiling = Some(Level::Remember);
|
||||
c.learning_objectives
|
||||
.get_mut("lo-second")
|
||||
.expect("fixture")
|
||||
.level_ceiling = Some(Level::Apply);
|
||||
let md = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
let expected = "\
|
||||
## Learning objectives
|
||||
|
||||
After this lecture, you should be able to do the following.
|
||||
|
||||
### Remember
|
||||
|
||||
(@) objective lo-first
|
||||
|
||||
### Apply
|
||||
|
||||
(@) objective lo-second
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objective_with_no_level_still_appears() {
|
||||
// Ungrouped, at the end, rather than silently dropped.
|
||||
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("(@) objective lo-first"));
|
||||
assert!(md.contains("(@) objective lo-second"));
|
||||
assert!(!md.contains("###"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supplemental_heading_appears_only_when_something_is_under_it() {
|
||||
let mut c = course();
|
||||
c.lectures.get_mut("L1.1").expect("lecture").readings.pop();
|
||||
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(!md.contains("Supplemental"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_string_reading_still_renders() {
|
||||
let mut c = course();
|
||||
let readings = &mut c.lectures.get_mut("L1.1").expect("lecture").readings;
|
||||
readings.clear();
|
||||
readings.push(Reading {
|
||||
text: Some("KKW §6.1: system and surroundings. https://example.org".into()),
|
||||
..Reading::default()
|
||||
});
|
||||
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("system and surroundings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_reference_is_an_error_rather_than_a_blank() {
|
||||
let mut c = course();
|
||||
c.references.clear();
|
||||
let err = readings_markdown(&c, "L1.1", Style::Quarto);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering an assessment as a Quarto worksheet a student can work through, and
|
||||
//! a matching solutions document they can learn from.
|
||||
//!
|
||||
//! This is the path that does not go through Canvas. You assemble a homework,
|
||||
//! quiz, or practice set the same way you assemble an exam, then render it as two
|
||||
//! `.qmd` files: [`Variant::Worksheet`] holds the questions and nothing else, and
|
||||
//! [`Variant::Solutions`] holds the same questions with the key marked, the worked
|
||||
//! reasoning, the rubric for anything open-ended, and where to read again. A
|
||||
//! student with neither the Canvas quiz nor the printed exam can still practice
|
||||
//! from the worksheet and check themselves against the solutions.
|
||||
//!
|
||||
//! A worksheet never contains the answer. It is built only from stems and
|
||||
//! options, and the option letters are the printed positions, so the document has
|
||||
//! nothing in it to leak: not a `correct` flag, not a solution, not a rationale.
|
||||
//! [`Variant::Solutions`] is a separate render from the same input.
|
||||
//!
|
||||
//! Option order comes from the form's seed. When a form shuffles, both
|
||||
//! documents relabel to the printed order through
|
||||
//! [`select::option_order`], so a worksheet handed to
|
||||
//! a student who saw form B agrees with the form B solutions.
|
||||
//!
|
||||
//! Everything a solution shows is authored: the model answer, the explanation, the
|
||||
//! per-option notes, the rubric, and the review citations. Nothing is invented
|
||||
//! here. A question with an empty [`crate::item::Solution`] renders its key and
|
||||
//! stops, which is a visible cue to go finish writing it.
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::{CourseFile, Reference};
|
||||
use crate::error::Result;
|
||||
use crate::item::{Choice, Citation, Item};
|
||||
use crate::markup;
|
||||
use crate::select;
|
||||
|
||||
/// Which of the two documents to render.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Variant {
|
||||
/// Questions only, for a student to work through.
|
||||
#[default]
|
||||
Worksheet,
|
||||
/// Questions with the key, worked solutions, rubric, and readings.
|
||||
Solutions,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Both documents, in the order they are usually written.
|
||||
pub const ALL: [Variant; 2] = [Variant::Worksheet, Variant::Solutions];
|
||||
|
||||
/// The token used on the command line and in a file name.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "worksheet",
|
||||
Variant::Solutions => "solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// The suffix a generated file name carries, e.g. `-solutions`.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "",
|
||||
Variant::Solutions => "-solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// The word for this document in a title.
|
||||
fn title_word(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "Questions",
|
||||
Variant::Solutions => "Solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a `--variant` value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - the token, case insensitive; `questions` is accepted for the
|
||||
/// worksheet and `key` for the solutions, since those are what people type.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Usage`] naming the valid tokens.
|
||||
pub fn parse(name: &str) -> Result<Variant> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"worksheet" | "questions" | "q" => Ok(Variant::Worksheet),
|
||||
"solutions" | "solution" | "key" => Ok(Variant::Solutions),
|
||||
other => Err(crate::error::Error::usage(format!(
|
||||
"unknown practice document `{other}`; use worksheet or solutions"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to render.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// Which form's ordering to use. Defaults to an unshuffled form.
|
||||
pub form: Form,
|
||||
/// Which document.
|
||||
pub variant: Variant,
|
||||
/// Leave vertical space after each question on the worksheet for a written
|
||||
/// answer. Ignored for the solutions document.
|
||||
pub answer_space: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Options {
|
||||
Options {
|
||||
form: Form {
|
||||
id: "A".to_string(),
|
||||
seed: 0,
|
||||
shuffle_items: false,
|
||||
shuffle_options: false,
|
||||
},
|
||||
variant: Variant::Worksheet,
|
||||
answer_space: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// Options for one variant on one form.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The options, with the answer space on.
|
||||
pub fn new(variant: Variant, form: Form) -> Options {
|
||||
Options {
|
||||
form,
|
||||
variant,
|
||||
answer_space: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the questions-only worksheet.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
|
||||
/// missing item.
|
||||
pub fn worksheet(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
|
||||
render(
|
||||
catalog,
|
||||
record,
|
||||
&Options::new(Variant::Worksheet, form.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the solutions document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`worksheet`].
|
||||
pub fn solutions(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
|
||||
render(
|
||||
catalog,
|
||||
record,
|
||||
&Options::new(Variant::Solutions, form.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders one document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - what to render.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
|
||||
/// missing item.
|
||||
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
|
||||
let course = &catalog.course;
|
||||
let mut out = front_matter(course, record, opts.variant);
|
||||
|
||||
if let Some(instructions) = &record.assessment.instructions {
|
||||
out.push_str(&markup::to_markdown(instructions));
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
// One shared stimulus is printed once, above the first question that uses it,
|
||||
// so a testlet reads as a block rather than repeating the vignette per item.
|
||||
let mut printed_stimulus: Option<String> = None;
|
||||
|
||||
let layout = select::layout(record, &opts.form);
|
||||
for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let number = position + 1;
|
||||
|
||||
if let Some(stimulus_id) = &item.stimulus {
|
||||
if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) {
|
||||
if let Some(stimulus) = course.stimuli.get(stimulus_id) {
|
||||
out.push_str("::: {.stimulus}\n\n");
|
||||
out.push_str(&markup::to_markdown(&stimulus.body));
|
||||
out.push_str("\n\n:::\n\n");
|
||||
}
|
||||
printed_stimulus = Some(stimulus_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match opts.variant {
|
||||
Variant::Worksheet => worksheet_question(
|
||||
&mut out,
|
||||
number,
|
||||
placement,
|
||||
item,
|
||||
&opts.form,
|
||||
opts.answer_space,
|
||||
),
|
||||
Variant::Solutions => {
|
||||
solution_question(&mut out, number, placement, item, &opts.form, course)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Quarto YAML front matter.
|
||||
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
|
||||
let title = format!("{}: {}", record.assessment.title, variant.title_word());
|
||||
let subtitle = format!("{} · {}", course.course.code, course.course.title);
|
||||
let mut out = String::from("---\n");
|
||||
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
|
||||
out.push_str(&format!("subtitle: \"{}\"\n", yaml_quote(&subtitle)));
|
||||
if let Some(date) = record.assessment.date {
|
||||
out.push_str(&format!("date: \"{date}\"\n"));
|
||||
}
|
||||
out.push_str("format:\n html:\n toc: false\n number-sections: false\n");
|
||||
out.push_str("---\n\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// One question on the worksheet: stem, options in printed order, no answer.
|
||||
fn worksheet_question(
|
||||
out: &mut String,
|
||||
number: usize,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
answer_space: bool,
|
||||
) {
|
||||
out.push_str(&heading(number, placement));
|
||||
out.push_str(&markup::to_markdown(&item.stem));
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"{}. {}\n",
|
||||
letter(position),
|
||||
markup::to_markdown(&source.text)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
} else if answer_space {
|
||||
// A place to write, sized by the theme, present only when asked for.
|
||||
out.push_str("::: {.answer-space}\n:::\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// One question in the solutions document: stem, key, worked reasoning, rubric,
|
||||
/// and where to look again.
|
||||
fn solution_question(
|
||||
out: &mut String,
|
||||
number: usize,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
course: &CourseFile,
|
||||
) {
|
||||
out.push_str(&heading(number, placement));
|
||||
out.push_str(&meta_line(placement, item));
|
||||
out.push_str(&markup::to_markdown(&item.stem));
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
let mark = if source.correct { " ✓" } else { "" };
|
||||
let note = source
|
||||
.student_text()
|
||||
.map(|t| format!(": {}", markup::to_markdown(t)))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"{}. {}{mark}{note}\n",
|
||||
letter(position),
|
||||
markup::to_markdown(&source.text)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
solution_body(out, item);
|
||||
objectives_line(out, item, course);
|
||||
review_line(out, item, course);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
/// The model answer, explanation, rubric, and accepted answers, when present.
|
||||
fn solution_body(out: &mut String, item: &Item) {
|
||||
let Some(solution) = item.solution.as_ref().filter(|s| !s.is_empty()) else {
|
||||
if !item.has_options() {
|
||||
// An open-response question with no written solution is unfinished, and
|
||||
// saying so in the document is more useful than a silent blank.
|
||||
out.push_str("_No solution written yet._\n\n");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(answer) = &solution.model_answer {
|
||||
out.push_str(&format!(
|
||||
"**Model answer.** {}\n\n",
|
||||
markup::to_markdown(answer)
|
||||
));
|
||||
}
|
||||
if let Some(explanation) = &solution.explanation {
|
||||
out.push_str(&markup::to_markdown(explanation));
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
if !solution.rubric.is_empty() {
|
||||
out.push_str("**Rubric**\n\n");
|
||||
for criterion in &solution.rubric {
|
||||
let points = criterion
|
||||
.points
|
||||
.map(|p| format!(" ({} pt)", trim_number(p)))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"- {}{points}\n",
|
||||
markup::to_markdown(&criterion.description)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if !solution.accepted.is_empty() {
|
||||
let joined: Vec<String> = solution
|
||||
.accepted
|
||||
.iter()
|
||||
.map(|a| markup::to_markdown(a))
|
||||
.collect();
|
||||
out.push_str(&format!("**Accepted answers:** {}\n\n", joined.join("; ")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Tests:` line naming the objectives this item measures.
|
||||
fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
if item.learning_objectives.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texts: Vec<String> = item
|
||||
.learning_objectives
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.collect();
|
||||
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
|
||||
}
|
||||
|
||||
/// The `Review:` line, resolving each citation to a short label, linked when a URL
|
||||
/// resolves.
|
||||
fn review_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
let Some(solution) = item.solution.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if solution.review.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cites: Vec<String> = solution.review.iter().map(|c| cite(course, c)).collect();
|
||||
out.push_str(&format!("**Review:** {}\n\n", cites.join("; ")));
|
||||
}
|
||||
|
||||
/// Resolves one citation to Markdown, mirroring the lecture reading style
|
||||
/// `` `KKW` [§6.1](url) ``.
|
||||
fn cite(course: &CourseFile, citation: &Citation) -> String {
|
||||
if let Some(text) = &citation.text {
|
||||
if citation.reference.is_none() {
|
||||
return text.clone();
|
||||
}
|
||||
}
|
||||
let Some(key) = &citation.reference else {
|
||||
return citation.display();
|
||||
};
|
||||
let Some(reference) = course.references.get(key) else {
|
||||
return citation.display();
|
||||
};
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let locator = citation.locator.as_deref().unwrap_or("");
|
||||
match resolve_url(citation, reference) {
|
||||
Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"),
|
||||
Some(url) => format!("`{label}` [{}]({url})", reference.title),
|
||||
None if !locator.is_empty() => format!("`{label}` {locator}"),
|
||||
None => format!("`{label}`"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL for a citation: its own `url`, else the reference `base_url` joined with
|
||||
/// the citation `path`.
|
||||
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &citation.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = citation.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `## Question N` heading, marking a bonus item.
|
||||
fn heading(number: usize, placement: &Placement) -> String {
|
||||
let bonus = if placement.bonus { " (bonus)" } else { "" };
|
||||
format!("## Question {number}{bonus}\n\n")
|
||||
}
|
||||
|
||||
/// The italic level-and-points line under a solutions heading.
|
||||
fn meta_line(placement: &Placement, item: &Item) -> String {
|
||||
let level = placement.level.unwrap_or(item.level);
|
||||
let mut parts = vec![format!("Level {} ({})", level.code(), level.name())];
|
||||
if let Some(points) = placement.points {
|
||||
parts.push(format!("{} point(s)", trim_number(points)));
|
||||
}
|
||||
format!("_{}_\n\n", parts.join(" · "))
|
||||
}
|
||||
|
||||
/// The options in the order the form prints them.
|
||||
///
|
||||
/// Salted with the item's global id, the same value the Typst and QTI exports use,
|
||||
/// so a worksheet built for form B lists options in the order that form's paper and
|
||||
/// its Canvas quiz do.
|
||||
fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> {
|
||||
select::option_order(form, uid, item.options.len())
|
||||
.into_iter()
|
||||
.map(|i| &item.options[i])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The printed letter for a zero-based position.
|
||||
fn letter(position: usize) -> char {
|
||||
(b'A' + (position as u8 % 26)) as char
|
||||
}
|
||||
|
||||
/// Formats a point value without a trailing `.0`.
|
||||
fn trim_number(value: f64) -> String {
|
||||
if value.fract() == 0.0 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
let s = format!("{value:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a double quote for a YAML double-quoted scalar.
|
||||
fn yaml_quote(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assessment::{Assessment, Kind, Platform};
|
||||
|
||||
/// Writes a course and a bank to a temp directory and loads them, the same way
|
||||
/// the catalog tests do, so this exercises only public API. The `tag` keeps each
|
||||
/// test in its own directory, so tests running in parallel do not clobber a
|
||||
/// shared `course.yaml`.
|
||||
fn catalog(tag: &str) -> Catalog {
|
||||
let dir = std::env::temp_dir().join(format!("cb-practice-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("course.yaml"),
|
||||
r#"
|
||||
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
|
||||
references:
|
||||
kkw:
|
||||
label: KKW
|
||||
title: The molecules of life
|
||||
base_url: https://example.org/kkw/
|
||||
lectures:
|
||||
L1.1: { title: Enthalpy }
|
||||
learning_objectives:
|
||||
lo-enthalpy:
|
||||
text: Define enthalpy and explain the constant-pressure result.
|
||||
lectures: [L1.1]
|
||||
order: 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("banks").join("l11.yaml"),
|
||||
r#"
|
||||
bank: { id: l11, title: L1.1 }
|
||||
items:
|
||||
- id: q-enthalpy-001
|
||||
status: draft
|
||||
level: 1
|
||||
stem: At constant pressure, the heat exchanged equals which quantity?
|
||||
learning_objectives: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
|
||||
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
|
||||
- { id: C, text: "zero" }
|
||||
solution:
|
||||
explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H."
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
- id: q-enthalpy-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
|
||||
learning_objectives: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
|
||||
rubric:
|
||||
- { description: "states H = U + PV", points: 1 }
|
||||
- { description: "identifies q_p with the enthalpy change", points: 1 }
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Catalog::load(&dir).expect("catalog loads")
|
||||
}
|
||||
|
||||
fn record() -> AssessmentFile {
|
||||
AssessmentFile {
|
||||
schema_version: "1.0".into(),
|
||||
assessment: Assessment {
|
||||
id: "hw-1".into(),
|
||||
title: "Homework 1".into(),
|
||||
term: None,
|
||||
kind: Kind::Homework,
|
||||
date: None,
|
||||
platform: Platform::Canvas,
|
||||
minutes_allowed: None,
|
||||
attempts: None,
|
||||
shuffle: None,
|
||||
scoring_policy: None,
|
||||
instructions: None,
|
||||
notes: None,
|
||||
},
|
||||
blueprint: None,
|
||||
forms: Vec::new(),
|
||||
items: vec![
|
||||
Placement {
|
||||
number: 1,
|
||||
item: "l11::q-enthalpy-001".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "l11::q-enthalpy-op-001".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worksheet_withholds_the_answer() {
|
||||
let md =
|
||||
worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("## Question 1"));
|
||||
assert!(md.contains("A. the enthalpy change"));
|
||||
// Nothing that reveals the key or the reasoning.
|
||||
assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}");
|
||||
assert!(!md.contains("Model answer"), "no model answer:\n{md}");
|
||||
assert!(!md.contains("P dV"), "no explanation:\n{md}");
|
||||
assert!(!md.contains("Rubric"));
|
||||
// The open-response question leaves room to write.
|
||||
assert!(md.contains("answer-space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solutions_show_key_reasoning_rubric_and_review() {
|
||||
let md =
|
||||
solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("A. the enthalpy change ✓"));
|
||||
assert!(md.contains("the internal energy change: ignores expansion work"));
|
||||
assert!(md.contains("**Model answer.**"));
|
||||
assert!(md.contains("H = U + PV"));
|
||||
assert!(md.contains("**Rubric**"));
|
||||
assert!(md.contains("states H = U + PV (1 pt)"));
|
||||
assert!(md.contains("Tests:** Define enthalpy"));
|
||||
// The review citation resolves to the label and a link.
|
||||
assert!(
|
||||
md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_titles_each_document() {
|
||||
let ws = worksheet(
|
||||
&catalog("front-matter-ws"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(ws.contains("title: \"Homework 1: Questions\""));
|
||||
let sol = solutions(
|
||||
&catalog("front-matter-sol"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(sol.contains("title: \"Homework 1: Solutions\""));
|
||||
}
|
||||
}
|
||||
+400
-11
@@ -47,9 +47,7 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
|
||||
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
||||
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A very small XML tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One XML element.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -155,9 +153,102 @@ fn escape_attr(s: &str) -> String {
|
||||
escape_text(s).replace('"', """)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
/// Renders authoring markup to HTML for Canvas, remapping math delimiters to the
|
||||
/// forms Canvas's MathJax recognizes.
|
||||
///
|
||||
/// Canvas loads MathJax and typesets a text field only when it finds one of its
|
||||
/// own delimiters: `\( … \)` inline, or `$$ … $$` as a display block. Bare `$ … $`
|
||||
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ … $`
|
||||
/// inline) would otherwise reach the quiz as literal dollar-sign text. Here inline
|
||||
/// `$ … $` becomes `\( … \)` and display `$$ … $$` is left as it is.
|
||||
///
|
||||
/// The split runs before inline markup, the same way the site export handles it,
|
||||
/// so a subscript like `$q_p$` is not read as an emphasis span. Apart from the
|
||||
/// math, the paragraph and line handling matches [`markup::to_html`].
|
||||
fn to_html_canvas(src: &str) -> String {
|
||||
let paragraphs: Vec<String> = src
|
||||
.split("\n\n")
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| {
|
||||
let joined = p
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("<p>{}</p>", canvas_segments(&joined))
|
||||
})
|
||||
.collect();
|
||||
if paragraphs.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
paragraphs.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits one line on math delimiters and renders each run: prose through the
|
||||
/// shared escape, symbol, and inline-markup pipeline; math wrapped in the Canvas
|
||||
/// delimiter for its kind, its interior escaped for HTML transport so a `<` inside
|
||||
/// math survives as `<` and is decoded back before MathJax reads it.
|
||||
fn canvas_segments(line: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = line;
|
||||
while let Some(at) = rest.find('$') {
|
||||
if at > 0 {
|
||||
out.push_str(&canvas_prose(&rest[..at]));
|
||||
}
|
||||
let after = &rest[at..];
|
||||
if let Some(display) = after.strip_prefix("$$") {
|
||||
if let Some(end) = display.find("$$") {
|
||||
out.push_str(&format!("$${}$$", markup::escape_html(&display[..end])));
|
||||
rest = &display[end + 2..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let inline = &after[1..];
|
||||
match inline.find('$') {
|
||||
Some(end) => {
|
||||
out.push_str(&format!("\\({}\\)", markup::escape_html(&inline[..end])));
|
||||
rest = &inline[end + 1..];
|
||||
}
|
||||
None => {
|
||||
// An unterminated `$` is ordinary text.
|
||||
out.push_str(&canvas_prose(after));
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push_str(&canvas_prose(rest));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A prose run: escape, the symbol table, then inline markup, the pipeline
|
||||
/// [`markup::to_html`] uses.
|
||||
fn canvas_prose(text: &str) -> String {
|
||||
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
|
||||
}
|
||||
|
||||
/// The per-option feedback to show, given the attempt policy.
|
||||
///
|
||||
/// A single-attempt quiz is summative, so it shows the full post-submission
|
||||
/// feedback ([`crate::item::Choice::student_text`]: the misconception and
|
||||
/// explanation). A
|
||||
/// multi-attempt quiz is formative, so it shows only the hint, withholding the
|
||||
/// misconception a student would otherwise read before their next try. When a
|
||||
/// distractor has no hint, a multi-attempt quiz shows nothing for it rather than
|
||||
/// falling back to the misconception.
|
||||
fn option_feedback<'a>(choice: &'a crate::item::Choice, opts: &QtiOptions) -> Option<&'a str> {
|
||||
if opts.attempts == 1 {
|
||||
choice.student_text()
|
||||
} else {
|
||||
choice.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// Package construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Options for a QTI export.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -170,7 +261,9 @@ pub struct QtiOptions {
|
||||
pub include_feedback: bool,
|
||||
/// Whether to let Canvas shuffle answers on top of the form's own order.
|
||||
pub shuffle_in_canvas: bool,
|
||||
/// Maximum attempts; `-1` for unlimited.
|
||||
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
|
||||
/// per-option feedback from the misconception to the hint, so a formative
|
||||
/// quiz nudges rather than reveals.
|
||||
pub attempts: i64,
|
||||
/// How repeated attempts are scored.
|
||||
pub scoring_policy: ScoringPolicy,
|
||||
@@ -251,7 +344,10 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
for placement in select::layout(record, &opts.form) {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
if item.key_indices().is_empty() {
|
||||
// A choice question needs a keyed option or Canvas cannot score it. An
|
||||
// open-response question has no options and is graded by hand, so the
|
||||
// absence of a key is expected; it exports as a Canvas essay.
|
||||
if item.format.has_options() && item.key_indices().is_empty() {
|
||||
problems.push(format!(
|
||||
"question {} ({}) has no keyed option, so Canvas cannot score it",
|
||||
placement.number, placement.item
|
||||
@@ -338,6 +434,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
///
|
||||
/// The element.
|
||||
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
|
||||
// An open-response item is an essay in Canvas: no choices, graded by hand.
|
||||
if !item.format.has_options() {
|
||||
return build_essay_item(assessment_id, uid, item, points, opts);
|
||||
}
|
||||
|
||||
let order = select::option_order(&opts.form, uid, item.options.len());
|
||||
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
|
||||
|
||||
@@ -367,14 +468,14 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
|
||||
.map(|(o, id)| {
|
||||
Node::new("response_label")
|
||||
.attr("ident", id.clone())
|
||||
.child(mattext(&markup::to_html(&o.text)))
|
||||
.child(mattext(&to_html_canvas(&o.text)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let presentation = Node::new("presentation")
|
||||
.child(mattext(&format!(
|
||||
"<div>{}</div>",
|
||||
markup::to_html(&item.stem)
|
||||
to_html_canvas(&item.stem)
|
||||
)))
|
||||
.child(
|
||||
Node::new("response_lid")
|
||||
@@ -398,7 +499,7 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
|
||||
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
|
||||
if opts.include_feedback {
|
||||
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
|
||||
if o.student_text().is_none() {
|
||||
if option_feedback(o, opts).is_none() {
|
||||
continue;
|
||||
}
|
||||
resprocessing = resprocessing.child(
|
||||
@@ -483,13 +584,13 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
|
||||
|
||||
if opts.include_feedback {
|
||||
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
|
||||
if let Some(text) = o.student_text() {
|
||||
if let Some(text) = option_feedback(o, opts) {
|
||||
node = node.child(
|
||||
Node::new("itemfeedback")
|
||||
.attr("ident", format!("{id}_fb"))
|
||||
.child(
|
||||
Node::new("flow_mat")
|
||||
.child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
|
||||
.child(mattext(&format!("<div>{}</div>", to_html_canvas(text)))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -499,6 +600,104 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
|
||||
node
|
||||
}
|
||||
|
||||
/// Builds a Canvas essay item for an open-response question.
|
||||
///
|
||||
/// An essay has no choices and no automatic score: the `<other/>` condition leaves
|
||||
/// grading to the instructor. When feedback is on and a model answer exists, it
|
||||
/// rides along as general feedback so a student sees it after submitting.
|
||||
///
|
||||
/// This mapping has not been round-tripped through a live Canvas import in this
|
||||
/// build, so verify it against your instance before relying on it for a graded
|
||||
/// quiz.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `assessment_id` - salts the generated ids.
|
||||
/// * `uid` - the item's global id.
|
||||
/// * `item` - the item.
|
||||
/// * `points` - points as administered.
|
||||
/// * `opts` - export options.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The element.
|
||||
fn build_essay_item(
|
||||
assessment_id: &str,
|
||||
uid: &str,
|
||||
item: &Item,
|
||||
points: f64,
|
||||
opts: &QtiOptions,
|
||||
) -> Node {
|
||||
let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![
|
||||
metadata_field("question_type", item.format.qti_type()),
|
||||
metadata_field("points_possible", &format!("{points:.2}")),
|
||||
metadata_field("assessment_question_identifierref", &qti_id(uid)),
|
||||
]));
|
||||
|
||||
let presentation = Node::new("presentation")
|
||||
.child(mattext(&format!(
|
||||
"<div>{}</div>",
|
||||
to_html_canvas(&item.stem)
|
||||
)))
|
||||
.child(
|
||||
Node::new("response_str")
|
||||
.attr("ident", "response1")
|
||||
.attr("rcardinality", "Single")
|
||||
.child(
|
||||
Node::new("render_fib").child(
|
||||
Node::new("response_label")
|
||||
.attr("ident", "answer1")
|
||||
.attr("rshuffle", "No"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The model answer, shown as general feedback after submission.
|
||||
let model = item
|
||||
.solution
|
||||
.as_ref()
|
||||
.and_then(|s| s.model_answer.as_deref())
|
||||
.filter(|_| opts.include_feedback);
|
||||
|
||||
let mut condition = Node::new("respcondition")
|
||||
.attr("continue", "No")
|
||||
.child(Node::new("conditionvar").child(Node::new("other")));
|
||||
if model.is_some() {
|
||||
condition = condition.child(
|
||||
Node::new("displayfeedback")
|
||||
.attr("feedbacktype", "Response")
|
||||
.attr("linkrefid", "general_fb"),
|
||||
);
|
||||
}
|
||||
|
||||
let resprocessing = Node::new("resprocessing")
|
||||
.child(
|
||||
Node::new("outcomes").child(
|
||||
Node::new("decvar")
|
||||
.attr("maxvalue", "100")
|
||||
.attr("minvalue", "0")
|
||||
.attr("varname", "SCORE")
|
||||
.attr("vartype", "Decimal"),
|
||||
),
|
||||
)
|
||||
.child(condition);
|
||||
|
||||
let mut node = Node::new("item")
|
||||
.attr("ident", qti_id(&format!("{assessment_id}/{uid}")))
|
||||
.attr("title", item.display_title())
|
||||
.child(item_meta)
|
||||
.child(presentation)
|
||||
.child(resprocessing);
|
||||
|
||||
if let Some(text) = model {
|
||||
node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child(
|
||||
Node::new("flow_mat").child(mattext(&format!("<div>{}</div>", to_html_canvas(text)))),
|
||||
));
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
/// A `<material><mattext texttype="text/html">` pair.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -687,4 +886,194 @@ mod tests {
|
||||
assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s");
|
||||
assert_eq!(slug_filename(""), "quiz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_math_uses_mathjax_delimiters() {
|
||||
// Inline $...$ becomes \( ... \), which Canvas typesets in the text flow.
|
||||
assert_eq!(
|
||||
to_html_canvas("Enthalpy, $\\Delta H$"),
|
||||
"<p>Enthalpy, \\(\\Delta H\\)</p>"
|
||||
);
|
||||
// Display $$...$$ is left as a Canvas block.
|
||||
assert_eq!(to_html_canvas("$$E = mc^2$$"), "<p>$$E = mc^2$$</p>");
|
||||
// A subscript inside math is not read as emphasis.
|
||||
assert_eq!(
|
||||
to_html_canvas("$q_p = \\Delta H$"),
|
||||
"<p>\\(q_p = \\Delta H\\)</p>"
|
||||
);
|
||||
// A `<` inside math is escaped for transport; Canvas decodes it before
|
||||
// MathJax reads it.
|
||||
assert_eq!(to_html_canvas("$a < b$"), "<p>\\(a < b\\)</p>");
|
||||
// Prose with no math is escaped and wrapped, same as `to_html`.
|
||||
assert_eq!(to_html_canvas("a & b"), "<p>a & b</p>");
|
||||
}
|
||||
|
||||
fn distractor() -> crate::item::Choice {
|
||||
crate::item::Choice {
|
||||
id: "B".into(),
|
||||
text: "Internal energy".into(),
|
||||
correct: false,
|
||||
credit: None,
|
||||
explanation: Some("Only at constant volume.".into()),
|
||||
hint: Some("Reconsider what stays constant in an open flask.".into()),
|
||||
misconception: Some("Uses the constant-volume result.".into()),
|
||||
error_type: None,
|
||||
defensible: false,
|
||||
defense: None,
|
||||
feedback_student: None,
|
||||
selection_rate_expected: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_attempt_shows_the_misconception_and_more_show_the_hint() {
|
||||
let choice = distractor();
|
||||
let mut opts = QtiOptions {
|
||||
attempts: 1,
|
||||
..QtiOptions::default()
|
||||
};
|
||||
// student_text() prefers feedback_student, then the misconception.
|
||||
assert_eq!(
|
||||
option_feedback(&choice, &opts),
|
||||
Some("Uses the constant-volume result.")
|
||||
);
|
||||
|
||||
opts.attempts = 3;
|
||||
assert_eq!(
|
||||
option_feedback(&choice, &opts),
|
||||
Some("Reconsider what stays constant in an open flask.")
|
||||
);
|
||||
opts.attempts = -1; // unlimited is also multi-attempt
|
||||
assert!(
|
||||
option_feedback(&choice, &opts)
|
||||
.unwrap()
|
||||
.starts_with("Reconsider")
|
||||
);
|
||||
|
||||
// Multiple attempts with no hint show nothing, so the misconception is
|
||||
// not revealed before the next try.
|
||||
let no_hint = crate::item::Choice {
|
||||
hint: None,
|
||||
..distractor()
|
||||
};
|
||||
assert_eq!(option_feedback(&no_hint, &opts), None);
|
||||
}
|
||||
|
||||
fn catalog(tag: &str) -> Catalog {
|
||||
let dir = std::env::temp_dir().join(format!("cb-qti-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("course.yaml"),
|
||||
r#"
|
||||
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
|
||||
lectures:
|
||||
L1.1: { title: Enthalpy }
|
||||
learning_objectives:
|
||||
lo-enthalpy:
|
||||
text: Define enthalpy.
|
||||
lectures: [L1.1]
|
||||
order: 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("banks").join("b.yaml"),
|
||||
r#"
|
||||
bank: { id: b, title: Bank }
|
||||
items:
|
||||
- id: q-mcq
|
||||
status: draft
|
||||
level: 2
|
||||
format: single_best_answer
|
||||
stem: "The heat at constant pressure equals a change in what?"
|
||||
learning_objectives: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true }
|
||||
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Constant-volume result." }
|
||||
- id: q-open
|
||||
status: draft
|
||||
level: 3
|
||||
format: open_response
|
||||
stem: "Show why $q_p = \\Delta H$."
|
||||
learning_objectives: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Catalog::load(&dir).expect("catalog loads")
|
||||
}
|
||||
|
||||
fn record() -> AssessmentFile {
|
||||
use crate::assessment::{Assessment, Kind, Placement, Platform};
|
||||
AssessmentFile {
|
||||
schema_version: "1.0".into(),
|
||||
assessment: Assessment {
|
||||
id: "a1.1".into(),
|
||||
title: "Homework 1".into(),
|
||||
term: None,
|
||||
kind: Kind::Homework,
|
||||
date: None,
|
||||
platform: Platform::Canvas,
|
||||
minutes_allowed: None,
|
||||
attempts: None,
|
||||
shuffle: None,
|
||||
scoring_policy: None,
|
||||
instructions: None,
|
||||
notes: None,
|
||||
},
|
||||
blueprint: None,
|
||||
forms: Vec::new(),
|
||||
items: vec![
|
||||
Placement {
|
||||
number: 1,
|
||||
item: "b::q-mcq".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "b::q-open".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_response_item_exports_as_a_canvas_essay() {
|
||||
let cat = catalog("essay");
|
||||
let rec = record();
|
||||
let pkg = build(&cat, &rec, &QtiOptions::default())
|
||||
.expect("an essay item does not block the export");
|
||||
// Both questions survive: the choice question and the open-response essay.
|
||||
assert!(
|
||||
pkg.quiz_xml.contains("multiple_choice_question"),
|
||||
"the choice question should be present"
|
||||
);
|
||||
assert!(
|
||||
pkg.quiz_xml.contains("essay_question"),
|
||||
"the open-response item should export as a Canvas essay"
|
||||
);
|
||||
// The essay stem's math is rendered with Canvas delimiters, and the model
|
||||
// answer rides along as feedback.
|
||||
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
|
||||
assert!(pkg.quiz_xml.contains("H = U + PV"));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -98,7 +98,7 @@ pub fn student(
|
||||
));
|
||||
out.push_str(&format!("**{}**\n\n", summary.display_name()));
|
||||
|
||||
// ---------------------------------------------------------------- score
|
||||
// --- score
|
||||
out.push_str(&format!(
|
||||
"You scored **{:.1} of {:.1} points ({:.0}%)**",
|
||||
summary.points, summary.points_possible, summary.percent
|
||||
@@ -144,7 +144,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- objectives
|
||||
// --- objectives
|
||||
if opts.objectives && !summary.objectives.is_empty() {
|
||||
out.push_str("## What this exam says about each learning objective\n\n");
|
||||
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
||||
@@ -180,7 +180,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- levels
|
||||
// --- levels
|
||||
if opts.levels && summary.levels.len() > 1 {
|
||||
out.push_str("## Kinds of thinking\n\n");
|
||||
out.push_str(
|
||||
@@ -231,7 +231,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- what to do
|
||||
// --- what to do
|
||||
if !summary.focus.is_empty() {
|
||||
out.push_str("## Where to put your time\n\n");
|
||||
out.push_str("In this order:\n\n");
|
||||
@@ -274,7 +274,7 @@ pub fn student(
|
||||
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- missed items
|
||||
// --- missed items
|
||||
if opts.missed && !summary.missed.is_empty() {
|
||||
out.push_str("## Question by question\n\n");
|
||||
out.push_str(
|
||||
@@ -356,7 +356,7 @@ pub fn cohort(
|
||||
.unwrap_or_else(|| "date not recorded".into())
|
||||
));
|
||||
|
||||
// ------------------------------------------------------------- summary
|
||||
// --- summary
|
||||
let r = &analysis.reliability;
|
||||
out.push_str("## Summary\n\n");
|
||||
out.push_str(&format!(
|
||||
@@ -408,7 +408,7 @@ pub fn cohort(
|
||||
out.push_str(&format!("> {w}\n\n"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- revise queue
|
||||
// --- revise queue
|
||||
let queue = analysis.revise_queue();
|
||||
out.push_str("## What to revise\n\n");
|
||||
if queue.is_empty() {
|
||||
@@ -477,7 +477,7 @@ pub fn cohort(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- item table
|
||||
// --- item table
|
||||
out.push_str("## Every item\n\n");
|
||||
out.push_str(
|
||||
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
|
||||
@@ -535,7 +535,7 @@ pub fn cohort(
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- class gaps
|
||||
// --- class gaps
|
||||
out.push_str("## Objectives the class did not meet\n\n");
|
||||
if cohort.class_gaps.is_empty() {
|
||||
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
|
||||
@@ -556,7 +556,7 @@ pub fn cohort(
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ level coverage
|
||||
// --- level coverage
|
||||
out.push_str("## Coverage and class performance by level\n\n");
|
||||
let counts = record.level_counts();
|
||||
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
|
||||
@@ -587,7 +587,7 @@ pub fn cohort(
|
||||
let _ = bp;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- archetypes
|
||||
// --- archetypes
|
||||
if !cohort.archetypes.is_empty() {
|
||||
out.push_str("## Patterns across students\n\n");
|
||||
out.push_str(
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering an assessment for a Quarto course website, with solutions gated
|
||||
//! behind a per-page password.
|
||||
//!
|
||||
//! This is the path that publishes to the web rather than to Canvas or a printed
|
||||
//! exam. It produces three things for one assessment:
|
||||
//!
|
||||
//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
|
||||
//! empty, hidden `.qsol` slot per item. A page includes it with
|
||||
//! `{{< include _questions.qmd >}}`. It carries no answers.
|
||||
//! 2. A `<id>-solutions.json` bundle: every solution rendered to an HTML fragment,
|
||||
//! then encrypted. The ciphertext ships in the page, but the plaintext never
|
||||
//! does, so a student cannot read answers from the source or the network tab.
|
||||
//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
|
||||
//! It is printed for the instructor and stored nowhere, so it cannot be
|
||||
//! recovered from the files. Hand it out, and rotate it after a due date.
|
||||
//!
|
||||
//! The browser side is [`assets`]: `questions.css` styles the questions and the
|
||||
//! unlocked solutions, and `solutions.js` derives the key from the typed password,
|
||||
//! decrypts, and injects each fragment. The crypto here matches that script byte
|
||||
//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
|
||||
//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
|
||||
//! appended to the ciphertext. Get any parameter wrong and a correct password
|
||||
//! would fail to authenticate.
|
||||
//!
|
||||
//! Two rules carry over from the other exporters. A question paper never contains
|
||||
//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
|
||||
//! the encrypted bundle. And option order comes from the form's seed, so the
|
||||
//! printed letters in the questions and the letters the solution refers to are the
|
||||
//! same order.
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::{CourseFile, Reference};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::item::{Choice, Citation, Item, Solution};
|
||||
use crate::markup;
|
||||
use crate::select;
|
||||
use crate::taxonomy::Format;
|
||||
|
||||
use aes_gcm::Aes256Gcm;
|
||||
use aes_gcm::aead::generic_array::GenericArray;
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
|
||||
const ITERATIONS: u32 = 250_000;
|
||||
|
||||
/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
|
||||
/// means five bits each, so sixteen of them carry eighty bits of entropy.
|
||||
const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// The bundled browser assets, installed once per site (not per page).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pairs of file name and verbatim contents: the stylesheet and the unlock
|
||||
/// script. Write them into the site's static directory and wire them in
|
||||
/// `_quarto.yml`.
|
||||
pub fn assets() -> [(&'static str, &'static str); 2] {
|
||||
[
|
||||
(
|
||||
"questions.css",
|
||||
include_str!("../assets/site/questions.css"),
|
||||
),
|
||||
("solutions.js", include_str!("../assets/site/solutions.js")),
|
||||
]
|
||||
}
|
||||
|
||||
/// How to render one assessment for the site.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// The form whose option order to print.
|
||||
pub form: Form,
|
||||
/// A password to encrypt with. `None` generates a fresh random one, which is
|
||||
/// the intended path; pass `Some` only to re-encrypt a bundle with a known
|
||||
/// password.
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// Builds options for a form, generating the password.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `form` - the form whose option order to print.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Options that will generate a fresh password at render time.
|
||||
pub fn new(form: Form) -> Options {
|
||||
Options {
|
||||
form,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one render produces, ready to write.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rendered {
|
||||
/// The assessment id, used for the bundle file name and the gate's
|
||||
/// `data-bundle` attribute.
|
||||
pub page: String,
|
||||
/// The `_questions.qmd` partial.
|
||||
pub questions_qmd: String,
|
||||
/// The `<id>-solutions.json` bundle, serialized and newline-terminated.
|
||||
pub solutions_json: String,
|
||||
/// The password that decrypts the bundle. Print it; it is stored nowhere.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Renders an assessment into the questions partial and the encrypted bundle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course, for items, objectives, and references.
|
||||
/// * `record` - the assembled assessment.
|
||||
/// * `opts` - the form and an optional password.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The partial, the serialized bundle, and the password.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
|
||||
/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
|
||||
/// failure.
|
||||
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result<Rendered> {
|
||||
let page = record.assessment.id.clone();
|
||||
let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
|
||||
let fragments = solution_fragments(catalog, record, &opts.form)?;
|
||||
let password = match opts.password {
|
||||
Some(p) => p,
|
||||
None => gen_password()?,
|
||||
};
|
||||
let bundle = build_bundle(&page, &password, &fragments)?;
|
||||
let json = serde_json::to_string_pretty(&bundle)
|
||||
.map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
|
||||
Ok(Rendered {
|
||||
page,
|
||||
questions_qmd,
|
||||
solutions_json: format!("{json}\n"),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the `_questions.qmd` partial.
|
||||
fn questions_qmd(
|
||||
catalog: &Catalog,
|
||||
record: &AssessmentFile,
|
||||
form: &Form,
|
||||
page: &str,
|
||||
) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
out.push_str(
|
||||
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
|
||||
\x20 matter, no title, no prose. Include from the page with:\n\
|
||||
\x20 {{< include _questions.qmd >}} -->\n\n",
|
||||
);
|
||||
out.push_str(&format!(
|
||||
"::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
|
||||
));
|
||||
|
||||
let mut number = 0usize;
|
||||
let default_points = catalog.course.policy.points_per_item;
|
||||
let layout = select::layout(record, form);
|
||||
for placement in layout.iter().filter(|p| !p.dropped) {
|
||||
let item = &catalog.require(&placement.item)?.item;
|
||||
number += 1;
|
||||
out.push_str(&question_block(
|
||||
number,
|
||||
placement,
|
||||
item,
|
||||
form,
|
||||
default_points,
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Leave exactly one trailing newline.
|
||||
while out.ends_with("\n\n") {
|
||||
out.pop();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
|
||||
fn question_block(
|
||||
number: usize,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
default_points: f64,
|
||||
) -> String {
|
||||
let id = &item.id;
|
||||
let points = placement
|
||||
.points
|
||||
.unwrap_or_else(|| item.points(default_points));
|
||||
let unit = if (points - 1.0).abs() < f64::EPSILON {
|
||||
"point"
|
||||
} else {
|
||||
"points"
|
||||
};
|
||||
|
||||
let mut b = String::new();
|
||||
b.push_str(&format!("::: {{.q #{id}}}\n"));
|
||||
b.push_str(":::: {.q-head}\n");
|
||||
b.push_str(&format!(
|
||||
"[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
|
||||
kind_label(item.format),
|
||||
trim_number(points),
|
||||
));
|
||||
b.push_str("::::\n\n");
|
||||
|
||||
b.push_str(":::: {.q-stem}\n");
|
||||
b.push_str(&markup::to_markdown(&item.stem));
|
||||
b.push_str("\n::::\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
b.push_str(":::: {.q-choices}\n");
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
for (position, &source) in order.iter().enumerate() {
|
||||
b.push_str(&format!(
|
||||
"{}. {}\n",
|
||||
position + 1,
|
||||
markup::to_markdown(&item.options[source].text)
|
||||
));
|
||||
}
|
||||
b.push_str("::::\n\n");
|
||||
} else {
|
||||
b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
|
||||
}
|
||||
|
||||
b.push_str(&format!(
|
||||
":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
|
||||
));
|
||||
b.push_str(":::\n");
|
||||
b
|
||||
}
|
||||
|
||||
/// The human label for a format, shown in the question head.
|
||||
fn kind_label(format: Format) -> &'static str {
|
||||
match format {
|
||||
Format::SingleBestAnswer => "Single best answer",
|
||||
Format::MultipleResponse => "Multiple response",
|
||||
Format::TrueFalse => "True or false",
|
||||
Format::OpenResponse => "Open response",
|
||||
}
|
||||
}
|
||||
|
||||
// --- the solution fragments ---
|
||||
|
||||
/// Renders each item's solution to an HTML fragment, in printed order.
|
||||
///
|
||||
/// An item with nothing to show (an open-response question whose solution is
|
||||
/// empty) is left out, so its slot simply never unlocks.
|
||||
fn solution_fragments(
|
||||
catalog: &Catalog,
|
||||
record: &AssessmentFile,
|
||||
form: &Form,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let layout = select::layout(record, form);
|
||||
for placement in layout.iter().filter(|p| !p.dropped) {
|
||||
let item = &catalog.require(&placement.item)?.item;
|
||||
if let Some(html) = fragment(&catalog.course, placement, item, form) {
|
||||
out.push((item.id.clone(), html));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The fragment for one item, or `None` when there is nothing to show.
|
||||
fn fragment(
|
||||
course: &CourseFile,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
) -> Option<String> {
|
||||
if item.has_options() {
|
||||
Some(choice_fragment(course, placement, item, form))
|
||||
} else {
|
||||
open_fragment(course, item)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-best-answer or multiple-response fragment: the key, the model answer,
|
||||
/// the explanation, then per-distractor feedback.
|
||||
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
let printed: Vec<(usize, &Choice)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, &source)| (position, &item.options[source]))
|
||||
.collect();
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
let keyed: Vec<String> = printed
|
||||
.iter()
|
||||
.filter(|(_, c)| c.correct)
|
||||
.map(|(position, c)| {
|
||||
format!(
|
||||
"<b>{}</b> — {}",
|
||||
letter(*position),
|
||||
inline_html(&c.text)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.push_str(
|
||||
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
|
||||
);
|
||||
out.push_str(&keyed.join("; "));
|
||||
out.push_str("</p>\n");
|
||||
|
||||
if let Some(solution) = item.solution.as_ref() {
|
||||
if let Some(model) = &solution.model_answer {
|
||||
out.push_str(&format!(
|
||||
"<div class=\"sol-model\">{}</div>\n",
|
||||
block_html(model)
|
||||
));
|
||||
}
|
||||
if let Some(explanation) = &solution.explanation {
|
||||
out.push_str(&format!(
|
||||
"<p class=\"sol-explain\">{}</p>\n",
|
||||
inline_html(explanation)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let distractors: Vec<(usize, &Choice)> = printed
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(_, c)| !c.correct)
|
||||
.collect();
|
||||
if distractors
|
||||
.iter()
|
||||
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
|
||||
{
|
||||
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
|
||||
out.push_str("<ul class=\"sol-feedback\">\n");
|
||||
for (position, c) in &distractors {
|
||||
if c.misconception.is_none() && why_wrong(c).is_none() {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!(
|
||||
" <li><span class=\"opt\">{}</span>\n",
|
||||
letter(*position)
|
||||
));
|
||||
out.push_str(" <div class=\"opt-body\">\n");
|
||||
if let Some(mis) = &c.misconception {
|
||||
out.push_str(&format!(
|
||||
" <span class=\"opt-mis\">{}</span>\n",
|
||||
inline_html(mis)
|
||||
));
|
||||
}
|
||||
if let Some(why) = why_wrong(c) {
|
||||
out.push_str(&format!(
|
||||
" <span class=\"opt-why\">{}</span>\n",
|
||||
inline_html(why)
|
||||
));
|
||||
}
|
||||
out.push_str(" </div></li>\n");
|
||||
}
|
||||
out.push_str("</ul>\n");
|
||||
}
|
||||
|
||||
push_reference(&mut out, course, item.solution.as_ref());
|
||||
out
|
||||
}
|
||||
|
||||
/// An open-response fragment: the model answer, the rubric, accepted variants.
|
||||
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
|
||||
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
|
||||
let mut out = String::new();
|
||||
|
||||
if let Some(model) = &solution.model_answer {
|
||||
out.push_str(&format!(
|
||||
"<div class=\"sol-model\">{}</div>\n",
|
||||
block_html(model)
|
||||
));
|
||||
}
|
||||
|
||||
if !solution.rubric.is_empty() {
|
||||
let caption = match solution.rubric_points() {
|
||||
Some(total) => {
|
||||
let unit = if (total - 1.0).abs() < f64::EPSILON {
|
||||
"point"
|
||||
} else {
|
||||
"points"
|
||||
};
|
||||
format!("Rubric — {} {unit}", trim_number(total))
|
||||
}
|
||||
None => "Rubric".to_string(),
|
||||
};
|
||||
out.push_str("<table class=\"sol-rubric\">\n");
|
||||
out.push_str(&format!(" <caption>{caption}</caption>\n"));
|
||||
out.push_str(
|
||||
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
|
||||
);
|
||||
out.push_str(" <tbody>\n");
|
||||
for criterion in &solution.rubric {
|
||||
let pts = criterion.points.map(trim_number).unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
" <tr><td>{pts}</td><td>{}</td></tr>\n",
|
||||
inline_html(&criterion.description)
|
||||
));
|
||||
}
|
||||
out.push_str(" </tbody>\n</table>\n");
|
||||
}
|
||||
|
||||
if !solution.accepted.is_empty() {
|
||||
let joined = solution
|
||||
.accepted
|
||||
.iter()
|
||||
.map(|a| inline_html(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
out.push_str(&format!(
|
||||
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\n"
|
||||
));
|
||||
}
|
||||
|
||||
push_reference(&mut out, course, Some(solution));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The student-facing reason a distractor is wrong: the instructor explanation
|
||||
/// first, then any student feedback.
|
||||
fn why_wrong(choice: &Choice) -> Option<&str> {
|
||||
choice
|
||||
.explanation
|
||||
.as_deref()
|
||||
.or(choice.feedback_student.as_deref())
|
||||
}
|
||||
|
||||
/// Appends the `Source:` line when the solution carries review citations.
|
||||
fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
|
||||
let Some(solution) = solution else { return };
|
||||
if solution.review.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sources = solution
|
||||
.review
|
||||
.iter()
|
||||
.map(|c| cite_html(course, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\n"));
|
||||
}
|
||||
|
||||
/// Renders one citation to HTML, linking it when a URL resolves.
|
||||
fn cite_html(course: &CourseFile, citation: &Citation) -> String {
|
||||
if let Some(text) = &citation.text {
|
||||
if citation.reference.is_none() {
|
||||
return markup::escape_html(text);
|
||||
}
|
||||
}
|
||||
let Some(key) = &citation.reference else {
|
||||
return markup::escape_html(&citation.display());
|
||||
};
|
||||
let Some(reference) = course.references.get(key) else {
|
||||
return markup::escape_html(&citation.display());
|
||||
};
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let locator = citation.locator.as_deref().unwrap_or("");
|
||||
let body = if locator.is_empty() {
|
||||
markup::escape_html(label)
|
||||
} else {
|
||||
format!(
|
||||
"{} {}",
|
||||
markup::escape_html(label),
|
||||
markup::escape_html(locator)
|
||||
)
|
||||
};
|
||||
match resolve_url(citation, reference) {
|
||||
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
|
||||
None => body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a citation's link, from an explicit URL or a path joined to the
|
||||
/// reference's base URL.
|
||||
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &citation.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = citation.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- math-aware markup ---
|
||||
|
||||
/// One run of source text, split on math delimiters.
|
||||
enum Segment<'a> {
|
||||
/// Prose, formatted with the shared escape, symbol, and inline rules.
|
||||
Text(&'a str),
|
||||
/// A math span, passed through with its interior escaped for the browser.
|
||||
Math { inner: &'a str, display: bool },
|
||||
}
|
||||
|
||||
/// Splits source into prose and `$…$` or `$$…$$` math runs.
|
||||
///
|
||||
/// The split runs on raw source so a formatting rule can never reach inside math:
|
||||
/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
|
||||
fn split_math(src: &str) -> Vec<Segment<'_>> {
|
||||
let mut segments = Vec::new();
|
||||
let mut rest = src;
|
||||
while let Some(at) = rest.find('$') {
|
||||
if at > 0 {
|
||||
segments.push(Segment::Text(&rest[..at]));
|
||||
}
|
||||
let after = &rest[at..];
|
||||
if let Some(display) = after.strip_prefix("$$") {
|
||||
if let Some(end) = display.find("$$") {
|
||||
segments.push(Segment::Math {
|
||||
inner: &display[..end],
|
||||
display: true,
|
||||
});
|
||||
rest = &display[end + 2..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let inline = &after[1..];
|
||||
match inline.find('$') {
|
||||
Some(end) => {
|
||||
segments.push(Segment::Math {
|
||||
inner: &inline[..end],
|
||||
display: false,
|
||||
});
|
||||
rest = &inline[end + 1..];
|
||||
}
|
||||
None => {
|
||||
// An unterminated `$` is treated as ordinary text.
|
||||
segments.push(Segment::Text(after));
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
segments.push(Segment::Text(rest));
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
|
||||
fn format_prose(text: &str) -> String {
|
||||
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
|
||||
}
|
||||
|
||||
/// Emits a math run with its delimiters, escaping the interior so the browser
|
||||
/// hands MathJax clean text (a `<` inside math becomes `<`, which the DOM
|
||||
/// decodes back before MathJax reads it).
|
||||
fn render_math(inner: &str, display: bool) -> String {
|
||||
let delim = if display { "$$" } else { "$" };
|
||||
format!("{delim}{}{delim}", markup::escape_html(inner))
|
||||
}
|
||||
|
||||
/// Converts authoring markup to an inline HTML string, preserving math.
|
||||
///
|
||||
/// No paragraph wrapping: the caller supplies the surrounding element.
|
||||
fn inline_html(src: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for segment in split_math(src.trim()) {
|
||||
match segment {
|
||||
Segment::Text(text) => out.push_str(&format_prose(text)),
|
||||
Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `<p>`.
|
||||
fn block_html(src: &str) -> String {
|
||||
src.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| format!("<p>{}</p>", inline_html(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
// --- the encrypted bundle -----
|
||||
|
||||
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Bundle {
|
||||
v: u8,
|
||||
page: String,
|
||||
kdf: Kdf,
|
||||
cipher: &'static str,
|
||||
items: OrderedItems,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Kdf {
|
||||
name: &'static str,
|
||||
hash: &'static str,
|
||||
iterations: u32,
|
||||
salt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Enc {
|
||||
iv: String,
|
||||
ct: String,
|
||||
}
|
||||
|
||||
/// Item entries serialized as a JSON object in insertion order, so the bundle
|
||||
/// lists solutions in the order the questions appear rather than sorted by id.
|
||||
#[derive(Debug, Clone)]
|
||||
struct OrderedItems(Vec<(String, Enc)>);
|
||||
|
||||
impl Serialize for OrderedItems {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
||||
for (id, enc) in &self.0 {
|
||||
map.serialize_entry(id, enc)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a random per-bundle password.
|
||||
///
|
||||
/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
|
||||
/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The password, to print for the instructor.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
|
||||
pub fn gen_password() -> Result<String> {
|
||||
let mut raw = [0u8; 16];
|
||||
csprng(&mut raw)?;
|
||||
// Thirty-two divides 256, so the modulo is unbiased.
|
||||
let symbols: Vec<u8> = raw
|
||||
.iter()
|
||||
.map(|b| PW_ALPHABET[(*b % 32) as usize])
|
||||
.collect();
|
||||
let groups: Vec<String> = symbols
|
||||
.chunks(4)
|
||||
.map(|c| String::from_utf8_lossy(c).into_owned())
|
||||
.collect();
|
||||
Ok(groups.join("-"))
|
||||
}
|
||||
|
||||
/// Encrypts rendered fragments into a bundle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `page` - the assessment id, stored as `page` and echoed by the script.
|
||||
/// * `password` - the password to derive the key from.
|
||||
/// * `fragments` - item id and solution HTML, in the order to list them.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bundle, ready to serialize as `<page>-solutions.json`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
|
||||
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
|
||||
let mut salt = [0u8; 16];
|
||||
csprng(&mut salt)?;
|
||||
let key = derive_key(password, &salt);
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|_| Error::other("the derived AES key was the wrong length"))?;
|
||||
|
||||
let mut items = Vec::with_capacity(fragments.len());
|
||||
for (id, html) in fragments {
|
||||
let mut iv = [0u8; 12];
|
||||
csprng(&mut iv)?;
|
||||
let nonce = GenericArray::from_slice(&iv);
|
||||
let ct = cipher
|
||||
.encrypt(nonce, html.as_bytes())
|
||||
.map_err(|_| Error::other("AES-GCM encryption failed"))?;
|
||||
items.push((
|
||||
id.clone(),
|
||||
Enc {
|
||||
iv: B64.encode(iv),
|
||||
ct: B64.encode(ct),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Bundle {
|
||||
v: 1,
|
||||
page: page.to_string(),
|
||||
kdf: Kdf {
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
iterations: ITERATIONS,
|
||||
salt: B64.encode(salt),
|
||||
},
|
||||
cipher: "AES-GCM",
|
||||
items: OrderedItems(items),
|
||||
})
|
||||
}
|
||||
|
||||
/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
|
||||
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, ITERATIONS, &mut key);
|
||||
key
|
||||
}
|
||||
|
||||
/// Fills a buffer with cryptographically secure random bytes.
|
||||
fn csprng(buf: &mut [u8]) -> Result<()> {
|
||||
getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
|
||||
}
|
||||
|
||||
// --- small helpers -------
|
||||
|
||||
/// Formats a point value: no decimal when whole, at most two places otherwise.
|
||||
fn trim_number(value: f64) -> String {
|
||||
if value.fract() == 0.0 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
let s = format!("{value:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The printed letter for a position: 0 is A, 1 is B, and so on.
|
||||
fn letter(position: usize) -> char {
|
||||
char::from(b'A' + (position % 26) as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn form() -> Form {
|
||||
Form {
|
||||
id: "A".into(),
|
||||
seed: 0,
|
||||
shuffle_items: false,
|
||||
shuffle_options: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog(tag: &str) -> Catalog {
|
||||
let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("course.yaml"),
|
||||
r#"
|
||||
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
|
||||
references:
|
||||
kkw:
|
||||
label: KKW
|
||||
title: The molecules of life
|
||||
base_url: https://example.org/kkw/
|
||||
lectures:
|
||||
L1.1: { title: Enthalpy }
|
||||
learning_objectives:
|
||||
lo-enthalpy:
|
||||
text: Define enthalpy and explain the constant-pressure result.
|
||||
lectures: [L1.1]
|
||||
order: 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("banks").join("b.yaml"),
|
||||
r#"
|
||||
bank: { id: b, title: Bank }
|
||||
items:
|
||||
- id: q-mcq
|
||||
status: draft
|
||||
level: 2
|
||||
format: single_best_answer
|
||||
stem: "The heat at constant pressure equals a change in what?"
|
||||
learning_objectives: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
|
||||
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
|
||||
solution:
|
||||
model_answer: "The change in enthalpy, $\\Delta H$."
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.3" }
|
||||
- id: q-open
|
||||
status: draft
|
||||
level: 3
|
||||
format: open_response
|
||||
stem: "Show why $q_p = \\Delta H$."
|
||||
learning_objectives: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
||||
rubric:
|
||||
- { description: "States $H = U + PV$.", points: 1 }
|
||||
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
|
||||
accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Catalog::load(&dir).expect("catalog loads")
|
||||
}
|
||||
|
||||
fn record() -> AssessmentFile {
|
||||
use crate::assessment::{Assessment, Kind, Platform};
|
||||
AssessmentFile {
|
||||
schema_version: "1.0".into(),
|
||||
assessment: Assessment {
|
||||
id: "a1.1".into(),
|
||||
title: "Homework 1".into(),
|
||||
term: None,
|
||||
kind: Kind::Homework,
|
||||
date: None,
|
||||
platform: Platform::Other,
|
||||
minutes_allowed: None,
|
||||
attempts: None,
|
||||
shuffle: None,
|
||||
scoring_policy: None,
|
||||
instructions: None,
|
||||
notes: None,
|
||||
},
|
||||
blueprint: None,
|
||||
forms: Vec::new(),
|
||||
items: vec![
|
||||
Placement {
|
||||
number: 1,
|
||||
item: "b::q-mcq".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "b::q-open".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn questions_partial_has_no_front_matter_and_no_answers() {
|
||||
let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
|
||||
assert!(!out.starts_with("---"), "a partial carries no front matter");
|
||||
assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
|
||||
assert!(out.contains("::: {.q #q-mcq}"));
|
||||
assert!(
|
||||
out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
|
||||
);
|
||||
// Choices are printed without letters; the correct flag never appears.
|
||||
assert!(out.contains("1. Enthalpy, $\\Delta H$"));
|
||||
assert!(!out.contains("correct"));
|
||||
// The open-response question gets a writing box, both get an empty slot.
|
||||
assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
|
||||
assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
|
||||
assert!(out.contains("[2 points]{.q-points}"));
|
||||
// No model answer leaks into the questions.
|
||||
assert!(!out.contains("sol-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
|
||||
let cat = catalog("choice");
|
||||
let rec = record();
|
||||
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
||||
let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
|
||||
assert!(mcq.contains("<span class=\"badge badge-correct\">Correct answer</span>"));
|
||||
assert!(mcq.contains("<b>A</b> — Enthalpy, $\\Delta H$"));
|
||||
assert!(mcq.contains(
|
||||
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
|
||||
));
|
||||
assert!(mcq.contains("<span class=\"opt\">B</span>"));
|
||||
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
|
||||
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
|
||||
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_fragment_has_a_rubric_table_with_a_total() {
|
||||
let cat = catalog("open");
|
||||
let rec = record();
|
||||
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
||||
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
||||
assert!(open.contains("<caption>Rubric — 2 points</caption>"));
|
||||
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
|
||||
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
|
||||
// A subscript inside math survives; angle brackets outside math are escaped.
|
||||
assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ < 5");
|
||||
// Bold outside math becomes a tag; a dollar-math run is passed through.
|
||||
assert_eq!(
|
||||
inline_html("**H** is $H = U + PV$"),
|
||||
"<strong>H</strong> is $H = U + PV$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundle_round_trips_through_the_kdf_and_cipher() {
|
||||
use aes_gcm::aead::generic_array::GenericArray;
|
||||
let fragments = vec![
|
||||
("q-mcq".to_string(), "<p>alpha</p>".to_string()),
|
||||
("q-open".to_string(), "<p>beta</p>".to_string()),
|
||||
];
|
||||
let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
|
||||
assert_eq!(bundle.v, 1);
|
||||
assert_eq!(bundle.page, "a1.1");
|
||||
assert_eq!(bundle.cipher, "AES-GCM");
|
||||
assert_eq!(bundle.kdf.iterations, ITERATIONS);
|
||||
// Insertion order is preserved in the serialized object.
|
||||
let json = serde_json::to_string(&bundle).unwrap();
|
||||
assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
|
||||
|
||||
// Decrypt the first item the way the browser would and check the plaintext.
|
||||
let salt = B64.decode(&bundle.kdf.salt).unwrap();
|
||||
let key = derive_key("enthalpy2026", &salt);
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let (_, enc) = &bundle.items.0[0];
|
||||
let iv = B64.decode(&enc.iv).unwrap();
|
||||
let ct = B64.decode(&enc.ct).unwrap();
|
||||
let pt = cipher
|
||||
.decrypt(GenericArray::from_slice(&iv), ct.as_ref())
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(pt).unwrap(), "<p>alpha</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generated_password_has_the_expected_shape() {
|
||||
let pw = gen_password().unwrap();
|
||||
assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
|
||||
assert_eq!(pw.matches('-').count(), 3);
|
||||
assert!(
|
||||
pw.chars()
|
||||
.all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_browser_assets_are_bundled() {
|
||||
let assets = assets();
|
||||
assert_eq!(assets[0].0, "questions.css");
|
||||
assert_eq!(assets[1].0, "solutions.js");
|
||||
assert!(assets[0].1.contains(".qsol"));
|
||||
assert!(assets[1].1.contains("AES-GCM"));
|
||||
}
|
||||
}
|
||||
@@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option<BTreeMap<String, f64>> {
|
||||
|
||||
/// The token for a response format.
|
||||
fn format_token(format: Format) -> &'static str {
|
||||
match format {
|
||||
Format::SingleBestAnswer => "single_best_answer",
|
||||
Format::MultipleResponse => "multiple_response",
|
||||
Format::TrueFalse => "true_false",
|
||||
}
|
||||
format.as_str()
|
||||
}
|
||||
|
||||
/// The token for an administration platform.
|
||||
|
||||
+7
-1
@@ -21,7 +21,9 @@
|
||||
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
|
||||
//! ingest, analyze, report.
|
||||
//! 4. [`typst_export`] covers printed output, template markers, and render config.
|
||||
//! 5. [`recipes`] holds short answers to specific questions, for when you already
|
||||
//! 5. [`assignment`] publishes a homework to the course website, with solutions
|
||||
//! gated behind a password.
|
||||
//! 6. [`recipes`] holds short answers to specific questions, for when you already
|
||||
//! know the shape of the tool.
|
||||
//!
|
||||
//! ## Why the tutorials are in here rather than a wiki
|
||||
@@ -55,6 +57,10 @@ pub mod first_exam {}
|
||||
#[doc = include_str!("../docs/TYPST.md")]
|
||||
pub mod typst_export {}
|
||||
|
||||
/// Publishing an assignment to the website, with solutions gated behind a password.
|
||||
#[doc = include_str!("../docs/guide/assignment.md")]
|
||||
pub mod assignment {}
|
||||
|
||||
/// Short answers to specific questions.
|
||||
#[doc = include_str!("../docs/guide/recipes.md")]
|
||||
pub mod recipes {}
|
||||
|
||||
+2
-2
@@ -104,13 +104,13 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono
|
||||
|
||||
pub use authoring::{jsonschema, lint, select};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
pub use data::store_parquet;
|
||||
pub use data::{canvas, gradescope, responses, store};
|
||||
|
||||
pub use analysis::{calibrate, classical, irt, students};
|
||||
|
||||
pub use export::{qti, report, typst};
|
||||
pub use export::site;
|
||||
pub use export::{lecture, practice, qti, report, typst};
|
||||
|
||||
pub use catalog::Catalog;
|
||||
pub use course::{CourseFile, SCHEMA_VERSION};
|
||||
|
||||
+96
-11
@@ -352,10 +352,20 @@ fn validate_item(
|
||||
issues.push("version must be at least 1".into());
|
||||
}
|
||||
|
||||
// --- options -----------------------------------------------------------
|
||||
if it.options.len() < 2 {
|
||||
// --- options ----
|
||||
// An open-response item takes no options; its answer lives in `solution`.
|
||||
// Every other format needs at least two things to choose between.
|
||||
if it.format.has_options() {
|
||||
if it.options.len() < 2 {
|
||||
issues.push(format!(
|
||||
"needs at least 2 options, has {}",
|
||||
it.options.len()
|
||||
));
|
||||
}
|
||||
} else if !it.options.is_empty() {
|
||||
issues.push(format!(
|
||||
"needs at least 2 options, has {}",
|
||||
"{} items take no options, but {} were given; put the answer in `solution`",
|
||||
it.format.as_str(),
|
||||
it.options.len()
|
||||
));
|
||||
}
|
||||
@@ -418,7 +428,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- key ---------------------------------------------------------------
|
||||
// --- key ---
|
||||
let keys = it.key_indices();
|
||||
match it.format {
|
||||
Format::SingleBestAnswer => {
|
||||
@@ -448,9 +458,14 @@ fn validate_item(
|
||||
issues.push("true_false needs exactly one keyed option".into());
|
||||
}
|
||||
}
|
||||
Format::OpenResponse => {
|
||||
if !keys.is_empty() {
|
||||
issues.push("open_response items have no keyed option".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- level and process must agree -------------------------------------
|
||||
// --- level and process must agree -------
|
||||
if let Some(p) = it.cognitive_process {
|
||||
if !it.level.allows(p) {
|
||||
issues.push(format!(
|
||||
@@ -461,7 +476,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- design plausibility ----------------------------------------------
|
||||
// --- design plausibility ------
|
||||
if let Some(d) = &it.design {
|
||||
if let Some(x) = d.expected_difficulty {
|
||||
if !(0.0..=1.0).contains(&x) {
|
||||
@@ -479,7 +494,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- calibration plausibility -----------------------------------------
|
||||
// --- calibration plausibility ------
|
||||
if let Some(c) = &it.calibration {
|
||||
if let Some(p) = c.p_value {
|
||||
if !(0.0..=1.0).contains(&p) {
|
||||
@@ -514,7 +529,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- history must be coherent -----------------------------------------
|
||||
// --- history must be coherent ------
|
||||
let mut last_version = 0u32;
|
||||
for (i, h) in it.history.iter().enumerate() {
|
||||
if h.version <= last_version {
|
||||
@@ -533,7 +548,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- retirement -------------------------------------------------------
|
||||
// --- retirement -----
|
||||
if it.retired.is_some() && it.status != Status::Retired {
|
||||
issues.push(format!(
|
||||
"has a `retired` block but status is `{}`",
|
||||
@@ -541,7 +556,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- approval gate ----------------------------------------------------
|
||||
// --- approval gate -------
|
||||
// Approval is what permits an item onto a graded assessment, so it is the
|
||||
// right place to require that the item is fully sourced and designed.
|
||||
if it.status == Status::Approved {
|
||||
@@ -557,9 +572,23 @@ fn validate_item(
|
||||
if it.design.is_none() {
|
||||
issues.push("approved items must carry a design block".into());
|
||||
}
|
||||
// An open-response item is graded from its solution, so approving one with
|
||||
// neither a model answer nor a rubric would leave nothing to mark it by.
|
||||
if !it.format.has_options() {
|
||||
let gradeable = it
|
||||
.solution
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty());
|
||||
if !gradeable {
|
||||
issues.push(
|
||||
"approved open_response items need a solution with a model_answer or a rubric"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- cross-file references --------------------------------------------
|
||||
// --- cross-file references ----
|
||||
if let Some(c) = course {
|
||||
for lo in &it.learning_objectives {
|
||||
match c.learning_objectives.get(lo) {
|
||||
@@ -592,6 +621,15 @@ fn validate_item(
|
||||
issues.push(format!("unknown stimulus `{st}`"));
|
||||
}
|
||||
}
|
||||
// A citation that names a reference key must name a real one, so a review
|
||||
// pointer in the solutions document never resolves to nothing.
|
||||
for citation in it.solution.iter().flat_map(|s| &s.review) {
|
||||
if let Some(key) = &citation.reference {
|
||||
if !c.references.contains_key(key) {
|
||||
issues.push(format!("solution.review cites unknown reference `{key}`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(floor) = c.policy.partial_credit_floor_level {
|
||||
for o in &it.options {
|
||||
if o.is_partial() && it.level < floor {
|
||||
@@ -644,6 +682,53 @@ mod tests {
|
||||
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_response_validates_without_options_and_rejects_them() {
|
||||
// No options is fine, and no key is required.
|
||||
let ok = bank(
|
||||
r#"
|
||||
- id: q-a-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain the first law.
|
||||
solution:
|
||||
model_answer: Energy is conserved.
|
||||
"#,
|
||||
);
|
||||
assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None));
|
||||
|
||||
// Giving an open-response item options is the mistake, and so is approving
|
||||
// one with nothing to grade it by.
|
||||
let bad = bank(
|
||||
r#"
|
||||
- id: q-a-op-002
|
||||
status: approved
|
||||
level: 2
|
||||
format: open_response
|
||||
cognitive_process: explain
|
||||
learning_objectives: [lo-x]
|
||||
sources: [{ lecture: L1.1 }]
|
||||
design: { rationale: r }
|
||||
stem: Explain the first law.
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
- { id: B, text: b }
|
||||
"#,
|
||||
);
|
||||
let issues = bad.validate(None);
|
||||
assert!(
|
||||
issues.iter().any(|i| i.contains("take no options")),
|
||||
"{issues:?}"
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("model_answer or a rubric")),
|
||||
"{issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catches_missing_and_multiple_keys() {
|
||||
let b = bank(
|
||||
|
||||
+766
-5
@@ -16,9 +16,12 @@
|
||||
//! belongs to the course and the administration, never to the item.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -61,6 +64,12 @@ pub struct CourseFile {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub learning_objectives: BTreeMap<String, Objective>,
|
||||
|
||||
/// Works the course cites, keyed by citation key such as
|
||||
/// `kuriyan2013molecules`. Readings point in here rather than restating a
|
||||
/// citation, so a reference is written once and a changed edition is one edit.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub references: BTreeMap<String, Reference>,
|
||||
|
||||
/// Shared stimuli for case-based testlets, keyed by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub stimuli: BTreeMap<String, Stimulus>,
|
||||
@@ -172,9 +181,318 @@ pub struct Lecture {
|
||||
/// Where the slides live, for study guidance in student reports.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slides_url: Option<String>,
|
||||
/// Assigned readings for the session.
|
||||
/// Assigned readings for the session, in the order you assign them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub readings: Vec<String>,
|
||||
pub readings: Vec<Reading>,
|
||||
}
|
||||
|
||||
/// A work the course cites: a textbook, an article, a dataset, a recording.
|
||||
///
|
||||
/// Keyed by citation key, so this registry is a bibliography rather than a second
|
||||
/// naming scheme. The field names follow BibTeX where BibTeX has one, which makes
|
||||
/// import and export mechanical.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reference {
|
||||
/// The short form a reading list shows, such as `KKW`. Unique across the
|
||||
/// registry, because reports print it in place of the key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// What kind of work this is, which decides how a citation renders.
|
||||
#[serde(default)]
|
||||
pub kind: ReferenceKind,
|
||||
/// Whether the course requires it or lists it as background.
|
||||
#[serde(default)]
|
||||
pub role: ReferenceRole,
|
||||
/// Full title.
|
||||
pub title: String,
|
||||
/// Authors as `Family, Given`, in the order printed on the work.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub authors: Vec<String>,
|
||||
/// Year of publication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
/// Edition as printed: `7th`, `Revised`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub edition: Option<String>,
|
||||
/// Publisher, for a book.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publisher: Option<String>,
|
||||
/// The journal, edited volume, or series this sits inside.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container: Option<String>,
|
||||
/// Volume within the container.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<String>,
|
||||
/// Issue within the volume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub issue: Option<String>,
|
||||
/// Page range of the work as a whole, not of any one reading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pages: Option<String>,
|
||||
/// DOI, bare: `10.1038/nature12373`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub doi: Option<String>,
|
||||
/// ISBN, for a book.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub isbn: Option<String>,
|
||||
/// Canonical URL for the work as a whole.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Prefix a reading's `path` is appended to. Having this means the citation
|
||||
/// key appears once rather than once per reading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// Anything students need to know about getting hold of it: reserve shelf,
|
||||
/// license, paywall.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// The kind of work, chosen to map onto BibTeX entry types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReferenceKind {
|
||||
/// A whole book.
|
||||
#[default]
|
||||
Book,
|
||||
/// A chapter in an edited volume.
|
||||
Chapter,
|
||||
/// A journal article.
|
||||
Article,
|
||||
/// A preprint, which is an article without a container.
|
||||
Preprint,
|
||||
/// A thesis or dissertation.
|
||||
Thesis,
|
||||
/// A page or resource that exists only online.
|
||||
Website,
|
||||
/// A program or library.
|
||||
Software,
|
||||
/// A published dataset.
|
||||
Dataset,
|
||||
/// A recording.
|
||||
Video,
|
||||
/// Anything else.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Whether the course requires a work or offers it as background.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReferenceRole {
|
||||
/// A course text. Assigned readings come from it.
|
||||
Required,
|
||||
/// Listed so students know it exists. Never assigned.
|
||||
#[default]
|
||||
Supplemental,
|
||||
}
|
||||
|
||||
/// One assigned location inside a [`Reference`], and what it is assigned for.
|
||||
///
|
||||
/// The prose splits three ways because each part answers a different question and
|
||||
/// each has a different consumer. `summary` says what the section contains, `focus`
|
||||
/// says what to take from it, and `skip` says what to ignore. A student report
|
||||
/// quotes `focus` at somebody who missed the objective; a lecture page prints all
|
||||
/// three.
|
||||
///
|
||||
/// A reading written as a bare string, which is what this field held before the
|
||||
/// schema existed, still parses: the whole string lands in `text`, and serializing
|
||||
/// writes it back out as a string rather than a mapping.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Reading {
|
||||
/// Citation key into [`CourseFile::references`].
|
||||
pub reference: Option<String>,
|
||||
/// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`.
|
||||
pub locator: Option<String>,
|
||||
/// Appended to the reference's `base_url` to reach this location.
|
||||
pub path: Option<String>,
|
||||
/// A full URL, for a location that is not under the reference's `base_url`.
|
||||
pub url: Option<String>,
|
||||
/// Whether it is assigned or offered alongside.
|
||||
pub role: ReadingRole,
|
||||
/// The objectives this reading serves.
|
||||
pub objectives: Vec<String>,
|
||||
/// What the section contains.
|
||||
pub summary: Option<String>,
|
||||
/// What to take from it, which is the sentence a study suggestion quotes.
|
||||
pub focus: Option<String>,
|
||||
/// What to gloss, and why it is out of scope.
|
||||
pub skip: Option<String>,
|
||||
/// A reading written as a bare string before this schema existed, held
|
||||
/// unparsed.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether a reading is assigned or offered alongside.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReadingRole {
|
||||
/// Assigned, and therefore fair to assess.
|
||||
#[default]
|
||||
Assigned,
|
||||
/// Offered as background. Not separately assessed.
|
||||
Supplemental,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
/// The URL for this location.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reference` - the work this reading is inside.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `url` when given, otherwise the reference's `base_url` joined with `path`,
|
||||
/// otherwise `None`.
|
||||
pub fn resolve_url(&self, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &self.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = self.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A short citation for a report: `KKW §6.1`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the citation key, used when the reference declares no label.
|
||||
/// * `reference` - the work, for its label.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The label and locator, or the unparsed `text` for a legacy reading.
|
||||
pub fn cite(&self, key: &str, reference: &Reference) -> String {
|
||||
if let Some(text) = &self.text {
|
||||
return text.clone();
|
||||
}
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
match &self.locator {
|
||||
Some(locator) => format!("{label} {locator}"),
|
||||
None => label.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a reading as a mapping, or as a bare string when that is all it holds.
|
||||
///
|
||||
/// The string case keeps a course file that predates this schema byte-identical
|
||||
/// through a load-and-save cycle, so migrating is something you choose rather than
|
||||
/// something the tool does to your file the first time it writes it.
|
||||
impl Serialize for Reading {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
if let Some(text) = &self.text {
|
||||
if self.reference.is_none() && self.locator.is_none() && self.objectives.is_empty() {
|
||||
return s.serialize_str(text);
|
||||
}
|
||||
}
|
||||
let mut map = s.serialize_map(None)?;
|
||||
if let Some(v) = &self.reference {
|
||||
map.serialize_entry("ref", v)?;
|
||||
}
|
||||
if let Some(v) = &self.locator {
|
||||
map.serialize_entry("locator", v)?;
|
||||
}
|
||||
if let Some(v) = &self.path {
|
||||
map.serialize_entry("path", v)?;
|
||||
}
|
||||
if let Some(v) = &self.url {
|
||||
map.serialize_entry("url", v)?;
|
||||
}
|
||||
if self.role != ReadingRole::Assigned {
|
||||
map.serialize_entry("role", &self.role)?;
|
||||
}
|
||||
if !self.objectives.is_empty() {
|
||||
map.serialize_entry("objectives", &self.objectives)?;
|
||||
}
|
||||
if let Some(v) = &self.summary {
|
||||
map.serialize_entry("summary", v)?;
|
||||
}
|
||||
if let Some(v) = &self.focus {
|
||||
map.serialize_entry("focus", v)?;
|
||||
}
|
||||
if let Some(v) = &self.skip {
|
||||
map.serialize_entry("skip", v)?;
|
||||
}
|
||||
if let Some(v) = &self.text {
|
||||
map.serialize_entry("text", v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a reading written either as a mapping or as a bare string.
|
||||
///
|
||||
/// The string form is what `readings` held before this schema, so course files
|
||||
/// written against the old shape keep loading. It is the same courtesy
|
||||
/// [`yaml::flexible_string`] extends to an unquoted `schema_version: 1.0`.
|
||||
impl<'de> Deserialize<'de> for Reading {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Reading, D::Error> {
|
||||
/// The mapping form, with the field set kept in one place.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Mapping {
|
||||
#[serde(rename = "ref", default)]
|
||||
reference: Option<String>,
|
||||
#[serde(default)]
|
||||
locator: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
role: ReadingRole,
|
||||
#[serde(default)]
|
||||
objectives: Vec<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
focus: Option<String>,
|
||||
#[serde(default)]
|
||||
skip: Option<String>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
struct V;
|
||||
impl<'a> Visitor<'a> for V {
|
||||
type Value = Reading;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a reading mapping with a `ref`, or a plain citation string")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Reading, E> {
|
||||
Ok(Reading {
|
||||
text: Some(v.to_string()),
|
||||
..Reading::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'a>>(self, map: M) -> std::result::Result<Reading, M::Error> {
|
||||
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
|
||||
Ok(Reading {
|
||||
reference: m.reference,
|
||||
locator: m.locator,
|
||||
path: m.path,
|
||||
url: m.url,
|
||||
role: m.role,
|
||||
objectives: m.objectives,
|
||||
summary: m.summary,
|
||||
focus: m.focus,
|
||||
skip: m.skip,
|
||||
text: m.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
/// A learning objective.
|
||||
@@ -190,6 +508,15 @@ pub struct Objective {
|
||||
/// The lectures that develop it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub lectures: Vec<String>,
|
||||
/// Position in teaching order, low first.
|
||||
///
|
||||
/// The registry is a map, so declaration order is lost on load, and sorting by
|
||||
/// id would put `lo-enthalpy` before `lo-first-law` when the second is a
|
||||
/// prerequisite of the first. Anything that prints objectives in the order you
|
||||
/// teach them, a lecture page above all, needs this. Objectives without it sort
|
||||
/// last, by id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
/// The highest level you intend to assess this objective at. Assembling an
|
||||
/// item above the ceiling is a warning: either the item overreaches or the
|
||||
/// ceiling needs raising.
|
||||
@@ -306,6 +633,25 @@ impl CourseFile {
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (key, reference) in &self.references {
|
||||
if reference.title.trim().is_empty() {
|
||||
issues.push(format!("reference `{key}`: empty title"));
|
||||
}
|
||||
if let Some(label) = &reference.label {
|
||||
labels.entry(label.as_str()).or_default().push(key);
|
||||
}
|
||||
}
|
||||
for (label, keys) in &labels {
|
||||
if keys.len() > 1 {
|
||||
issues.push(format!(
|
||||
"references: `{label}` is the label of {}; a label has to name one work \
|
||||
because reports print it instead of the key",
|
||||
keys.join(" and ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (id, lec) in &self.lectures {
|
||||
if lec.title.trim().is_empty() {
|
||||
issues.push(format!("lecture `{id}`: empty title"));
|
||||
@@ -315,6 +661,10 @@ impl CourseFile {
|
||||
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
|
||||
}
|
||||
}
|
||||
let mut seen: Vec<(&str, &str)> = Vec::new();
|
||||
for (index, reading) in lec.readings.iter().enumerate() {
|
||||
issues.extend(self.reading_issues(id, index, reading, &mut seen));
|
||||
}
|
||||
}
|
||||
|
||||
for (id, lo) in &self.learning_objectives {
|
||||
@@ -347,6 +697,66 @@ impl CourseFile {
|
||||
issues
|
||||
}
|
||||
|
||||
/// Checks one reading, collecting every problem with it.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `lecture` - the lecture id, for the message.
|
||||
/// * `index` - position in the lecture's list, since a reading has no id.
|
||||
/// * `reading` - the reading.
|
||||
/// * `seen` - reference and locator pairs already found in this lecture,
|
||||
/// extended as it goes.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per problem.
|
||||
fn reading_issues<'a>(
|
||||
&self,
|
||||
lecture: &str,
|
||||
index: usize,
|
||||
reading: &'a Reading,
|
||||
seen: &mut Vec<(&'a str, &'a str)>,
|
||||
) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
let at = format!("lecture `{lecture}` reading {}", index + 1);
|
||||
|
||||
let Some(key) = reading.reference.as_deref() else {
|
||||
if reading.text.is_none() {
|
||||
issues.push(format!(
|
||||
"{at}: needs a `ref` naming a reference, or a plain citation string"
|
||||
));
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
match self.references.get(key) {
|
||||
None => issues.push(format!("{at}: unknown reference `{key}`")),
|
||||
Some(reference) => {
|
||||
if reading.path.is_some() && reference.base_url.is_none() && reading.url.is_none() {
|
||||
issues.push(format!(
|
||||
"{at}: has a `path` but reference `{key}` has no `base_url` to join it to"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(locator) = reading.locator.as_deref() {
|
||||
if seen.contains(&(key, locator)) {
|
||||
issues.push(format!(
|
||||
"{at}: `{key} {locator}` is assigned twice in one lecture"
|
||||
));
|
||||
}
|
||||
seen.push((key, locator));
|
||||
}
|
||||
|
||||
for objective in &reading.objectives {
|
||||
if !self.learning_objectives.contains_key(objective) {
|
||||
issues.push(format!("{at}: unknown learning objective `{objective}`"));
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Detects cycles in the objective prerequisite graph.
|
||||
///
|
||||
/// A cycle would make a study-order suggestion loop forever, so it is worth
|
||||
@@ -470,7 +880,8 @@ impl CourseFile {
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Objectives in a stable teaching order: by unit as declared, then by id.
|
||||
/// Objectives in a stable teaching order: by unit as declared, then by
|
||||
/// [`Objective::order`], then by id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
@@ -490,11 +901,148 @@ impl CourseFile {
|
||||
.as_deref()
|
||||
.and_then(|u| unit_rank.get(u).copied())
|
||||
.unwrap_or(usize::MAX);
|
||||
(rank, (*id).clone())
|
||||
(rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
|
||||
});
|
||||
ids.into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// The objectives a lecture covers, in teaching order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `lecture` - the lecture id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Objective ids whose `lectures` list names this lecture, ordered by
|
||||
/// [`Objective::order`] and then by id.
|
||||
pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> {
|
||||
let mut ids: Vec<&String> = self
|
||||
.learning_objectives
|
||||
.iter()
|
||||
.filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture))
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
ids.sort_by_key(|id| {
|
||||
let lo = &self.learning_objectives[*id];
|
||||
(lo.order.unwrap_or(u32::MAX), (*id).clone())
|
||||
});
|
||||
ids.into_iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
/// Every reading that serves an objective, with the lecture it was assigned in.
|
||||
///
|
||||
/// Derived by scanning lectures rather than stored on the objective, for the
|
||||
/// same reason [`crate::history::History`] derives usage from assessment
|
||||
/// records: a second copy of an edge is a second thing to keep in step. It also
|
||||
/// puts the pointer on the volatile side, since a new edition renumbers
|
||||
/// sections but leaves your objectives alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `objective` - the objective id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pairs of lecture id and reading, in lecture order then assignment order.
|
||||
pub fn readings_for_objective(&self, objective: &str) -> Vec<(&str, &Reading)> {
|
||||
let mut out = Vec::new();
|
||||
for (lecture_id, lecture) in &self.lectures {
|
||||
for reading in &lecture.readings {
|
||||
if reading.objectives.iter().any(|o| o == objective) {
|
||||
out.push((lecture_id.as_str(), reading));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Assessed objectives with no reading behind them.
|
||||
///
|
||||
/// These are the objectives a student report cannot advise on: it can say the
|
||||
/// objective was missed, but not where to go and read about it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Objective ids in teaching order.
|
||||
pub fn objectives_without_readings(&self) -> Vec<&str> {
|
||||
let cited: std::collections::BTreeSet<&str> = self
|
||||
.lectures
|
||||
.values()
|
||||
.flat_map(|l| l.readings.iter())
|
||||
.flat_map(|r| r.objectives.iter())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
self.objectives_in_order()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (key, lo) = self.learning_objectives.get_key_value(&id)?;
|
||||
(lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Looks up a reference, erroring on a dangling citation key.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the citation key.
|
||||
/// * `context` - what cited it, for the error message.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The reference.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the key is not registered.
|
||||
pub fn reference(&self, key: &str, context: &str) -> Result<&Reference> {
|
||||
self.references.get(key).ok_or_else(|| Error::Unresolved {
|
||||
kind: "reference",
|
||||
id: key.to_string(),
|
||||
context: Some(context.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Expands `{objective-id}` in a prose field to whatever the caller wants.
|
||||
///
|
||||
/// Reading notes refer to objectives in passing ("a worked instance of
|
||||
/// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a
|
||||
/// student report renders it as text. Only a name that resolves to a declared
|
||||
/// objective is treated as a placeholder, so `$U_\text{final}$` passes through
|
||||
/// untouched; that collision is the reason this is not a general template
|
||||
/// syntax.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prose` - the field to expand.
|
||||
/// * `render` - called with each resolved objective id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The prose with resolved placeholders replaced.
|
||||
pub fn expand_objective_refs(&self, prose: &str, render: impl Fn(&str) -> String) -> String {
|
||||
let mut out = String::with_capacity(prose.len());
|
||||
let mut rest = prose;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let (head, tail) = rest.split_at(open);
|
||||
out.push_str(head);
|
||||
let Some(close) = tail.find('}') else {
|
||||
out.push_str(tail);
|
||||
return out;
|
||||
};
|
||||
let name = &tail[1..close];
|
||||
if self.learning_objectives.contains_key(name) {
|
||||
out.push_str(&render(name));
|
||||
} else {
|
||||
out.push_str(&tail[..=close]);
|
||||
}
|
||||
rest = &tail[close + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A skeleton course file for `coursebank init`.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -525,6 +1073,7 @@ impl CourseFile {
|
||||
text: "Replace this with an objective stated as a student action.".to_string(),
|
||||
unit: Some("u-intro".to_string()),
|
||||
lectures: vec!["L01".to_string()],
|
||||
order: Some(1),
|
||||
level_ceiling: Some(Level::Understand),
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
@@ -549,6 +1098,7 @@ impl CourseFile {
|
||||
}],
|
||||
lectures,
|
||||
learning_objectives: los,
|
||||
references: BTreeMap::new(),
|
||||
stimuli: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -706,4 +1256,215 @@ learning_objectives:
|
||||
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
|
||||
assert_eq!(slugify(" "), "");
|
||||
}
|
||||
|
||||
/// A course with one reference and two readings, one of them supplemental.
|
||||
fn with_readings() -> CourseFile {
|
||||
parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
kuriyan2013molecules:
|
||||
label: KKW
|
||||
role: required
|
||||
title: The molecules of life
|
||||
base_url: https://example.org/kkw/
|
||||
lectures:
|
||||
L1.1:
|
||||
title: Enthalpy
|
||||
readings:
|
||||
- ref: kuriyan2013molecules
|
||||
locator: '§6.1'
|
||||
path: '6/A/#1'
|
||||
objectives: [lo-a]
|
||||
summary: What a system is.
|
||||
focus: Fix the definitions.
|
||||
- ref: kuriyan2013molecules
|
||||
locator: '§1.9'
|
||||
path: '1/B/#9'
|
||||
role: supplemental
|
||||
objectives: [lo-b]
|
||||
learning_objectives:
|
||||
lo-a: { text: A, lectures: [L1.1], order: 1 }
|
||||
lo-b: { text: B, lectures: [L1.1], order: 2 }
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_structured_reading_parses_and_validates() {
|
||||
let c = with_readings();
|
||||
assert!(c.validate().is_empty(), "{:?}", c.validate());
|
||||
let readings = &c.lectures["L1.1"].readings;
|
||||
assert_eq!(
|
||||
readings[0].reference.as_deref(),
|
||||
Some("kuriyan2013molecules")
|
||||
);
|
||||
assert_eq!(readings[0].role, ReadingRole::Assigned);
|
||||
assert_eq!(readings[1].role, ReadingRole::Supplemental);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reading_url_is_built_from_the_reference_base() {
|
||||
let c = with_readings();
|
||||
let reference = &c.references["kuriyan2013molecules"];
|
||||
let reading = &c.lectures["L1.1"].readings[0];
|
||||
assert_eq!(
|
||||
reading.resolve_url(reference).as_deref(),
|
||||
Some("https://example.org/kkw/6/A/#1")
|
||||
);
|
||||
assert_eq!(reading.cite("kuriyan2013molecules", reference), "KKW §6.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_string_reading_still_parses_and_round_trips() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- 'KKW §6.1: system and surroundings. https://example.org/1'
|
||||
"#,
|
||||
);
|
||||
let reading = &c.lectures["L01"].readings[0];
|
||||
assert!(reading.reference.is_none());
|
||||
assert_eq!(
|
||||
reading.text.as_deref(),
|
||||
Some("KKW §6.1: system and surroundings. https://example.org/1")
|
||||
);
|
||||
assert!(c.validate().is_empty());
|
||||
|
||||
// Serializing writes the string back as a string, so a load-and-save cycle
|
||||
// does not migrate a file the author has not chosen to migrate.
|
||||
let yaml = serde_yaml_ng::to_string(&c).expect("serializes");
|
||||
assert!(yaml.contains("- 'KKW §6.1: system and surroundings. https://example.org/1'"));
|
||||
assert!(!yaml.contains("text:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readings_resolve_backwards_from_an_objective() {
|
||||
let c = with_readings();
|
||||
let found = c.readings_for_objective("lo-a");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].0, "L1.1");
|
||||
assert_eq!(found[0].1.locator.as_deref(), Some("§6.1"));
|
||||
assert!(c.readings_for_objective("lo-nobody").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objective_with_no_reading_is_reported() {
|
||||
let mut c = with_readings();
|
||||
assert!(c.objectives_without_readings().is_empty());
|
||||
c.learning_objectives.insert(
|
||||
"lo-orphan".to_string(),
|
||||
Objective {
|
||||
text: "Orphan".to_string(),
|
||||
unit: None,
|
||||
lectures: vec!["L1.1".to_string()],
|
||||
order: Some(3),
|
||||
level_ceiling: None,
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
assessed: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]);
|
||||
|
||||
// An objective you teach but do not test is not a gap.
|
||||
c.learning_objectives
|
||||
.get_mut("lo-orphan")
|
||||
.expect("just inserted")
|
||||
.assessed = false;
|
||||
assert!(c.objectives_without_readings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_order_beats_id_order_within_a_lecture() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
lectures:
|
||||
L1.1: { title: One }
|
||||
learning_objectives:
|
||||
lo-enthalpy: { text: Third, lectures: [L1.1], order: 3 }
|
||||
lo-first-law: { text: Second, lectures: [L1.1], order: 2 }
|
||||
lo-system: { text: First, lectures: [L1.1], order: 1 }
|
||||
"#,
|
||||
);
|
||||
// Alphabetically this is enthalpy, first-law, system, which puts an
|
||||
// objective ahead of its own prerequisite.
|
||||
assert_eq!(
|
||||
c.lecture_objectives("L1.1"),
|
||||
vec!["lo-system", "lo-first-law", "lo-enthalpy"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_references_and_objectives_on_a_reading_are_reported() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
known: { title: A book }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- { ref: missing, locator: '§1' }
|
||||
- { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] }
|
||||
"#,
|
||||
);
|
||||
let issues = c.validate();
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown reference `missing`"))
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown learning objective `lo-nope`"))
|
||||
);
|
||||
// `path` with no base_url to join it to.
|
||||
assert!(issues.iter().any(|i| i.contains("base_url")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_label_and_a_duplicate_locator_are_reported() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
one: { title: First, label: KKW }
|
||||
two: { title: Second, label: KKW }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- { ref: one, locator: '§1' }
|
||||
- { ref: one, locator: '§1' }
|
||||
"#,
|
||||
);
|
||||
let issues = c.validate();
|
||||
assert!(issues.iter().any(|i| i.contains("`KKW` is the label of")));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("assigned twice in one lecture"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_declared_objective_id_is_a_placeholder() {
|
||||
let c = with_readings();
|
||||
let expanded = c.expand_objective_refs(
|
||||
r"a worked instance of {lo-a}, where $U_\text{final}$ is unchanged, {lo-typo} too",
|
||||
|id| format!("<{id}>"),
|
||||
);
|
||||
assert_eq!(
|
||||
expanded,
|
||||
r"a worked instance of <lo-a>, where $U_\text{final}$ is unchanged, {lo-typo} too"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+312
-1
@@ -19,7 +19,11 @@
|
||||
//! it. That keeps bank files readable and reviewable in a pull request while
|
||||
//! still letting statistics accumulate across terms.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::hash::fingerprint;
|
||||
@@ -79,8 +83,25 @@ pub struct Item {
|
||||
|
||||
/// The answer options in canonical order. Shuffling happens at export time
|
||||
/// per form, never here, so the bank stays diffable.
|
||||
///
|
||||
/// Empty for a [`Format::OpenResponse`] item, which is answered in free text
|
||||
/// and graded from its [`Solution`] instead. A choice format must still supply
|
||||
/// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving
|
||||
/// them out is reported there, with every other problem, rather than failing
|
||||
/// the parse on its own.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<Choice>,
|
||||
|
||||
/// The worked solution: a model answer, an explanation a student can learn
|
||||
/// from, and, for an open-response item, the rubric it is graded against.
|
||||
///
|
||||
/// This is what the solutions document renders and what a paper answer key
|
||||
/// prints. It is withheld from any question paper and from the exam payload,
|
||||
/// the same way an option's `correct` flag is, so a document built for the
|
||||
/// student cannot leak it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub solution: Option<Solution>,
|
||||
|
||||
/// Objectives this item measures, as ids into the course registry.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
@@ -239,6 +260,214 @@ pub struct Source {
|
||||
pub recording_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// A pointer from an item into the course reference registry: where to read more,
|
||||
/// or what to revisit after missing the item.
|
||||
///
|
||||
/// It holds a citation key and a locator rather than a restated citation, so a
|
||||
/// reference is written once in `course.yaml` and a changed edition is a single
|
||||
/// edit. The exporters resolve it against
|
||||
/// [`crate::course::CourseFile::references`] into a short label such as `KKW §6.1`,
|
||||
/// linked when the location resolves to a URL. This is the same pointer a lecture
|
||||
/// [`crate::course::Reading`] uses, kept lean here because an item cites a reading;
|
||||
/// it does not restate one.
|
||||
///
|
||||
/// A citation may also be written as a bare string, which lands unparsed in `text`
|
||||
/// and serializes back out as a string, so a bank that stored readings as plain
|
||||
/// strings keeps loading and round-trips byte-for-byte.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Citation {
|
||||
/// Citation key into the course reference registry.
|
||||
pub reference: Option<String>,
|
||||
/// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
|
||||
pub locator: Option<String>,
|
||||
/// Appended to the reference's `base_url` to reach this location.
|
||||
pub path: Option<String>,
|
||||
/// A full URL, when the location is not under the reference's `base_url`.
|
||||
pub url: Option<String>,
|
||||
/// A citation written as a bare string, held unparsed.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl Citation {
|
||||
/// A short display string that needs no reference lookup.
|
||||
///
|
||||
/// Prefers the unparsed `text`, then the key and locator. A caller that holds
|
||||
/// the course, such as an exporter, can resolve a nicer label and a link; this
|
||||
/// is the fallback for one that does not.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The display string, empty when the citation carries nothing.
|
||||
pub fn display(&self) -> String {
|
||||
if let Some(text) = &self.text {
|
||||
return text.clone();
|
||||
}
|
||||
match (&self.reference, &self.locator) {
|
||||
(Some(k), Some(l)) => format!("{k} {l}"),
|
||||
(Some(k), None) => k.clone(),
|
||||
(None, Some(l)) => l.clone(),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a citation as a mapping, or as a bare string when that is all it holds.
|
||||
impl Serialize for Citation {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
if let Some(text) = &self.text {
|
||||
if self.reference.is_none()
|
||||
&& self.locator.is_none()
|
||||
&& self.path.is_none()
|
||||
&& self.url.is_none()
|
||||
{
|
||||
return s.serialize_str(text);
|
||||
}
|
||||
}
|
||||
let mut map = s.serialize_map(None)?;
|
||||
if let Some(v) = &self.reference {
|
||||
map.serialize_entry("ref", v)?;
|
||||
}
|
||||
if let Some(v) = &self.locator {
|
||||
map.serialize_entry("locator", v)?;
|
||||
}
|
||||
if let Some(v) = &self.path {
|
||||
map.serialize_entry("path", v)?;
|
||||
}
|
||||
if let Some(v) = &self.url {
|
||||
map.serialize_entry("url", v)?;
|
||||
}
|
||||
if let Some(v) = &self.text {
|
||||
map.serialize_entry("text", v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a citation written either as a mapping or as a bare string.
|
||||
impl<'de> Deserialize<'de> for Citation {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Citation, D::Error> {
|
||||
/// The mapping form, with the field set kept in one place.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Mapping {
|
||||
#[serde(rename = "ref", default)]
|
||||
reference: Option<String>,
|
||||
#[serde(default)]
|
||||
locator: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
struct V;
|
||||
impl<'a> Visitor<'a> for V {
|
||||
type Value = Citation;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a citation mapping with a `ref`, or a plain citation string")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Citation, E> {
|
||||
Ok(Citation {
|
||||
text: Some(v.to_string()),
|
||||
..Citation::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'a>>(
|
||||
self,
|
||||
map: M,
|
||||
) -> std::result::Result<Citation, M::Error> {
|
||||
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
|
||||
Ok(Citation {
|
||||
reference: m.reference,
|
||||
locator: m.locator,
|
||||
path: m.path,
|
||||
url: m.url,
|
||||
text: m.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of a grading rubric for an open-response item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RubricCriterion {
|
||||
/// What earns the points, e.g. "states H = U + PV" or "compares to ~2.5 kJ/mol".
|
||||
pub description: String,
|
||||
/// Points for this line. Absent lets a grader decide; when present, the lines
|
||||
/// are meant to sum to the item's point value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub points: Option<f64>,
|
||||
}
|
||||
|
||||
/// The worked solution to an item: what the answer is, why, and how it is graded.
|
||||
///
|
||||
/// One place, versioned with the question, holds everything a student learns from
|
||||
/// after the fact and everything a grader marks an open response against. For a
|
||||
/// choice item the per-option [`Choice::explanation`] says why each option is right
|
||||
/// or wrong; the solution adds the single worked line of reasoning a solutions
|
||||
/// document leads with. For an [`Format::OpenResponse`] item the solution is the
|
||||
/// whole answer, because there are no options to annotate.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Solution {
|
||||
/// The model answer, in the authoring markup. For an open-response item this is
|
||||
/// the response a full-credit student would write; for a choice item it is an
|
||||
/// optional one-line statement of the key in words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_answer: Option<String>,
|
||||
/// The worked reasoning a student can learn from: the derivation, the estimate,
|
||||
/// the argument for the key over its neighbours. This is the body of the
|
||||
/// solutions document.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub explanation: Option<String>,
|
||||
/// How an open response is graded, one criterion per line.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rubric: Vec<RubricCriterion>,
|
||||
/// Responses a short constructed answer would be accepted as. Shown in the
|
||||
/// solutions document as accepted answers, and the hook for automated grading
|
||||
/// later.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub accepted: Vec<String>,
|
||||
/// Where to look again after missing this item, as citations into the course
|
||||
/// reference registry. Resolved and linked by the exporters.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub review: Vec<Citation>,
|
||||
}
|
||||
|
||||
impl Solution {
|
||||
/// Whether the solution carries anything worth rendering.
|
||||
///
|
||||
/// Used to decide whether a solutions entry has a body to print, so an item
|
||||
/// with an empty `solution:` block is treated as having none.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.model_answer.is_none()
|
||||
&& self.explanation.is_none()
|
||||
&& self.rubric.is_empty()
|
||||
&& self.accepted.is_empty()
|
||||
&& self.review.is_empty()
|
||||
}
|
||||
|
||||
/// Total of the rubric line points, when every line carries one.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The sum, or `None` if any line omits its points or the rubric is empty.
|
||||
pub fn rubric_points(&self) -> Option<f64> {
|
||||
if self.rubric.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.rubric.iter().map(|c| c.points).sum::<Option<f64>>()
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure or data file reproduced with an item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -473,6 +702,7 @@ impl Item {
|
||||
stimulus: None,
|
||||
stem: stem.to_string(),
|
||||
options,
|
||||
solution: None,
|
||||
learning_objectives: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
@@ -538,6 +768,14 @@ impl Item {
|
||||
self.key_indices().len() > 1
|
||||
}
|
||||
|
||||
/// Whether the item presents selectable options, per its [`Format`].
|
||||
///
|
||||
/// `false` for an [`Format::OpenResponse`] item. Callers that would otherwise
|
||||
/// index `options` or read a key should branch on this first.
|
||||
pub fn has_options(&self) -> bool {
|
||||
self.format.has_options()
|
||||
}
|
||||
|
||||
/// The display title, falling back to a truncated stem.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -821,4 +1059,77 @@ options:
|
||||
IrtModel::ThreePl
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_response_item_parses_without_options() {
|
||||
let it = item(
|
||||
r#"
|
||||
id: q-enthalpy-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
|
||||
solution:
|
||||
model_answer: >-
|
||||
At constant pressure the P dV expansion work is folded into H = U + PV, so the
|
||||
heat q_p equals the change in H.
|
||||
rubric:
|
||||
- { description: "states H = U + PV", points: 1 }
|
||||
- { description: "identifies q_p with the enthalpy change", points: 1 }
|
||||
review:
|
||||
- { ref: kuriyan2012molecules, locator: "§6.4", path: "6/A/#4" }
|
||||
"#,
|
||||
);
|
||||
assert_eq!(it.format, Format::OpenResponse);
|
||||
assert!(it.options.is_empty());
|
||||
assert!(!it.has_options());
|
||||
assert!(it.key_letters().is_empty());
|
||||
let sol = it.solution.as_ref().expect("has a solution");
|
||||
assert!(!sol.is_empty());
|
||||
assert_eq!(sol.rubric_points(), Some(2.0));
|
||||
assert_eq!(sol.review.len(), 1);
|
||||
assert_eq!(
|
||||
sol.review[0].reference.as_deref(),
|
||||
Some("kuriyan2012molecules")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solution_serializes_only_what_it_holds() {
|
||||
let it = item(
|
||||
r#"
|
||||
id: q-demo-op-002
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: State the first law.
|
||||
solution:
|
||||
model_answer: The total energy of an isolated system is constant.
|
||||
"#,
|
||||
);
|
||||
let yaml = serde_yaml_ng::to_string(&it).expect("serializes");
|
||||
// Open-response items carry no options key, and an empty rubric is omitted.
|
||||
assert!(!yaml.contains("options:"), "no options key:\n{yaml}");
|
||||
assert!(!yaml.contains("rubric"), "empty rubric omitted:\n{yaml}");
|
||||
assert!(yaml.contains("model_answer:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_citation_round_trips_as_string_or_mapping() {
|
||||
// A bare string stays a bare string.
|
||||
let bare: Citation = serde_yaml_ng::from_str("\"KKW §6.4 (course reserve)\"").unwrap();
|
||||
assert_eq!(bare.text.as_deref(), Some("KKW §6.4 (course reserve)"));
|
||||
assert_eq!(bare.display(), "KKW §6.4 (course reserve)");
|
||||
let back = serde_yaml_ng::to_string(&bare).unwrap();
|
||||
assert_eq!(back.trim(), "KKW §6.4 (course reserve)");
|
||||
|
||||
// A mapping keeps its fields, and `ref` is the key's YAML spelling.
|
||||
let mapped: Citation =
|
||||
serde_yaml_ng::from_str("{ ref: kuriyan2012molecules, locator: \"§6.4\" }").unwrap();
|
||||
assert_eq!(mapped.reference.as_deref(), Some("kuriyan2012molecules"));
|
||||
assert_eq!(mapped.display(), "kuriyan2012molecules §6.4");
|
||||
let back = serde_yaml_ng::to_string(&mapped).unwrap();
|
||||
assert!(back.contains("ref: kuriyan2012molecules"));
|
||||
assert!(back.contains("locator:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,17 +423,53 @@ pub enum Format {
|
||||
MultipleResponse,
|
||||
/// Two options, True and False.
|
||||
TrueFalse,
|
||||
/// A free-text answer the student writes rather than selects.
|
||||
///
|
||||
/// It carries no options and is not machine-scored. What a grader marks it
|
||||
/// against, and what a solutions document shows, lives in the item's
|
||||
/// [`crate::item::Solution`]: a model answer and, when the item is worth more
|
||||
/// than a point, a rubric. This is the format for "explain", "derive", and
|
||||
/// "estimate" prompts that a set of distractors would trivialize.
|
||||
OpenResponse,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Every response format.
|
||||
pub const ALL: [Format; 4] = [
|
||||
Format::SingleBestAnswer,
|
||||
Format::MultipleResponse,
|
||||
Format::TrueFalse,
|
||||
Format::OpenResponse,
|
||||
];
|
||||
|
||||
/// The QTI question type Canvas expects for this format.
|
||||
pub fn qti_type(self) -> &'static str {
|
||||
match self {
|
||||
Format::SingleBestAnswer => "multiple_choice_question",
|
||||
Format::MultipleResponse => "multiple_answers_question",
|
||||
Format::TrueFalse => "true_false_question",
|
||||
Format::OpenResponse => "essay_question",
|
||||
}
|
||||
}
|
||||
|
||||
/// The snake_case token used in YAML.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Format::SingleBestAnswer => "single_best_answer",
|
||||
Format::MultipleResponse => "multiple_response",
|
||||
Format::TrueFalse => "true_false",
|
||||
Format::OpenResponse => "open_response",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether items in this format present selectable options.
|
||||
///
|
||||
/// `false` only for [`Format::OpenResponse`]. Validation, assembly, and the
|
||||
/// exporters branch on this rather than on the variant, so the day a second
|
||||
/// free-text format is added it inherits the no-options handling for free.
|
||||
pub fn has_options(self) -> bool {
|
||||
!matches!(self, Format::OpenResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// How strongly an item is expected to separate strong from weak students.
|
||||
@@ -633,4 +669,18 @@ mod tests {
|
||||
Status::InReview
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_response_is_the_only_format_without_options() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Format>("\"open_response\"").unwrap(),
|
||||
Format::OpenResponse
|
||||
);
|
||||
assert_eq!(Format::OpenResponse.as_str(), "open_response");
|
||||
assert_eq!(Format::OpenResponse.qti_type(), "essay_question");
|
||||
assert!(!Format::OpenResponse.has_options());
|
||||
for f in Format::ALL {
|
||||
assert_eq!(f.has_options(), f != Format::OpenResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-2
@@ -113,6 +113,32 @@ pub fn to_plain(src: &str) -> String {
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// Converts authoring markup to Pandoc-flavoured Markdown, for a Quarto document.
|
||||
///
|
||||
/// Subscripts and superscripts become Pandoc's `~x~` and `^x^`, the symbol table
|
||||
/// renders as Unicode, and the bold, italic, and inline-code spans are already
|
||||
/// Markdown, so they pass through unchanged. Paragraph breaks are kept. Nothing is
|
||||
/// HTML-escaped, because the consumer is a Markdown renderer rather than a page.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `src` - the authoring source.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pandoc Markdown, trimmed, with paragraph breaks preserved.
|
||||
pub fn to_markdown(src: &str) -> String {
|
||||
let symbolized = apply_symbols(src, false);
|
||||
let mut out = wrap_bracket(&symbolized, "#sub[", "~", "~");
|
||||
out = wrap_bracket(&out, "#sup[", "^", "^");
|
||||
out.lines()
|
||||
.map(|l| l.trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Passes authoring markup through for Typst.
|
||||
///
|
||||
/// The markup is already a Typst subset, so this only normalizes whitespace and
|
||||
@@ -173,7 +199,7 @@ pub fn escape_html(s: &str) -> String {
|
||||
/// # Returns
|
||||
///
|
||||
/// The substituted text.
|
||||
fn apply_symbols(s: &str, html: bool) -> String {
|
||||
pub(crate) fn apply_symbols(s: &str, html: bool) -> String {
|
||||
let mut out = s.to_string();
|
||||
for (token, entity, plain) in SYMBOLS {
|
||||
if out.contains(token) {
|
||||
@@ -192,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
|
||||
/// # Returns
|
||||
///
|
||||
/// The text with inline markup converted.
|
||||
fn apply_inline(s: &str) -> String {
|
||||
pub(crate) fn apply_inline(s: &str) -> String {
|
||||
let mut out = s.to_string();
|
||||
// Bracketed forms first: their contents may contain other markup characters.
|
||||
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
|
||||
@@ -379,4 +405,21 @@ mod tests {
|
||||
assert_eq!(to_typst("a @ b"), "a \\@ b");
|
||||
assert_eq!(to_typst("x < y"), "x \\< y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_uses_pandoc_scripts_and_unicode_symbols() {
|
||||
assert_eq!(to_markdown("H#sub[2]O"), "H~2~O");
|
||||
assert_eq!(to_markdown("x#sup[2]"), "x^2^");
|
||||
assert_eq!(
|
||||
to_markdown("K#sub[m] #sym.approx 5 mM"),
|
||||
"K~m~ \u{2248} 5 mM"
|
||||
);
|
||||
// Bold, italic, and code are already Markdown.
|
||||
assert_eq!(
|
||||
to_markdown("**bold** and *em* and `code`"),
|
||||
"**bold** and *em* and `code`"
|
||||
);
|
||||
// Paragraph breaks survive.
|
||||
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user