Render an Apple configuration profile from the server's PACC document
iOS is the one client none of the mail autoconfiguration reaches: Apple Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so adding an account means the device probing for ports - and a probe that meets a filtered port waits out a TCP timeout and reports the account as unverifiable, which the person reads as a wrong password. A profile states the hosts and the ports instead, and carries mail, calendars and contacts in one install. It invents none of them: site.yaml names a PACC document - the standard defined to be fetched from the mail domain itself - and every host, port and collection path comes from there, so the server is described in one place and the profile follows it. The document holds no address, username or password. iOS asks for those at install, which means one profile serves everyone with an account and it can be fetched by anyone: it says no more than PACC already says in public. Identifiers are derived from the site and the payload rather than drawn at random, because iOS replaces a profile whose ids it knows and stacks a duplicate when they differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
co-authored by
Claude Opus 5
parent
43a2f3a069
commit
44c7ee6870
+233
-54
@@ -238,7 +238,11 @@ fn viable(
|
||||
if remaining == 0 {
|
||||
return out;
|
||||
}
|
||||
for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) {
|
||||
for alt in page
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| include_disabled || !a.disabled)
|
||||
{
|
||||
if entry && need.via.as_deref().is_some_and(|via| via != alt.name) {
|
||||
continue;
|
||||
}
|
||||
@@ -268,7 +272,15 @@ fn viable(
|
||||
continue;
|
||||
}
|
||||
trail.push(target.id.clone());
|
||||
let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail);
|
||||
let onward = viable(
|
||||
questions,
|
||||
need,
|
||||
target,
|
||||
remaining - 1,
|
||||
false,
|
||||
include_disabled,
|
||||
trail,
|
||||
);
|
||||
trail.pop();
|
||||
if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) {
|
||||
let mut path = vec![step];
|
||||
@@ -287,7 +299,15 @@ fn viable(
|
||||
fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option<Vec<Viable>> {
|
||||
let page = questions.get(&need.enters)?;
|
||||
let mut trail = vec![page.id.clone()];
|
||||
Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail))
|
||||
Some(viable(
|
||||
questions,
|
||||
need,
|
||||
page,
|
||||
need.max_steps,
|
||||
true,
|
||||
include_disabled,
|
||||
&mut trail,
|
||||
))
|
||||
}
|
||||
|
||||
/// The cheapest path that meets the need, if any.
|
||||
@@ -321,7 +341,10 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||
{
|
||||
desks.push(Desk {
|
||||
page: q.id.clone(),
|
||||
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
|
||||
group: resource
|
||||
.requires_group
|
||||
.clone()
|
||||
.or_else(|| q.qualifies.clone()),
|
||||
page_group: q.qualifies.clone(),
|
||||
moves: resource
|
||||
.transitions
|
||||
@@ -344,15 +367,22 @@ fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter_map(|a| a.self_transition.as_ref())
|
||||
.filter(|s| s.bucket == bucket)
|
||||
.flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone())))
|
||||
.flat_map(|s| {
|
||||
s.from_states(initial)
|
||||
.into_iter()
|
||||
.map(|from| (from, s.to.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The moves a clock makes in `schema` (`after:` on a state), as
|
||||
/// (from, to). Nobody presses them, and they still move records.
|
||||
fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> {
|
||||
let mut moves: Vec<(String, String)> =
|
||||
schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect();
|
||||
let mut moves: Vec<(String, String)> = schema
|
||||
.after_for_state
|
||||
.iter()
|
||||
.map(|(from, after)| (from.clone(), after.to.clone()))
|
||||
.collect();
|
||||
moves.sort();
|
||||
moves
|
||||
}
|
||||
@@ -402,7 +432,11 @@ pub fn check(
|
||||
scope: "need",
|
||||
code,
|
||||
subject,
|
||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||
level: if need.planned || need.paused {
|
||||
Level::Warn
|
||||
} else {
|
||||
level
|
||||
},
|
||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||
(true, _, true) => format!("paused, not open now: {message}"),
|
||||
(false, true, true) => format!("planned, not met yet: {message}"),
|
||||
@@ -411,13 +445,23 @@ pub fn check(
|
||||
})
|
||||
};
|
||||
if !seen.insert(need.id.as_str()) {
|
||||
report(Level::Fail, "need-duplicate", String::new(), "declared more than once".to_string());
|
||||
report(
|
||||
Level::Fail,
|
||||
"need-duplicate",
|
||||
String::new(),
|
||||
"declared more than once".to_string(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The visitor's side: can they get their answer recorded?
|
||||
match questions.get(&need.enters) {
|
||||
None => report(Level::Fail, "entry-missing", need.enters.clone(), format!("entry page {:?} does not exist", need.enters)),
|
||||
None => report(
|
||||
Level::Fail,
|
||||
"entry-missing",
|
||||
need.enters.clone(),
|
||||
format!("entry page {:?} does not exist", need.enters),
|
||||
),
|
||||
Some(page) if !visible(page, need.enters_as.as_deref()) => report(
|
||||
Level::Fail,
|
||||
"entry-gated",
|
||||
@@ -433,7 +477,10 @@ pub fn check(
|
||||
Level::Warn,
|
||||
"entry-no-responsible",
|
||||
need.enters.clone(),
|
||||
format!("entry page {:?} names no one responsible to turn to", need.enters),
|
||||
format!(
|
||||
"entry page {:?} names no one responsible to turn to",
|
||||
need.enters
|
||||
),
|
||||
);
|
||||
}
|
||||
let best = entry_viable(questions, need, false)
|
||||
@@ -515,7 +562,10 @@ pub fn check(
|
||||
Level::Fail,
|
||||
"machine-missing",
|
||||
need.lands_in.clone(),
|
||||
format!("aggregates.yaml declares no state machine for {:?}", need.lands_in),
|
||||
format!(
|
||||
"aggregates.yaml declares no state machine for {:?}",
|
||||
need.lands_in
|
||||
),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
@@ -523,10 +573,15 @@ pub fn check(
|
||||
let own: Vec<&Desk> = desks
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g))
|
||||
d.group.as_deref().is_some_and(|g| {
|
||||
g == need.handled_by || need.also_moved_by.iter().any(|m| m == g)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) {
|
||||
if !desks
|
||||
.iter()
|
||||
.any(|d| d.group.as_deref() == Some(need.handled_by.as_str()))
|
||||
{
|
||||
let others: Vec<String> = desks
|
||||
.iter()
|
||||
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
|
||||
@@ -560,7 +615,11 @@ pub fn check(
|
||||
moves.extend(self_moves(questions, &need.lands_in, &schema.initial));
|
||||
moves.extend(clock_moves(schema));
|
||||
let exits = |state: &str| -> Vec<String> {
|
||||
moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect()
|
||||
moves
|
||||
.iter()
|
||||
.filter(|(from, _)| from == state)
|
||||
.map(|(_, to)| to.clone())
|
||||
.collect()
|
||||
};
|
||||
let movers = std::iter::once(need.handled_by.as_str())
|
||||
.chain(need.also_moved_by.iter().map(String::as_str))
|
||||
@@ -573,7 +632,9 @@ pub fn check(
|
||||
let elsewhere = offered_elsewhere(&desks, from, to);
|
||||
let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk");
|
||||
if elsewhere.is_empty() {
|
||||
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}")
|
||||
format!(
|
||||
"add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}",
|
||||
@@ -627,7 +688,10 @@ pub fn check(
|
||||
Level::Fail,
|
||||
"done-unknown",
|
||||
state.clone(),
|
||||
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
|
||||
format!(
|
||||
"done_when names {state:?}, which {:?} has no such state",
|
||||
need.lands_in
|
||||
),
|
||||
);
|
||||
} else if !reached.contains(state) {
|
||||
unreached_reported.insert((need.lands_in.clone(), state.clone()));
|
||||
@@ -678,7 +742,10 @@ pub fn check(
|
||||
if done.contains(before.as_str()) || done.contains(after.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) {
|
||||
if reached.contains(before)
|
||||
&& reached.contains(after)
|
||||
&& !reachable(before, &moves).contains(after)
|
||||
{
|
||||
report(
|
||||
Level::Fail,
|
||||
"stage-order",
|
||||
@@ -724,7 +791,13 @@ pub fn check(
|
||||
}
|
||||
}
|
||||
|
||||
findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported));
|
||||
findings.extend(operations(
|
||||
file,
|
||||
questions,
|
||||
aggregates,
|
||||
&strands_reported,
|
||||
&unreached_reported,
|
||||
));
|
||||
|
||||
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
|
||||
for persona in &file.personas {
|
||||
@@ -735,7 +808,10 @@ pub fn check(
|
||||
code: "persona-unknown-need",
|
||||
subject: persona.id.clone(),
|
||||
level: Level::Fail,
|
||||
message: format!("persona {:?} points at a need that is not declared", persona.id),
|
||||
message: format!(
|
||||
"persona {:?} points at a need that is not declared",
|
||||
persona.id
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -761,7 +837,9 @@ fn operations(
|
||||
buckets.sort();
|
||||
buckets.dedup();
|
||||
for bucket in buckets {
|
||||
let Some(schema) = aggregates.get(bucket) else { continue };
|
||||
let Some(schema) = aggregates.get(bucket) else {
|
||||
continue;
|
||||
};
|
||||
let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect();
|
||||
// Only open needs make a gap fail; a bucket that only planned
|
||||
// or paused needs use reports it as a warning.
|
||||
@@ -791,18 +869,29 @@ fn operations(
|
||||
// for the bucket's first need.
|
||||
let home = needs
|
||||
.iter()
|
||||
.find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str())))
|
||||
.find_map(|n| {
|
||||
desks
|
||||
.iter()
|
||||
.find(|d| d.group.as_deref() == Some(n.handled_by.as_str()))
|
||||
})
|
||||
.map(|d| d.page.clone())
|
||||
.unwrap_or_else(|| "the handling group's desk".to_string());
|
||||
let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}");
|
||||
let button = |from: &str, to: &str| {
|
||||
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}")
|
||||
};
|
||||
|
||||
let mut states: Vec<&String> = schema.event_for_state.keys().collect();
|
||||
states.sort();
|
||||
for state in &states {
|
||||
if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||
if reached.contains(*state)
|
||||
|| unreached_reported.contains(&(bucket.to_string(), (*state).clone()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect();
|
||||
let mut ways: Vec<&String> = reached
|
||||
.iter()
|
||||
.filter(|from| schema.allowed(from).contains(state))
|
||||
.collect();
|
||||
ways.sort();
|
||||
report(
|
||||
Level::Fail,
|
||||
@@ -875,17 +964,26 @@ fn operations(
|
||||
.flat_map(|f| &f.requirements)
|
||||
.map(|r| (r.name.as_str(), r.kind.as_str()))
|
||||
.collect();
|
||||
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
|
||||
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> =
|
||||
schema.mail_for_state.iter().collect();
|
||||
mail_states.sort_by(|a, b| a.0.cmp(b.0));
|
||||
for (state, mail) in mail_states {
|
||||
if !reached.contains(state) {
|
||||
report(Level::Warn, "mail-unreachable", state.clone(), format!("the mail on entering {state:?} never goes out: no record reaches {state:?} - fix: make {state:?} reachable, or drop its mail"));
|
||||
}
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
|
||||
for key in placeholders(&mail.subject)
|
||||
.into_iter()
|
||||
.chain(placeholders(&mail.body))
|
||||
{
|
||||
let known = match key.split_once('.') {
|
||||
Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"),
|
||||
None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key),
|
||||
Some((parent, _)) => fields
|
||||
.iter()
|
||||
.any(|(name, kind)| *name == parent && *kind == "record"),
|
||||
None => {
|
||||
MAIL_BUILTINS.contains(&key.as_str())
|
||||
|| fields.iter().any(|(name, _)| *name == key)
|
||||
}
|
||||
};
|
||||
if !known && !unknown.contains(&key) {
|
||||
unknown.push(key);
|
||||
@@ -913,7 +1011,17 @@ fn operations(
|
||||
|
||||
/// What the mailer fills in on every state mail, whatever the record
|
||||
/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`).
|
||||
const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"];
|
||||
const MAIL_BUILTINS: &[&str] = &[
|
||||
"site",
|
||||
"site_name",
|
||||
"chain",
|
||||
"email",
|
||||
"bucket",
|
||||
"state",
|
||||
"link",
|
||||
"self_link",
|
||||
"desk_link",
|
||||
];
|
||||
|
||||
/// The `{key}`s in a mail template, read the way `mail::render` reads
|
||||
/// them: letters, digits, `_`, `-` and `.`, closed by `}`.
|
||||
@@ -1011,7 +1119,10 @@ pub fn sim_model(
|
||||
// The simulator's shape: the states a visitor's own link
|
||||
// moves to (it does not model where from).
|
||||
let mut self_move_targets: Vec<String> =
|
||||
self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect();
|
||||
self_moves(questions, &need.lands_in, &schema.initial)
|
||||
.into_iter()
|
||||
.map(|(_, to)| to)
|
||||
.collect();
|
||||
self_move_targets.dedup();
|
||||
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
||||
json!({
|
||||
@@ -1116,23 +1227,39 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
|
||||
};
|
||||
for (i, step) in path.iter().enumerate() {
|
||||
let page = &questions[&step.page];
|
||||
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect();
|
||||
let open: Vec<&Alternative> = page
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| !a.disabled && leads_somewhere(a))
|
||||
.collect();
|
||||
if open.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let context = page_context(page);
|
||||
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)
|
||||
.into_iter()
|
||||
.map(|v| v.alternative)
|
||||
.collect();
|
||||
let expect: Vec<String> = viable(
|
||||
questions,
|
||||
need,
|
||||
page,
|
||||
need.max_steps - i,
|
||||
i == 0,
|
||||
false,
|
||||
&mut trail,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|v| v.alternative)
|
||||
.collect();
|
||||
let criteria = open
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let description = plain(&a.description);
|
||||
(
|
||||
a.name.clone(),
|
||||
if skim || description.is_empty() { a.name.clone() } else { description },
|
||||
if skim || description.is_empty() {
|
||||
a.name.clone()
|
||||
} else {
|
||||
description
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1273,7 +1400,10 @@ mod tests {
|
||||
fn effort_budget_counts_required_fields_only() {
|
||||
let (questions, aggregates) = site(FULL_DESK);
|
||||
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);
|
||||
@@ -1292,7 +1422,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_desk_missing_a_move_strands_records_and_cannot_finish() {
|
||||
// The desk can quote, but never close a quoted job.
|
||||
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
|
||||
let (questions, aggregates) = site(
|
||||
" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
@@ -1344,8 +1476,12 @@ mod tests {
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(fields[2]["optional"], true);
|
||||
// The desk's buttons, labelled, per state.
|
||||
let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap();
|
||||
assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
|
||||
let moves = model["buckets"]["projects"]["desks"][0]["moves"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert!(moves
|
||||
.iter()
|
||||
.any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
|
||||
assert_eq!(model["buckets"]["projects"]["initial"], "open");
|
||||
assert_eq!(model["personas"][0]["id"], "founder");
|
||||
|
||||
@@ -1369,7 +1505,9 @@ mod tests {
|
||||
fn a_need_can_be_carried_by_more_than_one_groups_desk() {
|
||||
// owners may only quote or lose; closing a quoted job is the
|
||||
// site managers' button, on a desk of their own.
|
||||
let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
|
||||
let (mut questions, aggregates) = site(
|
||||
" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n",
|
||||
);
|
||||
let more = build_questions(&[(
|
||||
"site/index.yaml".to_string(),
|
||||
"name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(),
|
||||
@@ -1385,7 +1523,8 @@ mod tests {
|
||||
assert!(check(&file, &questions, &aggregates).is_empty());
|
||||
// But someone has to be answerable: handled_by needs its own desk.
|
||||
file.needs[0].handled_by = "nobody".to_string();
|
||||
assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\""));
|
||||
assert!(fails(&check(&file, &questions, &aggregates))[0]
|
||||
.starts_with("no desk gated on \"nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1418,7 +1557,10 @@ mod tests {
|
||||
let file = needs("");
|
||||
let tasks = tasks(&file, &questions, false);
|
||||
let skimmed = super::tasks(&file, &questions, true);
|
||||
assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along");
|
||||
assert_eq!(
|
||||
skimmed[0].questions["pick"].criteria["Follow along"],
|
||||
"Follow along"
|
||||
);
|
||||
assert_eq!(tasks.len(), 1);
|
||||
let task = &tasks[0];
|
||||
assert_eq!(task.id, "founder@/");
|
||||
@@ -1428,11 +1570,19 @@ mod tests {
|
||||
assert_eq!(q.criteria["Follow along"], "Notes by mail.");
|
||||
assert!(q.instructions.contains("What are you here for?"));
|
||||
|
||||
let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }];
|
||||
let right = vec![Answer {
|
||||
id: "founder@/".into(),
|
||||
choice: "Hire us".into(),
|
||||
confidence: Some(0.55),
|
||||
}];
|
||||
let s = score(&tasks, &right, 0.6);
|
||||
assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1));
|
||||
|
||||
let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }];
|
||||
let wrong = vec![Answer {
|
||||
id: "founder@/".into(),
|
||||
choice: "Follow along".into(),
|
||||
confidence: Some(0.9),
|
||||
}];
|
||||
let s = score(&tasks, &wrong, 0.6);
|
||||
assert_eq!((s.correct, s.misroutes.len()), (0, 1));
|
||||
assert_eq!(s.accuracy(), 0.0);
|
||||
@@ -1442,7 +1592,10 @@ mod tests {
|
||||
|
||||
/// The fixture site with its aggregates replaced, for the checks
|
||||
/// that are about the state machine rather than the pages.
|
||||
fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap<String, AggregateSchema>) {
|
||||
fn site_with(
|
||||
desk_moves: &str,
|
||||
projects: &str,
|
||||
) -> (Questions, HashMap<String, AggregateSchema>) {
|
||||
let (questions, _) = site(desk_moves);
|
||||
let aggregates = parse_aggregates_yaml(&format!(
|
||||
"aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n"
|
||||
@@ -1452,7 +1605,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn warns(findings: &[Finding]) -> Vec<&str> {
|
||||
findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect()
|
||||
findings
|
||||
.iter()
|
||||
.filter(|f| f.level == Level::Warn)
|
||||
.map(|f| f.message.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1507,7 +1664,11 @@ mod tests {
|
||||
" - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n",
|
||||
" initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates);
|
||||
let findings = check(
|
||||
&needs(" stages: [open, surveyed, quoted, won, lost]\n"),
|
||||
&questions,
|
||||
&aggregates,
|
||||
);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - fix: add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""]
|
||||
@@ -1540,14 +1701,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_finding_keeps_its_id_when_reworded_and_says_its_fix() {
|
||||
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
|
||||
let (questions, aggregates) = site(
|
||||
" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
let strand = findings.iter().find(|f| f.code == "strand").unwrap();
|
||||
assert_eq!(strand.subject, "quoted");
|
||||
assert_eq!(strand.fix(), Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk"));
|
||||
let reworded = Finding { message: "something else entirely".into(), ..clone_of(strand) };
|
||||
assert_eq!(
|
||||
strand.fix(),
|
||||
Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk")
|
||||
);
|
||||
let reworded = Finding {
|
||||
message: "something else entirely".into(),
|
||||
..clone_of(strand)
|
||||
};
|
||||
assert_eq!(reworded.id(), strand.id());
|
||||
let elsewhere = Finding { subject: "open".into(), ..clone_of(strand) };
|
||||
let elsewhere = Finding {
|
||||
subject: "open".into(),
|
||||
..clone_of(strand)
|
||||
};
|
||||
assert_ne!(elsewhere.id(), strand.id());
|
||||
let json = strand.to_json();
|
||||
assert_eq!(json["code"], "strand");
|
||||
@@ -1556,6 +1728,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn clone_of(f: &Finding) -> Finding {
|
||||
Finding { need: f.need.clone(), level: f.level, message: f.message.clone(), scope: f.scope, code: f.code, subject: f.subject.clone() }
|
||||
Finding {
|
||||
need: f.need.clone(),
|
||||
level: f.level,
|
||||
message: f.message.clone(),
|
||||
scope: f.scope,
|
||||
code: f.code,
|
||||
subject: f.subject.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user