Skip to main content

day_core/
conformance.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Tests that run inside a built Day app (docs/testing.md): a [`Case`] is a page plus a drive,
5//! or a headless run, declared next to the code it tests with `#[day::test]` and collected in a
6//! registry. The dayscript engine runs them on a real toolkit under `day test`; the mock
7//! harness runs the same functions under `cargo test`. Nothing here knows how a step is
8//! executed: a [`DriveBackend`] does, and the engine supplies one.
9//!
10//! A test's name is its function's, with hyphens for underscores (`button_status` runs as
11//! `button-status`): the registry entry carries the identifier, so a tool reading the source
12//! (`day test --list`, an editor) and the app agree without either one running the other.
13
14use std::cell::RefCell;
15use std::future::Future;
16use std::pin::Pin;
17use std::rc::Rc;
18
19use day_spec::Cap;
20
21use crate::AnyPiece;
22
23/// A failed assertion, with the sentence the report shows.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct Fail(pub String);
26
27impl std::fmt::Display for Fail {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.write_str(&self.0)
30    }
31}
32
33impl From<String> for Fail {
34    fn from(s: String) -> Self {
35        Fail(s)
36    }
37}
38
39impl From<&str> for Fail {
40    fn from(s: &str) -> Self {
41        Fail(s.to_owned())
42    }
43}
44
45/// What a test's body answers.
46pub type TestResult = Result<(), Fail>;
47
48/// What a drive asks of the app, in dayscript's vocabulary. The engine maps each to the step
49/// a script would send, so a drive and a script mean the same thing by the same words.
50#[derive(Clone, Debug, PartialEq)]
51pub enum DriveOp {
52    Tap(String),
53    Input(String, String),
54    Toggle(String, bool),
55    SetValue(String, f64),
56    Select(String, usize),
57    Focus(String),
58    Submit(String),
59    Navigate(String),
60    WaitIdle,
61    Pause(f64),
62    /// Capture the window under this name into the test's record.
63    Shot(String),
64    AssertText(String, String),
65    AssertVisible(String),
66    AssertMissing(String),
67    /// That an element is not on screen: missing, empty, or natively hidden.
68    AssertHidden(String),
69    /// A pointer entering an element, at a point in it or (for `None`) its center.
70    Hover(String, Option<(f64, f64)>),
71    /// The pointer leaving an element.
72    HoverLeave(String),
73    /// A two-finger pan over an element, by this much in total.
74    Pan(String, f64, f64),
75    /// A pinch over an element, to this cumulative scale.
76    Pinch(String, f64),
77    /// A drag over an element, from one point to another in its coordinates.
78    Drag(String, (f64, f64), (f64, f64)),
79    /// A tap at a point in an element's coordinates.
80    TapAt(String, f64, f64),
81    /// A key press, to an element or (for `None`) to whatever holds focus.
82    Key(Option<String>, String),
83    /// That the app announced this text to the screen reader during the run.
84    AssertAnnounced(String),
85    AssertValue(String, f64),
86    /// A toggle's state.
87    AssertOn(String, bool),
88    AssertFocused(String, bool),
89    AssertRoute(String),
90    A11yAudit(Option<String>),
91    /// The native widget's own state, as the platform reports it.
92    AssertNative(String, NativeExpect),
93    /// An element's frame, in Day's layout and natively.
94    AssertFrame(String, FrameExpect),
95    /// The color at a point of an element (fractions of its frame), `#rrggbb`.
96    SamplePixel(String, f64, f64, String),
97    /// A URL the app asked to open during the run.
98    AssertOpenedUrl(String),
99    /// Activate a list's row (a double click, Return, a tap on the phones), by position.
100    Activate(String, usize),
101    /// Move a list's row from one position to another, as a drag-to-reorder commits it.
102    Reorder(String, usize, usize),
103    /// Delete a list's row, as the platform's delete gesture commits it.
104    DeleteRow(String, usize),
105    /// Press a row's swipe action: the list, the row, the edge (`true` = leading) and the
106    /// action's position in that edge's offer.
107    SwipeRow(String, usize, bool, usize),
108    /// Scroll a scroll view or list to an edge (`"top"`, `"bottom"`, `"leading"`, `"trailing"`).
109    ScrollTo(String, String),
110    /// Expand or collapse a tree's row, named by its row id.
111    Expand(String, String, bool),
112    /// Go back one page in the innermost navigation stack.
113    NavBack,
114    /// Move a tree's row (by row id) under a parent (`None` = the root) at a position.
115    TreeMove(String, String, Option<String>, Option<usize>),
116    /// Choose an item, by label, from an element's context menu.
117    ContextMenu(String, String),
118    /// Choose an app-menu item by its id.
119    Menu(String),
120    /// Press a toolbar button, by item id.
121    ToolbarPress(String),
122    /// Set a toolbar toggle, by item id.
123    ToolbarToggle(String, bool),
124    /// Answer presentations natively (`false`) or hold them for `respond` (`true`).
125    DialogScripted(bool),
126    /// That a presentation is pending, with this title where one is given.
127    AssertPresented(Option<String>),
128    /// Answer the pending presentation: a button by position, or dismiss it (`None`).
129    Respond(Option<usize>),
130    /// Confirm a prompt with text (including an empty string).
131    RespondText(String),
132}
133
134/// What [`Drive::assert_frame`] expects of an element's frame, in points; only the fields set
135/// are checked. `x`/`y` are relative to `relative_to`'s origin, or the window content's.
136#[derive(Clone, Debug, Default, PartialEq)]
137pub struct FrameExpect {
138    pub width: Option<f64>,
139    pub height: Option<f64>,
140    pub x: Option<f64>,
141    pub y: Option<f64>,
142    /// The element whose origin `x`/`y` are measured from.
143    pub relative_to: Option<String>,
144    /// How far each value may be off (default 1 point).
145    pub tolerance: Option<f64>,
146}
147
148/// What [`Drive::assert_native`] expects the native widget to report; only the fields set are
149/// checked (docs/testing.md).
150#[derive(Clone, Debug, Default, PartialEq)]
151pub struct NativeExpect {
152    /// The text the widget displays.
153    pub text: Option<String>,
154    /// A slider's or progress indicator's value.
155    pub number: Option<f64>,
156    /// A toggle's state, or a button's selected state.
157    pub checked: Option<bool>,
158    /// Whether the widget takes input.
159    pub enabled: Option<bool>,
160    /// Whether the widget is shown.
161    pub visible: Option<bool>,
162}
163
164/// The future one drive op resolves to.
165pub type OpFuture = Pin<Box<dyn Future<Output = TestResult>>>;
166
167/// Executes [`DriveOp`]s. The engine's backend runs each as the matching dayscript step on the
168/// main thread and waits, asynchronously, through the step's retry window; the mock harness
169/// uses the same backend against the mock toolkit.
170pub trait DriveBackend {
171    fn run(&self, op: DriveOp) -> OpFuture;
172}
173
174/// The handle a test drives the app with. Every method awaits the op and fails the test with
175/// the step's own message when the op fails after its retry window.
176#[derive(Clone)]
177pub struct Drive {
178    backend: Rc<dyn DriveBackend>,
179}
180
181impl Drive {
182    /// A drive over `backend`; the engine and the mock harness build one per test.
183    pub fn new(backend: Rc<dyn DriveBackend>) -> Self {
184        Drive { backend }
185    }
186
187    fn op(&self, op: DriveOp) -> OpFuture {
188        self.backend.run(op)
189    }
190
191    pub fn tap(&self, id: &str) -> OpFuture {
192        self.op(DriveOp::Tap(id.into()))
193    }
194    /// Type `text` into the field, replacing what it holds.
195    pub fn input(&self, id: &str, text: &str) -> OpFuture {
196        self.op(DriveOp::Input(id.into(), text.into()))
197    }
198    pub fn toggle(&self, id: &str, on: bool) -> OpFuture {
199        self.op(DriveOp::Toggle(id.into(), on))
200    }
201    pub fn set_value(&self, id: &str, value: f64) -> OpFuture {
202        self.op(DriveOp::SetValue(id.into(), value))
203    }
204    pub fn select(&self, id: &str, index: usize) -> OpFuture {
205        self.op(DriveOp::Select(id.into(), index))
206    }
207    pub fn focus(&self, id: &str) -> OpFuture {
208        self.op(DriveOp::Focus(id.into()))
209    }
210    pub fn submit(&self, id: &str) -> OpFuture {
211        self.op(DriveOp::Submit(id.into()))
212    }
213    pub fn navigate(&self, route: &str) -> OpFuture {
214        self.op(DriveOp::Navigate(route.into()))
215    }
216    /// Let native transitions settle.
217    pub fn wait_idle(&self) -> OpFuture {
218        self.op(DriveOp::WaitIdle)
219    }
220    pub fn pause(&self, secs: f64) -> OpFuture {
221        self.op(DriveOp::Pause(secs))
222    }
223    /// Capture the window as `name`; the capture rides the test's record to the runner.
224    pub fn shot(&self, name: &str) -> OpFuture {
225        self.op(DriveOp::Shot(name.into()))
226    }
227    pub fn assert_text(&self, id: &str, text: &str) -> OpFuture {
228        self.op(DriveOp::AssertText(id.into(), text.into()))
229    }
230    pub fn assert_visible(&self, id: &str) -> OpFuture {
231        self.op(DriveOp::AssertVisible(id.into()))
232    }
233    pub fn assert_missing(&self, id: &str) -> OpFuture {
234        self.op(DriveOp::AssertMissing(id.into()))
235    }
236    /// The pointer entering `id` at (`x`, `y`) in its coordinates (its center for `None`).
237    pub fn hover(&self, id: &str, at: Option<(f64, f64)>) -> OpFuture {
238        self.op(DriveOp::Hover(id.into(), at))
239    }
240    /// The pointer leaving `id`.
241    pub fn hover_leave(&self, id: &str) -> OpFuture {
242        self.op(DriveOp::HoverLeave(id.into()))
243    }
244    /// A two-finger pan over `id` by (`dx`, `dy`) in total.
245    pub fn pan(&self, id: &str, dx: f64, dy: f64) -> OpFuture {
246        self.op(DriveOp::Pan(id.into(), dx, dy))
247    }
248    /// A pinch over `id` to `scale` (cumulative; 1 = unchanged).
249    pub fn pinch(&self, id: &str, scale: f64) -> OpFuture {
250        self.op(DriveOp::Pinch(id.into(), scale))
251    }
252    /// A pointer drag over `id` from `from` to `to`, in its coordinates.
253    pub fn drag(&self, id: &str, from: (f64, f64), to: (f64, f64)) -> OpFuture {
254        self.op(DriveOp::Drag(id.into(), from, to))
255    }
256    /// A tap at (`x`, `y`) in `id`'s coordinates.
257    pub fn tap_at(&self, id: &str, x: f64, y: f64) -> OpFuture {
258        self.op(DriveOp::TapAt(id.into(), x, y))
259    }
260    /// A key press (web `KeyboardEvent.key` names: `"Enter"`, `"ArrowRight"`, `"a"`) to `id`,
261    /// or to whatever holds focus for `None`.
262    pub fn key(&self, id: Option<&str>, key: &str) -> OpFuture {
263        self.op(DriveOp::Key(id.map(str::to_owned), key.into()))
264    }
265    /// That the app announced `text` to the screen reader during the run.
266    pub fn assert_announced(&self, text: &str) -> OpFuture {
267        self.op(DriveOp::AssertAnnounced(text.into()))
268    }
269    /// That an element is not on screen: missing, with an empty frame, or hidden natively (a
270    /// collapsed pane a toolkit keeps built).
271    pub fn assert_hidden(&self, id: &str) -> OpFuture {
272        self.op(DriveOp::AssertHidden(id.into()))
273    }
274    pub fn assert_value(&self, id: &str, value: f64) -> OpFuture {
275        self.op(DriveOp::AssertValue(id.into(), value))
276    }
277    /// A toggle's on/off state.
278    pub fn assert_on(&self, id: &str, on: bool) -> OpFuture {
279        self.op(DriveOp::AssertOn(id.into(), on))
280    }
281    pub fn assert_focused(&self, id: &str, focused: bool) -> OpFuture {
282        self.op(DriveOp::AssertFocused(id.into(), focused))
283    }
284    pub fn assert_route(&self, route: &str) -> OpFuture {
285        self.op(DriveOp::AssertRoute(route.into()))
286    }
287    /// Diff the native accessibility tree against Day's expectation for `id` (all id'd
288    /// nodes when `None`), where the toolkit can read its tree back.
289    pub fn a11y_audit(&self, id: Option<&str>) -> OpFuture {
290        self.op(DriveOp::A11yAudit(id.map(str::to_owned)))
291    }
292
293    /// The native widget's own state, as the platform reports it (docs/testing.md). A field
294    /// the toolkit cannot read is recorded in the report as unread rather than failed. In a
295    /// conformance run `assert_text`, `assert_value` and `assert_on` check the native widget
296    /// too; this is for what they do not cover.
297    pub fn assert_native(&self, id: &str, expect: NativeExpect) -> OpFuture {
298        self.op(DriveOp::AssertNative(id.into(), expect))
299    }
300    /// The element's frame in Day's layout and, where the toolkit reads it back, natively.
301    pub fn assert_frame(&self, id: &str, expect: FrameExpect) -> OpFuture {
302        self.op(DriveOp::AssertFrame(id.into(), expect))
303    }
304    /// The element's size, the common case of [`Drive::assert_frame`].
305    pub fn assert_size(&self, id: &str, width: f64, height: f64) -> OpFuture {
306        self.assert_frame(
307            id,
308            FrameExpect {
309                width: Some(width),
310                height: Some(height),
311                ..Default::default()
312            },
313        )
314    }
315    /// The color drawn at (`x`, `y`), fractions of the element's frame, as `#rrggbb`. Passes as
316    /// unread where the toolkit has no capture.
317    pub fn sample_pixel(&self, id: &str, x: f64, y: f64, color: &str) -> OpFuture {
318        self.op(DriveOp::SamplePixel(id.into(), x, y, color.into()))
319    }
320    /// That the app asked to open `url` (a link, `day::open_url`) during the run; in a run
321    /// nothing actually opens.
322    pub fn assert_opened_url(&self, url: &str) -> OpFuture {
323        self.op(DriveOp::AssertOpenedUrl(url.into()))
324    }
325    /// Whether the element takes input, in Day's tree and in the native widget.
326    pub fn assert_enabled(&self, id: &str, enabled: bool) -> OpFuture {
327        self.assert_native(
328            id,
329            NativeExpect {
330                enabled: Some(enabled),
331                ..Default::default()
332            },
333        )
334    }
335
336    /// Activate a list's row by position: what a double click, Return or a phone's tap commits.
337    pub fn activate(&self, id: &str, index: usize) -> OpFuture {
338        self.op(DriveOp::Activate(id.into(), index))
339    }
340    /// Move a list's row from `from` to `to`, as a native drag-to-reorder commits it.
341    pub fn reorder(&self, id: &str, from: usize, to: usize) -> OpFuture {
342        self.op(DriveOp::Reorder(id.into(), from, to))
343    }
344    /// Delete a list's row, as the platform's delete gesture commits it.
345    pub fn delete_row(&self, id: &str, row: usize) -> OpFuture {
346        self.op(DriveOp::DeleteRow(id.into(), row))
347    }
348    /// Press the `action`th swipe action on a list row's leading or trailing edge.
349    pub fn swipe_row(&self, id: &str, row: usize, leading: bool, action: usize) -> OpFuture {
350        self.op(DriveOp::SwipeRow(id.into(), row, leading, action))
351    }
352    /// Scroll a scroll view or list to an edge: `"top"`, `"bottom"`, `"leading"`, `"trailing"`.
353    pub fn scroll_to(&self, id: &str, edge: &str) -> OpFuture {
354        self.op(DriveOp::ScrollTo(id.into(), edge.into()))
355    }
356    /// Expand or collapse a tree's row, named by its row id.
357    pub fn expand(&self, id: &str, row: &str, expanded: bool) -> OpFuture {
358        self.op(DriveOp::Expand(id.into(), row.into(), expanded))
359    }
360    /// Go back one page in the innermost navigation stack.
361    pub fn nav_back(&self) -> OpFuture {
362        self.op(DriveOp::NavBack)
363    }
364    /// Move a tree's row (by row id) under `parent` (`None` = the root level), at `index` among
365    /// its new siblings (`None` = last), as a native drag commits it.
366    pub fn tree_move(
367        &self,
368        id: &str,
369        row: &str,
370        parent: Option<&str>,
371        index: Option<usize>,
372    ) -> OpFuture {
373        self.op(DriveOp::TreeMove(
374            id.into(),
375            row.into(),
376            parent.map(str::to_owned),
377            index,
378        ))
379    }
380
381    /// Choose the item labeled `item` from `id`'s context menu, as a right click (or long
382    /// press) and a choice would.
383    pub fn context_menu(&self, id: &str, item: &str) -> OpFuture {
384        self.op(DriveOp::ContextMenu(id.into(), item.into()))
385    }
386    /// Choose an app-menu item by the id the app gave it (`MenuEntry::id`).
387    pub fn menu(&self, item_id: &str) -> OpFuture {
388        self.op(DriveOp::Menu(item_id.into()))
389    }
390    /// Press the toolbar button `item`.
391    pub fn toolbar_press(&self, item: &str) -> OpFuture {
392        self.op(DriveOp::ToolbarPress(item.into()))
393    }
394    /// Turn the toolbar toggle `item` on or off.
395    pub fn toolbar_toggle(&self, item: &str, on: bool) -> OpFuture {
396        self.op(DriveOp::ToolbarToggle(item.into(), on))
397    }
398    /// Show presentations natively (`false`, the default) or hold them for
399    /// [`Drive::respond`] without any native dialog (`true`). In native mode `respond` still
400    /// answers, and the toolkit dismisses the dialog it showed.
401    pub fn dialogs_scripted(&self, scripted: bool) -> OpFuture {
402        self.op(DriveOp::DialogScripted(scripted))
403    }
404    /// That a dialog or picker is pending, titled `title` where one is given.
405    pub fn assert_presented(&self, title: Option<&str>) -> OpFuture {
406        self.op(DriveOp::AssertPresented(title.map(str::to_owned)))
407    }
408    /// Answer the pending dialog with the button at `button` (presentation order), or dismiss
409    /// it for `None`.
410    pub fn respond(&self, button: Option<usize>) -> OpFuture {
411        self.op(DriveOp::Respond(button))
412    }
413
414    /// Confirm a pending text prompt.
415    pub fn respond_text(&self, text: &str) -> OpFuture {
416        self.op(DriveOp::RespondText(text.into()))
417    }
418
419    /// A plain assertion for a headless test: fails with `what` when `ok` is false.
420    pub fn check(&self, ok: bool, what: &str) -> TestResult {
421        if ok { Ok(()) } else { Err(Fail(what.into())) }
422    }
423    /// Equality for a headless test, naming both sides on a mismatch.
424    pub fn check_eq<T: PartialEq + std::fmt::Debug>(&self, got: T, want: T) -> TestResult {
425        if got == want {
426            Ok(())
427        } else {
428            Err(Fail(format!("{got:?} ≠ expected {want:?}")))
429        }
430    }
431}
432
433/// Whether a case shows a page and drives it, or runs with no page.
434#[derive(Clone, Copy, Debug, PartialEq, Eq)]
435pub enum TestKind {
436    Gui,
437    Headless,
438}
439
440type Body = Rc<dyn Fn(Drive) -> Pin<Box<dyn Future<Output = TestResult>>>>;
441
442/// One test: what it proves, what it needs, the page it shows and the body that drives it.
443/// Built by a `#[day::test]` function each time the test runs, so the signals a page and its
444/// drive share are fresh per run; the registry names it after that function.
445#[derive(Clone)]
446pub struct Case {
447    name: String,
448    kind: TestKind,
449    proves: Vec<String>,
450    requires: Vec<Cap>,
451    /// Supports the case needs beyond a `Cap`: a piece's own `support()` answer, named.
452    requires_support: Vec<(String, fn() -> day_spec::Support)>,
453    page: Option<Rc<dyn Fn() -> AnyPiece>>,
454    shots: Vec<String>,
455    body: Option<Body>,
456    timeout_secs: Option<f64>,
457}
458
459impl Default for Case {
460    fn default() -> Self {
461        Case::new()
462    }
463}
464
465impl Case {
466    /// A GUI case: a page the app's test host shows while the case runs, and a drive against it.
467    pub fn new() -> Self {
468        Case {
469            name: String::new(),
470            kind: TestKind::Gui,
471            proves: Vec::new(),
472            requires: Vec::new(),
473            requires_support: Vec::new(),
474            page: None,
475            shots: Vec::new(),
476            body: None,
477            timeout_secs: None,
478        }
479    }
480    /// A test with no page: app logic run inside the app's own environment.
481    pub fn headless() -> Self {
482        Case {
483            kind: TestKind::Headless,
484            ..Case::new()
485        }
486    }
487    /// How long the case may take, page and drive together, before it fails as timed out.
488    /// Unset, the run's default applies (30 s, or what `day test --case-timeout` says).
489    pub fn timeout(mut self, secs: f64) -> Self {
490        self.timeout_secs = Some(secs);
491        self
492    }
493    /// The piece kind this case is the proof of (`kinds::BUTTON`); a pass marks the kind
494    /// on this toolkit in the coverage tables.
495    pub fn proves(mut self, kind: &str) -> Self {
496        self.proves.push(format!("kind:{kind}"));
497        self
498    }
499    /// The capability this case proves.
500    pub fn proves_cap(mut self, cap: Cap) -> Self {
501        self.proves.push(format!("cap:{cap:?}"));
502        self
503    }
504    /// The `Decorate` modifier this case proves (`"padding"`).
505    pub fn proves_modifier(mut self, modifier: &str) -> Self {
506        self.proves.push(format!("modifier:{modifier}"));
507        self
508    }
509    /// The toolkit duty this case proves (`"set_input_traits"`).
510    pub fn proves_duty(mut self, duty: &str) -> Self {
511        self.proves.push(format!("duty:{duty}"));
512        self
513    }
514    /// A capability the case needs; where the toolkit answers `Unsupported` the case is
515    /// skipped with that reason rather than failed. The one way a case adapts to a toolkit.
516    pub fn requires(mut self, cap: Cap) -> Self {
517        self.requires.push(cap);
518        self
519    }
520    /// A piece's own support the case needs (`day_piece_map::support`), named for the skip
521    /// reason: where it answers `Unsupported` the case is skipped, as for [`Case::requires`].
522    /// For a piece realized only where its platform ships the widget.
523    pub fn requires_support(mut self, what: &str, support: fn() -> day_spec::Support) -> Self {
524        self.requires_support.push((what.to_owned(), support));
525        self
526    }
527    /// The first support this case needs that the running toolkit lacks, named.
528    pub fn missing_support(&self) -> Option<String> {
529        self.requires_support
530            .iter()
531            .find(|(_, f)| f() == day_spec::Support::Unsupported)
532            .map(|(what, _)| what.clone())
533    }
534    /// The page the app shows for this case; any piece.
535    pub fn page<P: crate::Piece>(mut self, page: impl Fn() -> P + 'static) -> Self {
536        self.page = Some(Rc::new(move || AnyPiece::new(page())));
537        self
538    }
539    /// A capture taken before the drive runs, once the page is shown.
540    pub fn shot(mut self, name: &str) -> Self {
541        self.shots.push(name.into());
542        self
543    }
544    /// The drive: an async body over a [`Drive`], awaiting each step so the app's main loop
545    /// runs between them (a transition settles, a capture paints) as it does between a
546    /// script's steps.
547    pub fn drive<F>(mut self, body: impl Fn(Drive) -> F + 'static) -> Self
548    where
549        F: Future<Output = TestResult> + 'static,
550    {
551        self.body = Some(Rc::new(move |d| Box::pin(body(d))));
552        self
553    }
554    /// [`Case::drive`] under the name a headless test reads better with.
555    pub fn run<F>(self, body: impl Fn(Drive) -> F + 'static) -> Self
556    where
557        F: Future<Output = TestResult> + 'static,
558    {
559        self.drive(body)
560    }
561
562    /// The name the registry gave the case: its function's, hyphenated. Empty for a case
563    /// built outside the registry.
564    pub fn name(&self) -> &str {
565        &self.name
566    }
567    /// The case's own time limit, if it set one.
568    pub fn timeout_secs(&self) -> Option<f64> {
569        self.timeout_secs
570    }
571    pub fn kind(&self) -> TestKind {
572        self.kind
573    }
574    /// The `kind:`, `cap:` and `duty:` keys this case proves, in the matrices' own spelling.
575    pub fn proves_keys(&self) -> &[String] {
576        &self.proves
577    }
578    pub fn requirements(&self) -> &[Cap] {
579        &self.requires
580    }
581    /// The captures to take before the drive.
582    pub fn shots(&self) -> &[String] {
583        &self.shots
584    }
585    /// Build the case's page, if it has one.
586    pub fn build_page(&self) -> Option<AnyPiece> {
587        self.page.as_ref().map(|p| p())
588    }
589    /// Run the body over `drive`; a case with no body passes once its page showed.
590    pub fn run_body(&self, drive: Drive) -> Pin<Box<dyn Future<Output = TestResult>>> {
591        match &self.body {
592            Some(body) => body(drive),
593            None => Box::pin(async { Ok(()) }),
594        }
595    }
596}
597
598/// A registered test: the function that builds its [`Case`], and that function's identifier,
599/// which is where the test's name comes from. Declared by `#[day::test]`.
600#[derive(Clone, Copy)]
601pub struct TestFn {
602    ident: &'static str,
603    build: fn() -> Case,
604}
605
606impl TestFn {
607    /// An entry for `build`, named by `ident` (`stringify!` of the function or its path).
608    pub const fn new(ident: &'static str, build: fn() -> Case) -> Self {
609        TestFn { ident, build }
610    }
611    /// The test's name: the identifier's last path segment, with hyphens for underscores.
612    pub fn name(&self) -> String {
613        let last = self.ident.rsplit("::").next().unwrap_or(self.ident);
614        last.trim().replace('_', "-")
615    }
616    /// A fresh case, named.
617    pub fn case(&self) -> Case {
618        let mut case = (self.build)();
619        case.name = self.name();
620        case
621    }
622}
623
624mod registry;
625pub use registry::validate_registry;
626#[cfg(target_arch = "wasm32")]
627pub use registry::{Registration, install_web_registry};
628
629/// Every native `#[day::test]`, collected without runtime constructors.
630#[cfg(not(target_arch = "wasm32"))]
631#[linkme::distributed_slice]
632pub static TESTS: [TestFn];
633
634/// What a test host does when the active case changes: rebuild its content.
635type HostNotify = Rc<dyn Fn()>;
636
637day_reactive::tls_slots! {
638    conformance;
639    static RUNTIME: RefCell<Vec<TestFn>> = const { RefCell::new(Vec::new()) };
640    /// The case the engine is driving, which the test host shows; its page shares its signals
641    /// with the drive.
642    static ACTIVE: RefCell<Option<Case>> = const { RefCell::new(None) };
643    /// The mounted test host's rebuild, when the app shows one (`day::test_host`).
644    static HOST: RefCell<Option<HostNotify>> = const { RefCell::new(None) };
645    /// A panic raised while the host built the active case's page, for the runner to report.
646    static PAGE_PANIC: RefCell<Option<String>> = const { RefCell::new(None) };
647    /// The URLs the app asked to open while a test run was going on, when one is.
648    static OPENED: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
649    /// What the app announced to the screen reader while a test run was going on, when one is.
650    static ANNOUNCED: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
651}
652
653/// Start or end a test run's interception of [`crate::open_url`]: while it is on, a URL the app
654/// asks to open is recorded instead of opened, so `assert_opened_url` can check it and no
655/// browser starts on the machine running the tests.
656pub fn set_intercepting_urls(on: bool) {
657    OPENED.with(|o| *o.borrow_mut() = on.then(Vec::new));
658    ANNOUNCED.with(|a| *a.borrow_mut() = on.then(Vec::new));
659}
660
661/// Record a screen-reader announcement if a test run is going on. Unlike a URL it still goes
662/// to the toolkit: announcing has no side effect a run must avoid, and the duty is what a case
663/// proves.
664pub fn note_announcement(text: &str) {
665    ANNOUNCED.with(|a| {
666        if let Some(list) = a.borrow_mut().as_mut() {
667            list.push(text.to_owned());
668        }
669    });
670}
671
672/// The announcements made since the run began.
673pub fn announcements() -> Vec<String> {
674    ANNOUNCED.with(|a| a.borrow().clone().unwrap_or_default())
675}
676
677/// Record `url` if a test run is intercepting; whether it did.
678pub fn intercept_open_url(url: &str) -> bool {
679    OPENED.with(|o| match o.borrow_mut().as_mut() {
680        Some(list) => {
681            list.push(url.to_owned());
682            true
683        }
684        None => false,
685    })
686}
687
688/// The URLs opened (and intercepted) since the run began.
689pub fn opened_urls() -> Vec<String> {
690    OPENED.with(|o| o.borrow().clone().unwrap_or_default())
691}
692
693/// Add a synthetic case in a test harness. Application tests use `#[day::test]`.
694pub fn register(test: TestFn) {
695    RUNTIME.with(|r| {
696        let mut r = r.borrow_mut();
697        if !r.iter().any(|t| std::ptr::fn_addr_eq(t.build, test.build)) {
698            r.push(test);
699        }
700    });
701}
702
703/// Every registered test, link-time and run-time ones together.
704pub fn tests() -> Vec<TestFn> {
705    let mut all: Vec<TestFn> = Vec::new();
706    all.extend(registry::compiled_tests());
707    RUNTIME.with(|r| {
708        for t in r.borrow().iter() {
709            if !all.iter().any(|a| std::ptr::fn_addr_eq(a.build, t.build)) {
710                all.push(*t);
711            }
712        }
713    });
714    all
715}
716
717/// Every registered test as a fresh, named [`Case`], sorted by name.
718pub fn cases() -> Vec<Case> {
719    let mut cases: Vec<Case> = tests().iter().map(TestFn::case).collect();
720    cases.sort_by(|a, b| a.name.cmp(&b.name));
721    cases
722}
723
724/// Show `case` in the app's test host, built fresh, until the next call or [`clear_active`].
725/// Answers false when the app mounts no test host, so the runner can say so.
726pub fn set_active(case: Case) -> bool {
727    ACTIVE.with(|a| *a.borrow_mut() = Some(case));
728    PAGE_PANIC.with(|p| *p.borrow_mut() = None);
729    notify_host()
730}
731
732/// Return the test host to the app's own content.
733pub fn clear_active() {
734    ACTIVE.with(|a| *a.borrow_mut() = None);
735    notify_host();
736}
737
738fn notify_host() -> bool {
739    let host = HOST.with(|h| h.borrow().clone());
740    match host {
741        Some(rebuild) => {
742            rebuild();
743            true
744        }
745        None => false,
746    }
747}
748
749/// The active case, for the test host to build.
750pub fn active() -> Option<Case> {
751    ACTIVE.with(|a| a.borrow().clone())
752}
753
754/// Install the mounted test host's rebuild (`day::test_host` does, for the life of its
755/// scope); `None` when it goes.
756pub fn set_host(rebuild: Option<Rc<dyn Fn()>>) {
757    HOST.with(|h| *h.borrow_mut() = rebuild);
758}
759
760/// Record a panic the test host caught while building the active case's page.
761pub fn note_page_panic(message: String) {
762    PAGE_PANIC.with(|p| *p.borrow_mut() = Some(message));
763}
764
765/// The panic, if any, the active case's page raised as it was built.
766pub fn take_page_panic() -> Option<String> {
767    PAGE_PANIC.with(|p| p.borrow_mut().take())
768}
769
770/// The sentence a caught panic's payload reads as.
771pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
772    if let Some(s) = payload.downcast_ref::<&str>() {
773        (*s).to_owned()
774    } else if let Some(s) = payload.downcast_ref::<String>() {
775        s.clone()
776    } else {
777        "a panic with no message".to_owned()
778    }
779}
780
781/// A fresh case for the test called `name`, for a person browsing an app's tests (the
782/// conformance app's navigation does).
783pub fn case_named(name: &str) -> Option<Case> {
784    tests()
785        .into_iter()
786        .find(|t| t.name() == name)
787        .map(|t| t.case())
788}