use std::collections::BTreeMap;
use cratefield_core::{
Answer, AnswerValue, Classifier, ClassifierError, PushError, Question, validate_questions,
};
fn every_recipient() -> Vec<cratefield_core::Recipient> {
let all = [
cratefield_core::Recipient::apns("conformance-device-token"),
cratefield_core::Recipient::fcm("conformance-registration-token"),
cratefield_core::Recipient::web_push(
"https://push.example.test/conformance",
"BConformanceP256dhKey",
"ConformanceAuthKey",
),
];
for recipient in &all {
match recipient {
cratefield_core::Recipient::Apns { .. }
| cratefield_core::Recipient::Fcm { .. }
| cratefield_core::Recipient::WebPush { .. } => {}
}
}
all.to_vec()
}
pub async fn push_recipient_conformance(
push: &dyn cratefield_core::Push,
serves: &[cratefield_core::Platform],
) {
let notification = cratefield_core::Notification::new("conformance", "probe");
for recipient in every_recipient() {
let platform = recipient.platform();
let result = push.send(&recipient, ¬ification).await;
let unsupported = matches!(
&result,
Err(PushError::Rejected(message)) if message.contains("unsupported recipient")
);
if serves.contains(&platform) {
assert!(
!unsupported,
"adapter claims to serve {platform} but rejected its recipient as unsupported"
);
} else {
assert!(
unsupported,
"adapter does not serve {platform}: the port's answer is \
PushError::Rejected(\"unsupported recipient…\"), got {result:?}"
);
}
}
}
pub fn assert_wasm_safe_deps(module_crate: &str) {
let output =
std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["tree", "-p", module_crate, "--edges", "normal"])
.output()
.unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
assert!(
output.status.success(),
"cargo tree failed for {module_crate}"
);
let tree = String::from_utf8_lossy(&output.stdout);
for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
assert!(
!tree.contains(forbidden),
"{module_crate} pulls forbidden dependency `{}`:\n{tree}",
forbidden.trim_end_matches(" v")
);
}
}
const CONFIDENCE_TOLERANCE: f32 = 1e-4;
#[must_use]
pub fn classifier_conformance_questions() -> BTreeMap<String, Question> {
BTreeMap::from([
(
"topic".to_owned(),
Question::Choice {
instructions: "Which topic does the state belong to?".to_owned(),
criteria: BTreeMap::from([
("billing".to_owned(), "money, invoices".to_owned()),
("bugs".to_owned(), "something is broken".to_owned()),
]),
},
),
(
"severity".to_owned(),
Question::Score {
instructions: "How severe is the state?".to_owned(),
levels: vec![
("1".to_owned(), "a typo".to_owned()),
("5".to_owned(), "data loss".to_owned()),
],
},
),
(
"urgent".to_owned(),
Question::Noul {
instructions: "Does the state need attention now?".to_owned(),
},
),
])
}
#[must_use]
pub fn classifier_conformance_state() -> &'static str {
"cratefield classifier conformance state"
}
fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
let labels = question.labels();
let chosen: Option<String> = match (question, &answer.value) {
(Question::Choice { .. }, AnswerValue::Choice(label)) => {
assert!(
labels.contains(&label.as_str()),
"rule 3 (a choice is of an offered label): question {id:?} offers {labels:?}, \
the answer chose {label:?}"
);
Some(label.clone())
}
(Question::Score { .. }, AnswerValue::Score(value)) => {
let label = value.to_string();
if labels.contains(&label.as_str()) {
Some(label)
} else {
None
}
}
(Question::Noul { .. }, AnswerValue::Noul(verdict)) => {
Some(if *verdict { "true" } else { "false" }.to_owned())
}
(kind, value) => panic!(
"rule 2 (the answer's shape matches the question's): question {id:?} is {}, \
the answer carried {value:?}",
kind.kind()
),
};
for (label, probability) in &answer.probabilities {
assert!(
labels.contains(&label.as_str()),
"rule 4 (probabilities are keyed by offered labels): question {id:?} offers \
{labels:?}, the answer reported {label:?}"
);
assert!(
probability.is_finite() && (0.0..=1.0).contains(probability),
"rule 4 (a probability is finite and within 0.0..=1.0): question {id:?} reported \
{label:?} = {probability}"
);
}
assert!(
answer.confidence.is_finite() && (0.0..=1.0).contains(&answer.confidence),
"rule 5 (a confidence is finite and within 0.0..=1.0): question {id:?} reported \
confidence {}",
answer.confidence
);
if let Some(label) = chosen {
match answer.probabilities.get(&label) {
Some(probability) => assert!(
(answer.confidence - probability).abs() <= CONFIDENCE_TOLERANCE,
"rule 5 (confidence agrees with the distribution for the chosen label): \
question {id:?} chose {label:?} at {probability} but reported confidence {}",
answer.confidence
),
None => assert!(
answer.confidence.abs() <= CONFIDENCE_TOLERANCE,
"rule 5 (confidence agrees with the distribution for the chosen label): \
question {id:?} chose {label:?}, which its probabilities omit, so the only \
agreeing confidence is 0.0, got {}",
answer.confidence
),
}
}
}
pub async fn classifier_conformance(classifier: &dyn Classifier) {
let profile = classifier.profile();
assert!(
profile.max_state_chars > 0,
"rule 6 (the state ceiling is above zero): max_state_chars is {}",
profile.max_state_chars
);
let over_limit = "x".repeat(profile.max_state_chars + 1);
let (sent, dropped) = profile.truncate(&over_limit);
assert!(
dropped,
"rule 6 (truncate reports what it dropped): a state over the limit came back \
with dropped = false"
);
assert!(
sent.len() <= profile.max_state_chars,
"rule 6 (truncate honours the limit): a state of {} chars came back as {} over a \
limit of {}",
over_limit.len(),
sent.len(),
profile.max_state_chars
);
let (untouched, dropped) = profile.truncate(classifier_conformance_state());
assert!(
!dropped && untouched == classifier_conformance_state(),
"rule 6 (truncate is a no-op under the limit): the canonical state was trimmed"
);
let questions = classifier_conformance_questions();
let answers = classifier
.ask(classifier_conformance_state(), &questions)
.await
.unwrap_or_else(|error| {
panic!(
"the scripted ask failed — script the transport to answer \
classifier_conformance_questions() about classifier_conformance_state(): \
{error:?}"
)
});
let missing: Vec<&String> = questions
.keys()
.filter(|id| !answers.contains_key(*id))
.collect();
let extra: Vec<&String> = answers
.keys()
.filter(|id| !questions.contains_key(*id))
.collect();
assert!(
missing.is_empty() && extra.is_empty(),
"rule 1 (the answers are keyed exactly as the questions were asked): missing \
{missing:?}, extra {extra:?}"
);
for (id, question) in &questions {
assert_answer_conforms(id, question, &answers[id]);
}
}
pub async fn classifier_rejects_malformed_questions(classifier: &dyn Classifier) {
let malformed: [BTreeMap<String, Question>; 5] = [
BTreeMap::new(),
BTreeMap::from([(
" ".to_owned(),
Question::Noul {
instructions: "A blank id is malformed.".to_owned(),
},
)]),
BTreeMap::from([(
"blank-instructions".to_owned(),
Question::Noul {
instructions: " ".to_owned(),
},
)]),
BTreeMap::from([(
"one-criterion".to_owned(),
Question::Choice {
instructions: "A choice needs two criteria.".to_owned(),
criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
},
)]),
BTreeMap::from([(
"one-level".to_owned(),
Question::Score {
instructions: "A score needs two levels.".to_owned(),
levels: vec![("3".to_owned(), "middling".to_owned())],
},
)]),
];
for questions in &malformed {
assert!(
validate_questions(questions).is_err(),
"the suite's malformed sets stopped being malformed — update this suite \
alongside cratefield_core::validate_questions"
);
let result = classifier
.ask(classifier_conformance_state(), questions)
.await;
assert!(
matches!(result, Err(ClassifierError::Rejected(_))),
"a malformed question set is Rejected, never a panic and never an answer: got \
{result:?}"
);
}
}
pub async fn classifier_not_configured(classifier: &dyn Classifier) {
let result = classifier
.ask(
classifier_conformance_state(),
&classifier_conformance_questions(),
)
.await;
assert_eq!(
result.err(),
Some(ClassifierError::NotConfigured),
"an adapter with no key/binding/model answers NotConfigured"
);
}
pub async fn classifier_truncates_long_state(classifier: &dyn Classifier) {
let questions = classifier_conformance_questions();
let profile = classifier.profile();
let long_state = classifier_conformance_state().repeat(profile.max_state_chars + 1);
let answers = classifier
.ask(&long_state, &questions)
.await
.unwrap_or_else(|error| {
panic!(
"an over-limit state still gets answers — truncation is the adapter's job, not \
the caller's: {error:?}"
)
});
for (id, question) in &questions {
let answer = answers.get(id).unwrap_or_else(|| {
panic!(
"rule 1 (the answers are keyed exactly as the questions were asked): the \
over-limit ask dropped {id:?}"
)
});
assert_answer_conforms(id, question, answer);
}
}