28 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 74c9dc015c The lint moved into uhhm/portal as portal lint
Test / test (push) Successful in 1m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:33:54 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e3c68f9441 needs.yaml may name the tones
Test / test (push) Successful in 1m16s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:17:20 +02:00
portal release 050fb34fb0 Match portal v0.5.10
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 26s
2026-09-30 14:13:58 +02:00
portal release b86a12e5df Match portal v0.5.9
Test / test (push) Successful in 1m27s
Publish release / publish (push) Successful in 26s
2026-09-30 14:09:37 +02:00
portal release 2f04c82c89 Match portal v0.5.8
Test / test (push) Successful in 31s
Publish release / publish (push) Successful in 26s
2026-09-30 13:44:15 +02:00
portal release 00449916a3 Match portal v0.5.7
Test / test (push) Successful in 2m13s
Publish release / publish (push) Successful in 26s
2026-09-30 13:36:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 41db6093e5 The effort budget counts what a person fills in, not carrier fields
Test / test (push) Successful in 2m38s
A type: record field carries the case an answer is about on the link,
and a hidden preset carries a value; neither is asked. An objection
form that carried the application's id counted one field over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:57:03 +02:00
portal release 572dfc200c Match portal v0.5.6
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 26s
2026-09-30 08:22:10 +02:00
portal release 955a33609a Match portal v0.5.5
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 25s
2026-09-30 07:54:29 +02:00
portal release 9b96111916 Match portal v0.5.4
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 25s
2026-09-29 21:14:02 +02:00
portal release f2fc5097f7 Match portal v0.5.3
Test / test (push) Successful in 29s
Publish release / publish (push) Successful in 25s
2026-09-29 13:33:07 +02:00
portal release f2ca00b309 Match portal v0.5.2
Test / test (push) Successful in 1m17s
Publish release / publish (push) Successful in 26s
2026-09-28 18:11:49 +02:00
portal release ac4af4395a Match portal v0.5.1
Test / test (push) Successful in 1m0s
Publish release / publish (push) Successful in 26s
2026-09-28 17:51:11 +02:00
portal release 1e7245b062 Match portal v0.5.0
Test / test (push) Failing after 34s
Publish release / publish (push) Successful in 28s
2026-09-28 16:50:47 +02:00
portal release a847d0defa Match portal v0.4.0
Test / test (push) Successful in 29s
Publish release / publish (push) Successful in 25s
2026-09-24 21:37:28 +02:00
portal release 57b733e606 Match portal v0.3.47
Test / test (push) Successful in 29s
Publish release / publish (push) Successful in 24s
2026-09-24 05:20:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3923d4d0dc needs: stage_words, each state in the site's own words, carried into the simulation model
Test / test (push) Successful in 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 00db014eae A card with no button is read, never picked
Test / test (push) Successful in 1m6s
The opener that says what the business does and the look-around card
lead nowhere: they are what the page says before its options, so a
task and a simulation step carry them as `page` context and offer
only the cards that lead somewhere. Five personas per round had been
"choosing" the opener and counted as misroutes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:36:20 +02:00
portal release d2450fcb75 Match portal v0.3.46
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 36s
2026-09-23 22:21:14 +02:00
portal release 0841ed561a Match portal v0.3.45
Test / test (push) Successful in 33s
Publish release / publish (push) Successful in 28s
2026-09-23 21:26:51 +02:00
portal release 79f85132c5 Match portal v0.3.44
Test / test (push) Successful in 30s
Publish release / publish (push) Successful in 26s
2026-09-23 21:18:10 +02:00
portal release 58d68c3bff Match portal v0.3.43
Test / test (push) Successful in 29s
Publish release / publish (push) Successful in 25s
2026-09-23 19:48:01 +02:00
portal release 0cf70502fc Match portal v0.3.42
Publish release / publish (push) Successful in 25s
Test / test (push) Successful in 29s
2026-09-23 14:35:49 +02:00
portal release fb66694b85 Match portal v0.3.41
Publish release / publish (push) Successful in 25s
Test / test (push) Successful in 1m6s
2026-09-23 11:55:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 959e5f6ef0 check: compile and validate the access policy; --access prints the matrix
Test / test (push) Successful in 29s
Every check now compiles the content into portal's Cedar policy and
fails when it does not validate against the schema - a rule the
schema cannot express would grant nothing at runtime. `--access`
prints the matrix: action, resource, who, and the state move where it
applies. Pinned to portal v0.3.40, where the policy lives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:48:33 +02:00
portal release 9cd5dff4c2 Match portal v0.3.39
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 20s
2026-09-23 06:03:18 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bd8a5e0c6c Versioned as portal: released by portal's publish job
Test / test (push) Successful in 1m0s
iris reports its own version as the portal it matches; the tag, the
pin and the changelog line are written by portal's release step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 22:05:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e543b41cb0 CI: a writable CARGO_HOME, so the portal git dependency can be fetched
Test / test (push) Successful in 4m53s
The shared /var/local/cargo is read-only for the runner; 0.1.1 built
only because this machine had already cloned the same portal tag into
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:50:53 +02:00
9 changed files with 890 additions and 68 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ jobs:
publish: publish:
runs-on: bare runs-on: bare
env: env:
CARGO_HOME: /var/local/cargo # Writable, unlike the shared /var/local/cargo - see test.yml.
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
RUSTC_WRAPPER: /usr/bin/sccache
RUSTUP_HOME: /var/local/rustup RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
SCCACHE_DIR: /var/local/sccache SCCACHE_DIR: /var/local/sccache
+6 -1
View File
@@ -10,7 +10,12 @@ jobs:
runs-on: bare runs-on: bare
env: env:
# Same shared toolchain and cache as uhhm/portal's workflows. # Same shared toolchain and cache as uhhm/portal's workflows.
CARGO_HOME: /var/local/cargo # Not the shared /var/local/cargo: the runner may only read it, and
# the portal git dependency has to be cloned into CARGO_HOME. This
# one lives under the writable target dir; the rustc wrapper the
# shared config would have set is given here instead.
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
RUSTC_WRAPPER: /usr/bin/sccache
RUSTUP_HOME: /var/local/rustup RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
SCCACHE_DIR: /var/local/sccache SCCACHE_DIR: /var/local/sccache
+88
View File
@@ -3,6 +3,94 @@
One line per change, grouped by the release that shipped it. Newest One line per change, grouped by the release that shipped it. Newest
first. Each release names the portal tag it is built against. first. Each release names the portal tag it is built against.
## Unreleased
- `needs.yaml` may name the site's `tones`, one storyline each; the checks read the first storyline, which is what `build_questions` gives.
## 0.5.10 (2026-09-30) - portal v0.5.10
- Matches portal v0.5.10 (released by portal's publish job).
## 0.5.9 (2026-09-30) - portal v0.5.9
- Matches portal v0.5.9 (released by portal's publish job).
## 0.5.8 (2026-09-30) - portal v0.5.8
- Matches portal v0.5.8 (released by portal's publish job).
## 0.5.7 (2026-09-30) - portal v0.5.7
- Matches portal v0.5.7 (released by portal's publish job).
## Unreleased
- The effort budget counts what a person fills in: a `type: record` field (the case an answer is about, carried on the link) and a `hidden` preset are not asked and no longer count. An objection page whose form carried the application's id used to be one field over.
## 0.5.6 (2026-09-30) - portal v0.5.6
- Matches portal v0.5.6 (released by portal's publish job).
## 0.5.5 (2026-09-30) - portal v0.5.5
- Matches portal v0.5.5 (released by portal's publish job).
## 0.5.4 (2026-09-29) - portal v0.5.4
- Matches portal v0.5.4 (released by portal's publish job).
## 0.5.3 (2026-09-29) - portal v0.5.3
- Matches portal v0.5.3 (released by portal's publish job).
## 0.5.2 (2026-09-28) - portal v0.5.2
- Matches portal v0.5.2 (released by portal's publish job).
## 0.5.1 (2026-09-28) - portal v0.5.1
- Matches portal v0.5.1 (released by portal's publish job).
## 0.5.0 (2026-09-28) - portal v0.5.0
- Matches portal v0.5.0 (released by portal's publish job).
## 0.4.0 (2026-09-24) - portal v0.4.0
- Matches portal v0.4.0 (released by portal's publish job).
## 0.3.47 (2026-09-24) - portal v0.3.47
- Matches portal v0.3.47 (released by portal's publish job).
## 0.3.46 (2026-09-23) - portal v0.3.46
- Matches portal v0.3.46 (released by portal's publish job).
## 0.3.45 (2026-09-23) - portal v0.3.45
- Matches portal v0.3.45 (released by portal's publish job).
## 0.3.44 (2026-09-23) - portal v0.3.44
- Matches portal v0.3.44 (released by portal's publish job).
## 0.3.43 (2026-09-23) - portal v0.3.43
- Matches portal v0.3.43 (released by portal's publish job).
## 0.3.42 (2026-09-23) - portal v0.3.42
- Matches portal v0.3.42 (released by portal's publish job).
## 0.3.41 (2026-09-23) - portal v0.3.41
- Matches portal v0.3.41 (released by portal's publish job).
## 0.3.39 (2026-09-23) - portal v0.3.39
- Matches portal v0.3.39 (released by portal's publish job).
## 0.1.1 (2026-09-22) - portal v0.3.36 ## 0.1.1 (2026-09-22) - portal v0.3.36
- `check --repo` fetches needs.yaml itself; the v0.1.0 tag never published (a cache permission on the runner, since fixed). - `check --repo` fetches needs.yaml itself; the v0.1.0 tag never published (a cache permission on the runner, since fixed).
Generated
+687 -3
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iris" name = "iris"
version = "0.1.1" version = "0.5.10"
edition = "2021" edition = "2021"
description = "The iris over a portal site: nothing from a content repo comes through until it checks out" description = "The iris over a portal site: nothing from a content repo comes through until it checks out"
license = "MIT" license = "MIT"
@@ -10,7 +10,7 @@ repository = "https://project.uhhm.no/uhhm/iris"
# The site's own content types and loaders, so iris reads a repo exactly # The site's own content types and loaders, so iris reads a repo exactly
# the way the running site does. Pinned to the portal release it # the way the running site does. Pinned to the portal release it
# matches; bumping this tag is what a new iris release is. # matches; bumping this tag is what a new iris release is.
portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.3.36", features = ["ssr"] } portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.5.10", features = ["ssr"] }
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+8 -51
View File
@@ -1,53 +1,10 @@
# iris # iris, now `portal lint`
The iris over a [portal](https://project.uhhm.no/uhhm/portal) site. A The lint lives in [uhhm/portal](https://project.uhhm.no/uhhm/portal)
content push is an incoming wormhole; nothing comes through until it since portal 0.6, as the `portal lint` command of the same binary that
checks out. runs a site; its documentation is
[The lint](https://portal.uhhm.no/lint.html). A content repo's CI
downloads the release's `portal` and runs `portal lint --path questions`.
``` The `iris` releases up to v0.5.11 stay here and keep working for the
iris check --path questions # a local checkout `IRIS_RELEASE` pins that name them; nothing new is published.
iris check --repo https://project.uhhm.no/uhhm/questions
iris --path questions # the same, for one-line CI
```
`check` loads a content repo exactly the way the running site does,
validates it (every action lands on a page, every transition is one
the state machine allows, every recorded bucket is read back
somewhere), and then holds it to the business needs the repo declares
in `needs.yaml`, when it has one:
- a path of submissions gets each kind of visitor's answer into the
bucket it names, within a submission and required-field budget;
- the handling group holds a desk over that bucket, and every
finished state is reachable through the buttons it (and any
`also_moved_by` group) offers; no record strands on the way;
- every stage the business named is a state of the machine.
A need marked `planned` warns instead of failing. Exit status is the
verdict, so a content repo's CI is one line.
Beyond the verdict, `check` exports what a trainer needs to grade the
wording: `--needs-tasks` writes the personas as typed choice tasks
(`--skim` for headings only), `--needs-score` grades an engine's
answers, and `--needs-sim` writes a resolved model of the whole site.
`iris replay` runs simulated cases through the site's real aggregate
engine on a throwaway JetStream and reports every bucket by state;
`--report-only` takes the same scorecard from a live site's buckets.
See [portal-trainer](https://project.uhhm.no/uhhm/portal-trainer) for
what sits around those.
## Matching the site
Every type iris reads is portal's own: portal is a library dependency
pinned to a release tag in `Cargo.toml`, and `iris --version` says
which. A content repo should run the iris that matches the portal
release its site runs. Portal's own release tarball ships an `iris`
binary built the same way, so a site that pins nothing extra has one
at `/srv/app/<instance>/current/iris`.
## Release
Move the `Unreleased` lines in `CHANGELOG.md` under a heading for the
version, bump `Cargo.toml`, commit, tag `v<version>`, push. CI builds
the binary and attaches it to the Gitea release as `iris`, with that
section as the notes.
+21 -1
View File
@@ -23,6 +23,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
let mut min_accuracy: f64 = 0.0; let mut min_accuracy: f64 = 0.0;
let mut skim = false; let mut skim = false;
let mut sim_out: Option<String> = None; let mut sim_out: Option<String> = None;
let mut show_access = false;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
@@ -33,6 +34,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
"--needs-tasks" => tasks_out = args.next(), "--needs-tasks" => tasks_out = args.next(),
"--skim" => skim = true, "--skim" => skim = true,
"--needs-sim" => sim_out = args.next(), "--needs-sim" => sim_out = args.next(),
"--access" => show_access = true,
"--needs-score" => answers_in = args.next(), "--needs-score" => answers_in = args.next(),
"--min-accuracy" => { "--min-accuracy" => {
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy) min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
@@ -62,7 +64,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
(questions, aggregates_map, site, needs_raw) (questions, aggregates_map, site, needs_raw)
} else { } else {
eprintln!( eprintln!(
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]" "usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
); );
std::process::exit(2); std::process::exit(2);
}; };
@@ -91,6 +93,24 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
questions.len(), questions.len(),
aggregates_map.len() aggregates_map.len()
); );
// The access policy the site will compile from this content.
// It must type-check against portal's schema: a rule the
// schema cannot express would grant nothing at runtime.
let policy = portal::access::Policy::from_content(&questions, &aggregates_map);
if let Err(e) = policy.validate() {
eprintln!("FAIL: access policy does not validate: {e}");
std::process::exit(1);
}
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
if show_access {
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
let mut rows = policy.rules.clone();
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
for r in rows {
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
}
println!();
}
check_needs( check_needs(
needs_raw.as_deref(), needs_raw.as_deref(),
&questions, &questions,
+3 -5
View File
@@ -38,19 +38,17 @@ async fn main() -> anyhow::Result<()> {
replay::run(argv).await replay::run(argv).await
} }
Some("--version") | Some("-V") => { Some("--version") | Some("-V") => {
println!("iris {} (portal {})", env!("CARGO_PKG_VERSION"), PORTAL_TAG); println!("iris {v} (portal v{v})", v = env!("CARGO_PKG_VERSION"));
Ok(()) Ok(())
} }
Some(flag) if flag.starts_with("--") => check::run(argv).await, Some(flag) if flag.starts_with("--") => check::run(argv).await,
_ => { _ => {
eprintln!( eprintln!(
"iris {} - matches portal {PORTAL_TAG}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)", "iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)",
env!("CARGO_PKG_VERSION") v = env!("CARGO_PKG_VERSION")
); );
std::process::exit(2) std::process::exit(2)
} }
} }
} }
/// The portal release this iris was built against (see Cargo.toml).
const PORTAL_TAG: &str = "v0.3.36";
+72 -4
View File
@@ -36,6 +36,11 @@ pub struct NeedsFile {
pub needs: Vec<Need>, pub needs: Vec<Need>,
#[serde(default)] #[serde(default)]
pub personas: Vec<Persona>, pub personas: Vec<Persona>,
/// The tones of voice the site is told in, one storyline each
/// (portal docs, Storylines). Named here so the report can say
/// which tone a storyline index is; the checks read the first.
#[serde(default)]
pub tones: Vec<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -73,6 +78,11 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI. /// as a warning, so the gap stays visible without failing CI.
#[serde(default)] #[serde(default)]
pub planned: bool, pub planned: bool,
/// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model.
#[serde(default)]
pub stage_words: std::collections::BTreeMap<String, String>,
/// The finished states that are a good outcome for the business - /// The finished states that are a good outcome for the business -
/// a subset of `done_when`. "lost" is finished; "won" is success. /// a subset of `done_when`. "lost" is finished; "won" is success.
/// What a simulation or a live bucket report counts toward. /// What a simulation or a live bucket report counts toward.
@@ -152,11 +162,14 @@ fn visible(q: &Question, as_group: Option<&str>) -> bool {
} }
} }
/// What a person has to fill in: required fields, less the ones that
/// carry rather than ask - a `record` naming the case an answer is
/// about, a `hidden` preset - which cost nobody anything.
fn required_fields(alt: &Alternative) -> usize { fn required_fields(alt: &Alternative) -> usize {
alt.features alt.features
.iter() .iter()
.flat_map(|f| &f.requirements) .flat_map(|f| &f.requirements)
.filter(|r| !r.optional) .filter(|r| !r.optional && r.kind != "record" && r.kind != "hidden")
.count() .count()
} }
@@ -571,7 +584,8 @@ pub fn sim_model(
json!({ json!({
"page": step.page, "page": step.page,
"question": page.name, "question": page.name,
"options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({ "context": page_context(page),
"options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({
"name": a.name, "name": a.name,
"description": plain(&a.description), "description": plain(&a.description),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
@@ -597,6 +611,7 @@ pub fn sim_model(
json!({ json!({
"initial": schema.initial, "initial": schema.initial,
"transitions": schema.transitions, "transitions": schema.transitions,
"words": need.stage_words,
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({ "desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
"page": d.page, "page": d.page,
"group": d.group, "group": d.group,
@@ -646,6 +661,41 @@ fn plain(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ") text.split_whitespace().collect::<Vec<_>>().join(" ")
} }
/// A card a visitor can act on: it leads to a page, records an
/// answer, or moves a record. A card with none of those is read, not
/// picked - the opener that says what the business does, the look
/// around - and is context on the page, never an option.
pub fn leads_somewhere(a: &Alternative) -> bool {
a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some()
}
/// What the page says before its options: every card that leads
/// nowhere, its heading, description and features, as one line each.
pub fn page_context(page: &Question) -> String {
page.alternatives
.iter()
.filter(|a| !a.disabled && !leads_somewhere(a))
.map(|a| {
let mut line = a.name.clone();
let description = plain(&a.description);
if !description.is_empty() {
line.push_str(&format!(": {description}"));
}
for f in &a.features {
let text = plain(&f.description);
match (f.name.trim().is_empty(), text.is_empty()) {
(true, true) => {}
(true, false) => line.push_str(&format!(" {text}")),
(false, true) => line.push_str(&format!(" {}.", f.name.trim())),
(false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())),
}
}
line
})
.collect::<Vec<_>>()
.join("\n")
}
/// Every (persona, page-on-their-path) pair with a real choice on it. /// Every (persona, page-on-their-path) pair with a real choice on it.
/// `skim` shows the engine only each alternative's heading - what a /// `skim` shows the engine only each alternative's heading - what a
/// visitor who never reads the description has to go on. /// visitor who never reads the description has to go on.
@@ -660,10 +710,11 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
}; };
for (i, step) in path.iter().enumerate() { for (i, step) in path.iter().enumerate() {
let page = &questions[&step.page]; let page = &questions[&step.page];
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect(); let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect();
if open.len() < 2 { if open.len() < 2 {
continue; continue;
} }
let context = page_context(page);
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect(); let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter() .into_iter()
@@ -684,7 +735,11 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
persona: persona.id.clone(), persona: persona.id.clone(),
need: need.id.clone(), need: need.id.clone(),
page: step.page.clone(), page: step.page.clone(),
state: BTreeMap::from([("body", plain(&persona.text))]), state: if context.is_empty() {
BTreeMap::from([("body", plain(&persona.text))])
} else {
BTreeMap::from([("body", plain(&persona.text)), ("page", context)])
},
questions: BTreeMap::from([( questions: BTreeMap::from([(
"pick", "pick",
ChoiceQuestion { ChoiceQuestion {
@@ -813,6 +868,19 @@ mod tests {
let (questions, aggregates) = site(FULL_DESK); let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates); let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates);
assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]); assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]);
// A field that carries rather than asks is not effort: the
// record an answer is about arrives on the link.
let (mut questions, aggregates) = site(FULL_DESK);
let front = questions.get_mut("/").unwrap();
let brief = &mut front.alternatives[0].features[0].requirements;
let mut carrier = brief[0].clone();
carrier.name = "case".into();
carrier.kind = "record".into();
carrier.of = Some("projects".into());
carrier.optional = false;
brief.push(carrier);
let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates);
assert!(fails(&findings).is_empty(), "{findings:?}");
} }
#[test] #[test]