Skip to main content

cratefield_testing/
port.rs

1//! Port-only conformance helpers (issue #201): checks over a single port
2//! trait that need nothing beyond `cratefield-core`. Gated behind the
3//! light `port-conformance` feature so an adapter whose whole use of the
4//! kit is one free function does not pay for the harness, the router or
5//! a database adapter.
6//!
7//! The helpers that grew heavier stayed in [`crate::conformance`]
8//! (behind `harness`): the module suite needs the `TestHarness`, and the
9//! fakes were left there too because `FakeHttpClient` reads axum bodies.
10
11use std::collections::BTreeMap;
12
13use cratefield_core::{
14    Answer, AnswerValue, Classifier, ClassifierError, PushError, Question, validate_questions,
15};
16
17/// The three recipients an adapter can be handed, one per transport.
18///
19/// Every `Recipient` variant appears here, so a new transport added to the
20/// port makes this list — and therefore every adapter's conformance run —
21/// fail to compile until it is decided what the existing adapters answer.
22fn every_recipient() -> Vec<cratefield_core::Recipient> {
23    let all = [
24        cratefield_core::Recipient::apns("conformance-device-token"),
25        cratefield_core::Recipient::fcm("conformance-registration-token"),
26        cratefield_core::Recipient::web_push(
27            "https://push.example.test/conformance",
28            "BConformanceP256dhKey",
29            "ConformanceAuthKey",
30        ),
31    ];
32    for recipient in &all {
33        // Exhaustive by construction: adding a variant breaks this match.
34        match recipient {
35            cratefield_core::Recipient::Apns { .. }
36            | cratefield_core::Recipient::Fcm { .. }
37            | cratefield_core::Recipient::WebPush { .. } => {}
38        }
39    }
40    all.to_vec()
41}
42
43/// Runs a [`Push`](cratefield_core::Push) adapter against **every**
44/// [`Recipient`](cratefield_core::Recipient) variant and asserts the port's
45/// contract for transports it does not serve (issue #177): a clean
46/// [`PushError::Rejected`](cratefield_core::PushError::Rejected) naming the
47/// recipient as unsupported — never a panic, never a silent success, and
48/// never a `Transient` that an outbox would retry forever.
49///
50/// `serves` lists the transports this adapter does speak; those must answer
51/// something other than an unsupported-recipient rejection.
52///
53/// ```rust,ignore
54/// // The APNs adapter serves iOS and nothing else.
55/// push_recipient_conformance(&apns, &[Platform::Ios]).await;
56/// ```
57///
58/// # Panics
59///
60/// Panics with the failing recipient named when the contract is broken.
61pub async fn push_recipient_conformance(
62    push: &dyn cratefield_core::Push,
63    serves: &[cratefield_core::Platform],
64) {
65    let notification = cratefield_core::Notification::new("conformance", "probe");
66    for recipient in every_recipient() {
67        let platform = recipient.platform();
68        let result = push.send(&recipient, &notification).await;
69        let unsupported = matches!(
70            &result,
71            Err(PushError::Rejected(message)) if message.contains("unsupported recipient")
72        );
73        if serves.contains(&platform) {
74            assert!(
75                !unsupported,
76                "adapter claims to serve {platform} but rejected its recipient as unsupported"
77            );
78        } else {
79            assert!(
80                unsupported,
81                "adapter does not serve {platform}: the port's answer is \
82                 PushError::Rejected(\"unsupported recipient…\"), got {result:?}"
83            );
84        }
85    }
86}
87
88/// Asserts the named crate's normal dependency tree carries none of the
89/// dependencies a wasm module must never see: `worker`, `wasm-bindgen`,
90/// `tokio`, `reqwest`. Shell-out check (`cargo tree --edges normal`) so
91/// the assertion is made against the graph cargo actually resolves, not
92/// a copy of it that could drift.
93///
94/// # Panics
95///
96/// Panics when a forbidden dependency is found or cargo fails.
97pub fn assert_wasm_safe_deps(module_crate: &str) {
98    let output =
99        std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
100            .args(["tree", "-p", module_crate, "--edges", "normal"])
101            .output()
102            .unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
103    assert!(
104        output.status.success(),
105        "cargo tree failed for {module_crate}"
106    );
107    let tree = String::from_utf8_lossy(&output.stdout);
108    for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
109        assert!(
110            !tree.contains(forbidden),
111            "{module_crate} pulls forbidden dependency `{}`:\n{tree}",
112            forbidden.trim_end_matches(" v")
113        );
114    }
115}
116
117// ---------------------------------------------------------------------------
118// The `Classifier` port suite (issue #456)
119
120/// How far a reported `confidence` may sit from the probability the same
121/// answer reports for its own chosen label and still count as agreeing
122/// (`f32` arithmetic across an adapter's normalisation is not exact).
123const CONFIDENCE_TOLERANCE: f32 = 1e-4;
124
125/// The canonical question set the conformance suite asks: one
126/// [`Question::Choice`], one [`Question::Score`], one [`Question::Noul`],
127/// so every shape of the port is exercised once. The ids are fixed —
128/// `topic`, `severity`, `urgent` — and an adapter test scripts its
129/// transport to answer **exactly these** before running the suite.
130#[must_use]
131pub fn classifier_conformance_questions() -> BTreeMap<String, Question> {
132    BTreeMap::from([
133        (
134            "topic".to_owned(),
135            Question::Choice {
136                instructions: "Which topic does the state belong to?".to_owned(),
137                criteria: BTreeMap::from([
138                    ("billing".to_owned(), "money, invoices".to_owned()),
139                    ("bugs".to_owned(), "something is broken".to_owned()),
140                ]),
141            },
142        ),
143        (
144            "severity".to_owned(),
145            Question::Score {
146                instructions: "How severe is the state?".to_owned(),
147                levels: vec![
148                    ("1".to_owned(), "a typo".to_owned()),
149                    ("5".to_owned(), "data loss".to_owned()),
150                ],
151            },
152        ),
153        (
154            "urgent".to_owned(),
155            Question::Noul {
156                instructions: "Does the state need attention now?".to_owned(),
157            },
158        ),
159    ])
160}
161
162/// The state the conformance suite asks about: short and fixed, so a
163/// scripted transport can match it.
164#[must_use]
165pub fn classifier_conformance_state() -> &'static str {
166    "cratefield classifier conformance state"
167}
168
169/// Asserts one answer agrees with the question it answers. The rules are
170/// numbered as `classifier_conformance` documents them, and every panic
171/// names the rule it caught.
172fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
173    let labels = question.labels();
174
175    // Rule 2: the value's variant matches the question's kind.
176    let chosen: Option<String> = match (question, &answer.value) {
177        (Question::Choice { .. }, AnswerValue::Choice(label)) => {
178            // Rule 3: the chosen label is one the question offered.
179            assert!(
180                labels.contains(&label.as_str()),
181                "rule 3 (a choice is of an offered label): question {id:?} offers {labels:?}, \
182                 the answer chose {label:?}"
183            );
184            Some(label.clone())
185        }
186        (Question::Score { .. }, AnswerValue::Score(value)) => {
187            // Rule 3, as far as the port can express it: a score whose
188            // value names one of the levels names it as its label. A
189            // provider that interpolates (`3.5` on a `"1"`/`"5"` scale)
190            // states its confidence through `Answer::new` instead, and no
191            // chosen label is assertable there.
192            let label = value.to_string();
193            if labels.contains(&label.as_str()) {
194                Some(label)
195            } else {
196                None
197            }
198        }
199        (Question::Noul { .. }, AnswerValue::Noul(verdict)) => {
200            Some(if *verdict { "true" } else { "false" }.to_owned())
201        }
202        (kind, value) => panic!(
203            "rule 2 (the answer's shape matches the question's): question {id:?} is {}, \
204             the answer carried {value:?}",
205            kind.kind()
206        ),
207    };
208
209    // Rule 4: every probability is keyed by an offered label, and is a
210    // finite number within 0.0..=1.0. Keys are a subset on purpose: the
211    // port allows a provider that reports only a top-1.
212    for (label, probability) in &answer.probabilities {
213        assert!(
214            labels.contains(&label.as_str()),
215            "rule 4 (probabilities are keyed by offered labels): question {id:?} offers \
216             {labels:?}, the answer reported {label:?}"
217        );
218        assert!(
219            probability.is_finite() && (0.0..=1.0).contains(probability),
220            "rule 4 (a probability is finite and within 0.0..=1.0): question {id:?} reported \
221             {label:?} = {probability}"
222        );
223    }
224
225    // Rule 5: `confidence` is finite, within 0.0..=1.0, and agrees with
226    // the probability the same answer reports for its own chosen label.
227    assert!(
228        answer.confidence.is_finite() && (0.0..=1.0).contains(&answer.confidence),
229        "rule 5 (a confidence is finite and within 0.0..=1.0): question {id:?} reported \
230         confidence {}",
231        answer.confidence
232    );
233    if let Some(label) = chosen {
234        match answer.probabilities.get(&label) {
235            Some(probability) => assert!(
236                (answer.confidence - probability).abs() <= CONFIDENCE_TOLERANCE,
237                "rule 5 (confidence agrees with the distribution for the chosen label): \
238                 question {id:?} chose {label:?} at {probability} but reported confidence {}",
239                answer.confidence
240            ),
241            // An absent label is the port constructors' 0.0: consistent
242            // only when the confidence says the same.
243            None => assert!(
244                answer.confidence.abs() <= CONFIDENCE_TOLERANCE,
245                "rule 5 (confidence agrees with the distribution for the chosen label): \
246                 question {id:?} chose {label:?}, which its probabilities omit, so the only \
247                 agreeing confidence is 0.0, got {}",
248                answer.confidence
249            ),
250        }
251    }
252}
253
254/// Asserts the `Classifier` trait contract against a classifier whose
255/// transport is already scripted to answer
256/// [`classifier_conformance_questions`] about
257/// [`classifier_conformance_state`]. The suite makes **exactly one**
258/// `ask` call — script that one request and no other.
259///
260/// Asserted, each panic naming the rule it caught:
261///
262/// 1. the answer map's keys are **exactly** the asked question ids;
263/// 2. each answer's `value` variant matches its question's kind;
264/// 3. a choice's chosen label, a noul's verdict label, and (where the
265///    value names one) a score's level are labels the question offered;
266/// 4. every key of `probabilities` is an offered label and every value is
267///    finite within `0.0..=1.0` — keys may omit labels (a top-1 provider
268///    is legal), they may never invent one;
269/// 5. `confidence` is finite within `0.0..=1.0` and agrees with the
270///    probability the same answer reports for its own chosen label,
271///    within an explicit tolerance — the invariant that keeps an adapter
272///    from reporting a confidence its own distribution contradicts;
273/// 6. `profile().max_state_chars` is above zero, and `truncate` of an
274///    over-limit state returns something no longer than the limit.
275///
276/// What the suite deliberately does **not** assert: that `confidence`
277/// means the same number on two adapters. Calibration is not portable —
278/// a `0.8` from a purpose-trained classifier and a `0.8` elicited from a
279/// general language model are different numbers — so no assertion here
280/// compares confidence values between adapters, and a threshold tuned
281/// against one stays per-adapter (see `cratefield_core::Calibration`).
282///
283/// ```rust,ignore
284/// // In the adapter's own test, after scripting its transport:
285/// classifier_conformance(&adapter).await;
286/// ```
287///
288/// # Panics
289///
290/// Panics naming the broken rule when the contract is violated, and when
291/// the scripted ask fails.
292pub async fn classifier_conformance(classifier: &dyn Classifier) {
293    // Rule 6: the profile is usable and `truncate` honours its own limit,
294    // in both directions. Needs no transport.
295    let profile = classifier.profile();
296    assert!(
297        profile.max_state_chars > 0,
298        "rule 6 (the state ceiling is above zero): max_state_chars is {}",
299        profile.max_state_chars
300    );
301    let over_limit = "x".repeat(profile.max_state_chars + 1);
302    let (sent, dropped) = profile.truncate(&over_limit);
303    assert!(
304        dropped,
305        "rule 6 (truncate reports what it dropped): a state over the limit came back \
306         with dropped = false"
307    );
308    assert!(
309        sent.len() <= profile.max_state_chars,
310        "rule 6 (truncate honours the limit): a state of {} chars came back as {} over a \
311         limit of {}",
312        over_limit.len(),
313        sent.len(),
314        profile.max_state_chars
315    );
316    let (untouched, dropped) = profile.truncate(classifier_conformance_state());
317    assert!(
318        !dropped && untouched == classifier_conformance_state(),
319        "rule 6 (truncate is a no-op under the limit): the canonical state was trimmed"
320    );
321
322    // The one ask the suite makes.
323    let questions = classifier_conformance_questions();
324    let answers = classifier
325        .ask(classifier_conformance_state(), &questions)
326        .await
327        .unwrap_or_else(|error| {
328            panic!(
329                "the scripted ask failed — script the transport to answer \
330                 classifier_conformance_questions() about classifier_conformance_state(): \
331                 {error:?}"
332            )
333        });
334
335    // Rule 1: the keys are exactly the asked ids — no missing, no extra.
336    let missing: Vec<&String> = questions
337        .keys()
338        .filter(|id| !answers.contains_key(*id))
339        .collect();
340    let extra: Vec<&String> = answers
341        .keys()
342        .filter(|id| !questions.contains_key(*id))
343        .collect();
344    assert!(
345        missing.is_empty() && extra.is_empty(),
346        "rule 1 (the answers are keyed exactly as the questions were asked): missing \
347         {missing:?}, extra {extra:?}"
348    );
349
350    // Rules 2-5, per question.
351    for (id, question) in &questions {
352        assert_answer_conforms(id, question, &answers[id]);
353    }
354}
355
356/// Asserts the contract that needs no transport: a malformed question set
357/// — empty, blank id, blank instructions, a one-criterion choice, a
358/// one-level score — comes back as
359/// [`ClassifierError::Rejected`](cratefield_core::ClassifierError::Rejected)
360/// and never panics. Safe to run against an unconfigured adapter: the
361/// set is refused before any provider is reached.
362///
363/// # Panics
364///
365/// Panics when a malformed set is answered, or refused as anything but
366/// `Rejected`.
367pub async fn classifier_rejects_malformed_questions(classifier: &dyn Classifier) {
368    let malformed: [BTreeMap<String, Question>; 5] = [
369        BTreeMap::new(),
370        BTreeMap::from([(
371            "   ".to_owned(),
372            Question::Noul {
373                instructions: "A blank id is malformed.".to_owned(),
374            },
375        )]),
376        BTreeMap::from([(
377            "blank-instructions".to_owned(),
378            Question::Noul {
379                instructions: "  ".to_owned(),
380            },
381        )]),
382        BTreeMap::from([(
383            "one-criterion".to_owned(),
384            Question::Choice {
385                instructions: "A choice needs two criteria.".to_owned(),
386                criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
387            },
388        )]),
389        BTreeMap::from([(
390            "one-level".to_owned(),
391            Question::Score {
392                instructions: "A score needs two levels.".to_owned(),
393                levels: vec![("3".to_owned(), "middling".to_owned())],
394            },
395        )]),
396    ];
397    for questions in &malformed {
398        // The port's own validator refuses these five; if it ever stops
399        // to, this suite's list has to change with it — the guard keeps
400        // the check from passing vacuously.
401        assert!(
402            validate_questions(questions).is_err(),
403            "the suite's malformed sets stopped being malformed — update this suite \
404             alongside cratefield_core::validate_questions"
405        );
406        let result = classifier
407            .ask(classifier_conformance_state(), questions)
408            .await;
409        assert!(
410            matches!(result, Err(ClassifierError::Rejected(_))),
411            "a malformed question set is Rejected, never a panic and never an answer: got \
412             {result:?}"
413        );
414    }
415}
416
417/// Asserts an adapter with no key, binding or model behind it answers
418/// [`ClassifierError::NotConfigured`](cratefield_core::ClassifierError::NotConfigured)
419/// rather than panicking — the unwired port is an error the caller
420/// matches, so a module can degrade on it.
421///
422/// # Panics
423///
424/// Panics when the ask succeeds or fails as anything but `NotConfigured`.
425pub async fn classifier_not_configured(classifier: &dyn Classifier) {
426    let result = classifier
427        .ask(
428            classifier_conformance_state(),
429            &classifier_conformance_questions(),
430        )
431        .await;
432    assert_eq!(
433        result.err(),
434        Some(ClassifierError::NotConfigured),
435        "an adapter with no key/binding/model answers NotConfigured"
436    );
437}
438
439/// Asserts that asking with a `state` longer than
440/// `profile().max_state_chars` still succeeds — truncation is the
441/// adapter's job, so an over-limit state must never surface as an error
442/// to the caller.
443///
444/// A separate, opt-in check because it asks a **second** time: an
445/// adapter test whose transport matches one scripted request exactly can
446/// run `classifier_conformance` alone, and add this when its mock can
447/// take the second call.
448///
449/// # Panics
450///
451/// Panics when the over-limit ask fails or its answers break the same
452/// per-answer rules `classifier_conformance` asserts.
453pub async fn classifier_truncates_long_state(classifier: &dyn Classifier) {
454    let questions = classifier_conformance_questions();
455    let profile = classifier.profile();
456    let long_state = classifier_conformance_state().repeat(profile.max_state_chars + 1);
457
458    let answers = classifier
459        .ask(&long_state, &questions)
460        .await
461        .unwrap_or_else(|error| {
462            panic!(
463                "an over-limit state still gets answers — truncation is the adapter's job, not \
464             the caller's: {error:?}"
465            )
466        });
467    for (id, question) in &questions {
468        let answer = answers.get(id).unwrap_or_else(|| {
469            panic!(
470                "rule 1 (the answers are keyed exactly as the questions were asked): the \
471                 over-limit ask dropped {id:?}"
472            )
473        });
474        assert_answer_conforms(id, question, answer);
475    }
476}