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