Skip to main content

tilo_app/framework/
testing.rs

1//! Testing a framework app the way it runs: the real component runtime,
2//! storage, permissions, undo and history, without a window.
3//!
4//! Prefer [`tilo_resource::Replica`] for the resource's own behaviour (it
5//! needs no UI). Use [`TestApp`] to prove what the host adds: that controls
6//! invoke actions, that undo reverts them, that a viewer is refused, that
7//! state survives a reopen.
8//!
9//! Nothing here is reached from an app binary's own modes: those use
10//! [`super::headless`], so a release app carries none of this module.
11
12use ui_core::application::{ApplicationHost, ComponentRunnerInputs};
13use ui_core::headless::FrameHarness;
14use ui_core::launch::LaunchError;
15use ui_core::permissions::{Role, UserId};
16use ui_core::record::RecordId;
17
18use tilo_resource::Resource;
19
20use super::app::{ResourceApp, definition, prepare_on, role_permissions};
21use super::headless::{AUTHOR, INSTANCE, TEST_WORKSPACE, operation, profile_directory, surface_of};
22use super::root::ResourceRoot;
23
24pub use super::headless::{HeadlessApp, HostedApp, surface_in};
25
26/// The hang bound and bounded waits a test uses, re-exported from the
27/// toolkit's `test-support` so an app's tests reach them through `ui`
28/// rather than depending on the test environment's own crate.
29#[cfg(feature = "test-support")]
30pub use ui_core::testing::{hang_bound, hang_deadline, wait_for, wait_until};
31
32/// What interactions cost an app on the UI thread, phase by phase.
33#[cfg(feature = "test-support")]
34pub mod perf;
35
36/// Whether every published action is reachable through the door.
37#[cfg(feature = "test-support")]
38mod reach;
39#[cfg(feature = "test-support")]
40pub use reach::{
41    check_agents_are_shown_every_action, check_scenarios_reach_every_action, unreachable_actions,
42    unreachable_actions_in_share,
43};
44
45/// The name tests use for [`HeadlessApp`].
46pub type TestApp<A> = HeadlessApp<A>;
47
48/// The windows [`check_window_sidebar_button`] opens an app in: one wide
49/// enough to dock a sidebar beside any content, and one so narrow the
50/// sidebar is a screen of its own or a drawer over the content.
51pub const SIDEBAR_BUTTON_WINDOWS: [(u32, u32); 2] = [(1280, 800), (400, 800)];
52
53/// Open the app with `open` in each of [`SIDEBAR_BUTTON_WINDOWS`], shown in
54/// the desktop's window bar, and click the bar's sidebar button there: every
55/// click flips the sidebar, on the bar, in the window's shell and on screen
56/// ([`FrameHarness::check_window_sidebar_button`]). A narrow window keeps the
57/// button on the sidebar's own screen, behind the screen the person is on,
58/// and the check takes the bar's way back to it.
59pub fn check_window_sidebar_button<A: ResourceApp>(
60    mut open: impl FnMut() -> Result<HeadlessApp<A>, LaunchError>,
61) -> Result<(), String> {
62    for viewport in SIDEBAR_BUTTON_WINDOWS {
63        let mut app = open().map_err(|error| format!("{viewport:?}: the app opens: {error}"))?;
64        let harness = app.harness();
65        harness
66            .resize(viewport)
67            .map_err(|error| format!("{viewport:?}: {error}"))?;
68        harness.present_in_window_bar();
69        harness
70            .check_window_sidebar_button()
71            .map_err(|error| format!("{viewport:?}: {error}"))?;
72    }
73    Ok(())
74}
75
76/// Run `visit` on the custom surface of type `S` that `A`'s view hosts in
77/// `harness` (what [`ViewCx::surface`](super::ViewCx::surface) mounted), then
78/// the frames that follow; `None` when the view hosts no such surface at its
79/// root. For a test that drives a component mounted another way than
80/// [`HeadlessApp`] (two windows over one record).
81pub fn with_surface<A: ResourceApp, S: 'static, T>(
82    harness: &mut FrameHarness,
83    visit: impl FnOnce(&mut S) -> T,
84) -> Option<T> {
85    harness
86        .with_widget_mut(|root: &mut ResourceRoot| surface_of::<A, S>(root).map(visit))
87        .flatten()
88}
89
90/// The custom surface of type `S` that `A`'s view hosts, when `widget` is
91/// `A`'s root as a host mounted it: a tab of `A` in a shell, found in the
92/// host's own widget map.
93pub fn surface_in_root<A: ResourceApp, S: 'static>(
94    widget: &mut dyn ui_core::widget::Widget,
95) -> Option<&mut S> {
96    surface_of::<A, S>(widget.as_any_mut()?.downcast_mut::<ResourceRoot>()?)
97}
98
99/// A dispatcher outside any host, for a unit test of a surface (or of the
100/// model behind it) that edits through one: each immediate action
101/// ([`Dispatcher::apply_now`](super::Dispatcher::apply_now)) applies to the document handed to it,
102/// attributed to `author`, through the same executor as a hosted one, so
103/// the test exercises the surface's real write path. Queued invocations
104/// ([`Dispatcher::apply`](super::Dispatcher::apply)) are not taken by anyone: drive those through
105/// [`TestApp`].
106pub fn detached_dispatcher<R: Resource>(author: &str) -> super::Dispatcher<R> {
107    super::Dispatcher::new("test", R::KIND, false, Some(author))
108}
109
110/// The live state of `doc`, for a surface a unit test attaches outside any
111/// host (see [`detached_dispatcher`]); the surface keeps it current from the
112/// document its `sync` receives, as in a host.
113pub fn detached_live<R: Resource>(
114    doc: &tilo_resource::Doc,
115) -> Result<super::Live<R>, tilo_resource::ModelError> {
116    Ok(super::Live::new(tilo_resource::Reader::read(doc)?))
117}
118
119/// Inspect the window `A`'s live launch opens: the same definition and
120/// component, mounted in a native [`ui_core::App`] on a fresh private
121/// profile, without running its event loop. For what the window itself
122/// declares, such as capture protection.
123pub fn inspect_live_window<A: ResourceApp, T>(
124    inspect: impl FnOnce(&ui_core::App) -> T,
125) -> Result<T, LaunchError> {
126    drive_live_window::<A, T>(|app| inspect(app))
127}
128
129/// Drive the window `A`'s live launch opens, mounted as
130/// [`inspect_live_window`] mounts it: the real [`ui_core::App`], which a
131/// test or benchmark feeds input through [`ui_core::App::surface_input`].
132pub fn drive_live_window<A: ResourceApp, T>(
133    drive: impl FnOnce(&mut ui_core::App) -> T,
134) -> Result<T, LaunchError> {
135    drive_live_window_writing_after::<A, T>(app_state::DEFAULT_DEBOUNCE, drive)
136}
137
138/// [`drive_live_window`], with the window's record written `debounce` after
139/// the last edit instead of the window's own [`app_state::DEFAULT_DEBOUNCE`].
140/// The store writes on a thread of its own and wakes the window when the
141/// write lands, so whether a frame observed while typing takes that wake
142/// depends on how long the frames before it took. A debounce longer than
143/// the test holds every write past what it observes.
144pub fn drive_live_window_writing_after<A: ResourceApp, T>(
145    debounce: ui_core::time::Duration,
146    drive: impl FnOnce(&mut ui_core::App) -> T,
147) -> Result<T, LaunchError> {
148    let profile = profile_directory().map_err(operation)?;
149    let mut host = ApplicationHost::with_store(
150        std::rc::Rc::new(app_state::FsStore::with_debounce(
151            profile.path().join("state"),
152            debounce,
153        )),
154        "test-device".into(),
155        "test-workspace",
156        UserId(AUTHOR.into()),
157        role_permissions(AUTHOR, Role::Owner),
158        tilo_actions::ActionContext::default(),
159    )?;
160    let definition = definition::<A>();
161    let prepared = prepare_on(
162        &mut host,
163        definition,
164        INSTANCE,
165        None,
166        ComponentRunnerInputs::from_native_process(),
167    )?;
168    let component = definition.instantiate(prepared.context, prepared.target, prepared.restore)?;
169    let mut app = ui_core::App::from_component(component, prepared.runner_inputs)?;
170    let seen = drive(&mut app);
171    app.close_components();
172    Ok(seen)
173}
174
175/// The record window `instance` of `A` kept in the test workspace while `A` kept
176/// one per window: stamped with that window's default identity, initialized
177/// as `A` creates a record, then edited with `actions`. Open the app over
178/// such records with [`HeadlessApp::with_window_records`].
179pub fn window_record<A: ResourceApp>(
180    instance: u64,
181    actions: &[<A::Resource as Resource>::Action],
182) -> Result<tilo_resource::Doc, LaunchError> {
183    let mut doc = tilo_resource::Doc::new();
184    let kind = definition::<A>().record_kind.clone();
185    if !ui_core::record::init_scoped(
186        &mut doc,
187        &RecordId::window_default(instance),
188        &kind,
189        TEST_WORKSPACE,
190        Some(TEST_WORKSPACE),
191    ) {
192        return Err(operation("the window record could not be stamped"));
193    }
194    <A::Resource as Resource>::initialize(&mut doc).map_err(operation)?;
195    for action in actions {
196        tilo_actions::Action::apply(action, &mut doc).map_err(operation)?;
197    }
198    Ok(doc)
199}
200
201/// [`drive_live_window`] with the window shown in a region of a host's
202/// scene, as the shell shows an in-process app: the real app in a
203/// [`ui_core::app::EmbeddedApp`], which the drive lays out, draws into its
204/// own scene and feeds input through
205/// `ui_core::app::EmbeddedApp::input_for_test`.
206#[cfg(feature = "native")]
207pub fn drive_live_embedded<A: ResourceApp, T>(
208    drive: impl FnOnce(&mut ui_core::app::EmbeddedApp) -> T,
209) -> Result<T, LaunchError> {
210    let profile = profile_directory().map_err(operation)?;
211    let mut host = ApplicationHost::new(
212        profile.path().join("state"),
213        "test-device".into(),
214        "test-workspace",
215        UserId(AUTHOR.into()),
216        role_permissions(AUTHOR, Role::Owner),
217        tilo_actions::ActionContext::default(),
218    )?;
219    let definition = definition::<A>();
220    let prepared = prepare_on(
221        &mut host,
222        definition,
223        INSTANCE,
224        None,
225        ComponentRunnerInputs::from_native_process(),
226    )?;
227    let component = definition.instantiate(prepared.context, prepared.target, prepared.restore)?;
228    let app = ui_core::App::from_component(component, prepared.runner_inputs)?;
229    let mut tile = ui_core::app::EmbeddedApp::new(app, |_| {})?;
230    let seen = drive(&mut tile);
231    tile.app_mut().close_components();
232    Ok(seen)
233}
234
235/// A dispatcher of `R` for testing a surface's own methods without a host:
236/// the edits it applies now ([`Dispatcher::apply_now`](super::Dispatcher::apply_now)) go through the same
237/// replayable executor a hosted surface's do, attributed to a test author,
238/// onto the document the test hands the surface. Invocations it queues are
239/// resolved by a host only; test those through [`TestApp`].
240pub fn dispatcher<R: Resource>(app_id: &'static str) -> super::Dispatcher<R> {
241    super::Dispatcher::new(app_id, R::KIND, false, Some(AUTHOR))
242}
243
244/// Resolve what `dispatcher` ([`dispatcher`]) queued since the last call —
245/// its actions, typed values and groups — onto `doc` as a host does after an
246/// event, as confirmed: each invocation one step, attributed to the test
247/// author. An action its resource refuses (its clip is gone) changes
248/// nothing and is reported through [`Dispatcher::refused`](super::Dispatcher::refused), as the host
249/// reports it. Returns what each applied invocation applied, in order: one
250/// entry per undo step (a group's entry holds all of its actions).
251pub fn resolve<R: Resource>(
252    dispatcher: &super::Dispatcher<R>,
253    doc: &mut tilo_resource::Doc,
254) -> Result<Vec<Vec<R::Action>>, String> {
255    let mut applied = Vec::new();
256    let mut any_refused = false;
257    while let Some((invocation, _)) = dispatcher.core().take_attributed() {
258        if invocation.address.resource != dispatcher.core().resource_id() {
259            return Err(format!(
260                "`{}` on `{}` is not an action of {}",
261                invocation.address.action,
262                invocation.address.resource,
263                R::LABEL
264            ));
265        }
266        let now_ms = tilo_resource::origin_now_ms();
267        let actions = if invocation.address.action == tilo_resource::GROUP {
268            tilo_resource::group::build_group::<R>(&invocation.arguments, now_ms)?.actions
269        } else {
270            vec![tilo_resource::build_action::<R>(
271                &invocation.address.action,
272                &invocation.arguments,
273                now_ms,
274            )?]
275        };
276        let mut refused = None;
277        for action in &actions {
278            if let Err(error) =
279                dispatcher.apply_confirmed(&mut ui_core::widget::Edits::lend(doc), action)
280            {
281                refused = Some(error.to_string());
282                break;
283            }
284        }
285        match refused {
286            Some(reason) => {
287                any_refused = true;
288                dispatcher.core().refuse(&invocation.address.action, reason);
289            }
290            None => applied.push(actions),
291        }
292    }
293    // `doc` is decided by nobody else: what the views said they did is
294    // kept at once (`Dispatcher::say_when_kept`), unless it was refused.
295    for text in dispatcher.core().take_notices() {
296        if !any_refused {
297            dispatcher.core().kept(&text);
298        }
299    }
300    Ok(applied)
301}
302
303/// The search source the framework builds for `R` from `entries` — its
304/// resource's and its surfaces' — for testing what an app's entries find and
305/// how their hits read without a window.
306pub fn search_source<R: Resource>(
307    entries: Vec<tilo_resource::SearchEntry>,
308) -> impl ui_core::search::SearchProvider {
309    super::root::ResourceSearch::new(
310        R::LABEL,
311        R::APP_ID,
312        std::sync::Arc::new(super::root::IndexedEntries::built(entries, 0)),
313    )
314}