use std::rc::Rc;
use std::time::{Duration, Instant};
use accesskit::{
Action as AccessibilityAction, ActionData as AccessibilityActionData,
ActionRequest as AccessibilityActionRequest, TreeId as AccessibilityTreeId,
};
use hydrolysis::{HeadlessRuntime, KeyCode, Modifiers, SemanticRuntime, Style};
use waterui::app::App;
use waterui::{Plugin, ViewExt as _};
use waterui_core::handler::AnyViewBuilder;
use waterui_core::{AnyView, Environment, View};
use crate::artifacts::{CapturedSnapshot, TestArtifacts};
use crate::driver::{
self, DriverPumpResult, FrameTiming, ResourceSampler, RuntimeDriver, VIRTUAL_FRAME,
};
use crate::perf::{PerfApp, PerfConfig, PerfReport};
use crate::query::Query;
use crate::selector::{ElementAnchor, ElementRef, ElementSet, Selector};
use crate::semantics::{NodeId, TreeSnapshot};
use crate::snapshot::Snapshot;
use crate::wait::{Expectation, ExpectationKind, WaitOptions, WaitResult};
#[derive(Clone, Copy, Debug, Default)]
pub struct NoStyle;
#[derive(Clone, Debug)]
pub struct Styled<S: Style> {
pub(crate) style: S,
}
#[must_use]
pub fn ui() -> UiBuilder {
UiBuilder::new()
}
#[must_use]
pub fn mount_app(app: App, style: impl Style) -> OffscreenApp {
let size = *app.main_window().frame.get().size();
ui().theme(style)
.viewport(
frame_points_as_u32(size.width),
frame_points_as_u32(size.height),
)
.mount_app(app)
}
#[expect(
clippy::cast_possible_truncation,
reason = "a window frame is a small positive logical size; the clamp keeps the value inside u32 range"
)]
fn frame_points_as_u32(points: f32) -> u32 {
u32::try_from(points.max(1.0).round() as i32).expect("clamped frame size is positive")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RuntimeFlavor {
#[default]
Test,
Application,
}
#[derive(Clone)]
pub struct UiBuilder<S = NoStyle> {
env: Environment,
width: u32,
height: u32,
style: S,
perf_config: PerfConfig,
flavor: RuntimeFlavor,
scale_factor: f64,
}
impl<S> core::fmt::Debug for UiBuilder<S> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("UiBuilder")
.field("width", &self.width)
.field("height", &self.height)
.field("perf_config", &self.perf_config)
.field("flavor", &self.flavor)
.field("scale_factor", &self.scale_factor)
.finish_non_exhaustive()
}
}
impl Default for UiBuilder {
fn default() -> Self {
Self::new()
}
}
impl UiBuilder<NoStyle> {
#[must_use]
pub fn new() -> Self {
Self {
env: Environment::new(),
width: 390,
height: 844,
style: NoStyle,
perf_config: PerfConfig::default(),
flavor: RuntimeFlavor::Test,
scale_factor: 1.0,
}
}
#[must_use]
pub fn theme<S: Style>(self, style: S) -> UiBuilder<Styled<S>> {
UiBuilder {
env: self.env,
width: self.width,
height: self.height,
style: Styled { style },
perf_config: self.perf_config,
flavor: self.flavor,
scale_factor: self.scale_factor,
}
}
pub fn mount<V, F>(self, view_fn: F) -> SemanticApp
where
V: View + 'static,
F: Fn() -> V + 'static,
{
self.mount_semantic(AnyViewBuilder::new(move || AnyView::new(view_fn())))
}
}
impl<S> UiBuilder<S> {
#[must_use]
pub fn environment(mut self, env: Environment) -> Self {
self.env = env;
self
}
#[must_use]
pub const fn viewport(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
#[must_use]
pub const fn perf_config(mut self, config: PerfConfig) -> Self {
self.perf_config = config;
self
}
#[must_use]
pub const fn runtime(mut self, flavor: RuntimeFlavor) -> Self {
self.flavor = flavor;
self
}
#[must_use]
pub const fn scale_factor(mut self, scale_factor: f64) -> Self {
self.scale_factor = scale_factor;
self
}
fn mount_env(&self) -> Environment {
let mut env = self.env.clone();
waterui::realization::install(&mut env);
waterui::realization::install_video(&mut env);
env
}
fn mount_semantic(self, content: AnyViewBuilder<AnyView>) -> SemanticApp {
let env = self.mount_env();
let runtime = match self.flavor {
RuntimeFlavor::Test => {
SemanticRuntime::new_for_tests(env, content, self.width, self.height)
}
RuntimeFlavor::Application => {
SemanticRuntime::new(env, content, self.width, self.height)
}
};
SemanticApp::new(runtime, (self.width, self.height))
}
}
struct StyleTokens<S: Style>(Rc<S>);
impl<S: Style> Plugin for StyleTokens<S> {
fn install(self, env: &mut Environment) {
self.0.install_tokens(env);
}
}
impl<S: Style> UiBuilder<Styled<S>> {
fn untheme(self) -> (UiBuilder<NoStyle>, S) {
let Self {
env,
width,
height,
style: Styled { style },
perf_config,
flavor,
scale_factor,
} = self;
(
UiBuilder {
env,
width,
height,
style: NoStyle,
perf_config,
flavor,
scale_factor,
},
style,
)
}
pub fn mount<V, F>(self, view_fn: F) -> SemanticApp
where
V: View + 'static,
F: Fn() -> V + 'static,
{
let (builder, style) = self.untheme();
let style = Rc::new(style);
builder.mount_semantic(AnyViewBuilder::new(move || {
AnyView::new(view_fn().install(StyleTokens(Rc::clone(&style))))
}))
}
pub fn mount_offscreen<V, F>(self, view_fn: F) -> OffscreenApp
where
V: View + 'static,
F: Fn() -> V + 'static,
{
let env = self.mount_env();
self.mount_rendered(env, AnyViewBuilder::new(move || AnyView::new(view_fn())))
}
#[must_use]
pub fn mount_app(self, app: App) -> OffscreenApp {
let (windows, _menu_bar, app_env) = app.into_parts();
let window = windows
.into_iter()
.next()
.expect("App::into_parts yields the main window first");
let mut env = app_env.layered_on(&self.env);
waterui::realization::install_video(&mut env);
self.mount_rendered(env, window.content)
}
fn mount_rendered(self, env: Environment, content: AnyViewBuilder<AnyView>) -> OffscreenApp {
assert!(
self.scale_factor.is_finite() && self.scale_factor > 0.0,
"waterui-testing scale_factor must be finite and greater than zero, got {}",
self.scale_factor
);
let runtime = match self.flavor {
RuntimeFlavor::Test => HeadlessRuntime::new_for_tests(
env,
content,
self.width,
self.height,
self.style.style,
),
RuntimeFlavor::Application => {
HeadlessRuntime::new(env, content, self.width, self.height, self.style.style)
}
};
OffscreenApp {
app: SemanticApp::new(
runtime.with_scale_factor(self.scale_factor),
(self.width, self.height),
),
}
}
pub fn perf<V, F>(self, view_fn: F) -> PerfReport
where
V: View + 'static,
F: Fn() -> V + 'static,
S: Clone,
{
self.perf_with(view_fn, |perf| {
perf.measure("steady-redraw", |run| {
run.redraw();
});
})
}
pub fn perf_with<V, F, A>(self, view_fn: F, automation: A) -> PerfReport
where
V: View + 'static,
F: Fn() -> V + 'static,
A: FnOnce(&mut PerfApp),
S: Clone,
{
let config = self.perf_config;
let mut app = PerfApp::new(self, view_fn, config);
automation(&mut app);
app.finish()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DragOptions {
pub steps: u16,
pub frame_per_step: bool,
}
impl Default for DragOptions {
fn default() -> Self {
Self {
steps: 6,
frame_per_step: false,
}
}
}
#[derive(Debug)]
pub struct OffscreenApp {
pub(crate) app: SemanticApp<HeadlessRuntime>,
}
impl OffscreenApp {
#[must_use]
pub const fn semantic(&self) -> &SemanticApp<HeadlessRuntime> {
&self.app
}
#[must_use]
pub const fn semantic_mut(&mut self) -> &mut SemanticApp<HeadlessRuntime> {
&mut self.app
}
pub fn pump_for(&mut self, duration: Duration) {
let mut remaining = duration;
while !remaining.is_zero() {
let step = VIRTUAL_FRAME.min(remaining);
remaining -= step;
self.app.pump_step(step);
}
}
pub fn pump_until(&mut self, timeout: Duration, mut ready: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
loop {
self.app.pump_step(VIRTUAL_FRAME);
if ready() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(VIRTUAL_FRAME);
}
}
pub fn snapshot(&mut self) -> Snapshot {
let _ = crate::executor::drain_parked_local_work();
let at = self.app.tick(VIRTUAL_FRAME);
let outcome = RuntimeDriver::pump_at(&mut self.app.runtime, at, true);
self.app
.apply_pump_result(outcome)
.unwrap_or_else(|| panic!("waterui-testing driver did not produce a snapshot"))
}
pub fn capture_snapshot(
&mut self,
suite: impl AsRef<str>,
case: impl AsRef<str>,
stage: impl AsRef<str>,
) -> CapturedSnapshot {
let artifacts = self.app.artifacts(suite);
artifacts.capture_snapshot(case, stage, self.snapshot())
}
pub fn queue_pointer_down(&mut self, x: f32, y: f32) {
self.app.queue_pointer_down_at(x, y);
}
pub fn queue_pointer_up(&mut self, x: f32, y: f32) {
self.app.queue_pointer_up_at(x, y);
}
pub fn queue_pointer_move(&mut self, x: f32, y: f32) {
self.app
.runtime
.push_input_event(driver::pointer_move_event(x, y));
}
}
impl core::ops::Deref for OffscreenApp {
type Target = SemanticApp<HeadlessRuntime>;
fn deref(&self) -> &Self::Target {
&self.app
}
}
impl core::ops::DerefMut for OffscreenApp {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.app
}
}
pub struct SemanticApp<R = SemanticRuntime> {
pub(crate) runtime: R,
pub(crate) tree: TreeSnapshot,
pub(crate) ui_focus: Option<NodeId>,
pub(crate) revision: u64,
pub(crate) viewport: (u32, u32),
pub(crate) clock: Option<Instant>,
pub(crate) resources: ResourceSampler,
}
impl<R> core::fmt::Debug for SemanticApp<R> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("SemanticApp")
.field("revision", &self.tree.revision())
.field("nodes", &self.tree.nodes().len())
.finish_non_exhaustive()
}
}
impl<R> SemanticApp<R> {
#[must_use]
pub const fn tree(&self) -> &TreeSnapshot {
&self.tree
}
#[must_use]
pub const fn ui_focus(&self) -> Option<NodeId> {
self.ui_focus
}
#[must_use]
pub fn artifacts(&self, suite: impl AsRef<str>) -> TestArtifacts {
TestArtifacts::new(suite.as_ref())
}
#[must_use]
pub const fn viewport(&self) -> (u32, u32) {
self.viewport
}
}
#[allow(
clippy::missing_panics_doc,
reason = "assertion helpers intentionally panic with WaterUI-specific diagnostics"
)]
impl<R: RuntimeDriver> SemanticApp<R> {
pub(crate) fn new(runtime: R, viewport: (u32, u32)) -> Self {
let mut app = Self {
runtime,
tree: TreeSnapshot::empty(),
ui_focus: None,
revision: 1,
viewport,
clock: None,
resources: ResourceSampler::new(),
};
let rebuilt = app.pump_once();
assert!(
rebuilt,
"waterui-testing initial mount did not produce a semantic tree"
);
app.settle();
app
}
pub fn query(&mut self) -> Query<'_, R> {
Query {
app: self,
selector: Selector::default(),
}
}
pub fn assert_exists(&mut self, selector: &Selector) {
let results = self.resolve_elements(selector);
let count = results.len();
assert!(
(count != 0),
"waterui-testing assertion failed: selector {} expected to exist but matched 0 nodes on revision {}",
selector.describe(),
self.tree.revision()
);
}
pub fn assert_not_exists(&mut self, selector: &Selector) {
let results = self.resolve_elements(selector);
let count = results.len();
assert!(
(count == 0),
"waterui-testing assertion failed: selector {} expected to be absent but matched {count} nodes; candidates: {}",
selector.describe(),
results.debug_summary(3)
);
}
pub fn assert_ui_focus(&mut self, selector: &Selector) {
let element = self.resolve_single(selector);
if self.ui_focus == Some(element.id()) {
return;
}
let actual = self.ui_focus.map_or_else(
|| String::from("none"),
|id| {
self.tree.node(id).map_or_else(
|| format!("id={} (no longer in tree)", id.as_u64()),
|node| {
ElementAnchor::new(id, node.clone(), self.tree.revision()).debug_summary()
},
)
},
);
panic!(
"waterui-testing assertion failed: selector {} resolved ({}) but UI focus is on {actual}",
selector.describe(),
element.debug_summary()
);
}
pub fn assert_value_eq(&mut self, selector: &Selector, value: impl Into<String>) {
let expected = value.into();
let element = self.resolve_single(selector);
let actual = element.node().value();
assert!(
actual == Some(expected.as_str()),
"waterui-testing assertion failed: selector value mismatch (expected {expected:?}, got {actual:?})"
);
}
#[must_use]
pub const fn expect_exists(&self, selector: Selector) -> Expectation {
Expectation {
kind: ExpectationKind::Exists(selector),
inverted: false,
}
}
#[must_use]
pub const fn expect_not_exists(&self, selector: Selector) -> Expectation {
Expectation {
kind: ExpectationKind::NotExists(selector),
inverted: false,
}
}
#[must_use]
pub fn expect_value_eq(&self, selector: Selector, value: impl Into<String>) -> Expectation {
Expectation {
kind: ExpectationKind::ValueEquals {
selector,
value: value.into(),
},
inverted: false,
}
}
#[must_use]
pub const fn expect_ui_focus(&self, selector: Selector) -> Expectation {
Expectation {
kind: ExpectationKind::UiFocus(selector),
inverted: false,
}
}
pub fn element(&mut self, id: NodeId) -> Option<ElementRef<R>> {
self.sync_tree();
let node = self.tree.node(id)?.clone();
Some(ElementRef::new(id, node, self.tree.revision()))
}
pub fn wait_for(&mut self, expectations: &[Expectation], options: WaitOptions) -> WaitResult {
const MIN_IDLE_BACKOFF: Duration = Duration::from_millis(1);
const MAX_IDLE_BACKOFF: Duration = Duration::from_millis(16);
assert!(
!(expectations.is_empty()),
"waterui-testing wait_for requires at least one expectation"
);
let has_inverted = expectations.iter().any(|e| e.inverted);
let order_ranks = {
let mut rank = 0usize;
expectations
.iter()
.map(|expectation| {
if expectation.inverted {
None
} else {
let current = rank;
rank += 1;
Some(current)
}
})
.collect::<Vec<_>>()
};
let mut fulfilled = vec![false; expectations.len()];
let mut next_order_rank = 0usize;
let deadline = Instant::now() + options.timeout;
let mut idle_backoff = Duration::ZERO;
loop {
for (idx, expectation) in expectations.iter().enumerate() {
let condition = self.evaluate_expectation(expectation);
if expectation.inverted {
if condition {
return WaitResult::InvertedFulfillment;
}
continue;
}
if fulfilled[idx] {
continue;
}
if condition {
if options.enforce_order {
let rank = order_ranks[idx]
.expect("non-inverted expectation must carry an order rank");
if rank != next_order_rank {
return WaitResult::IncorrectOrder;
}
next_order_rank += 1;
}
fulfilled[idx] = true;
}
}
let all_non_inverted = expectations
.iter()
.enumerate()
.all(|(idx, expectation)| expectation.inverted || fulfilled[idx]);
if all_non_inverted && !has_inverted {
return WaitResult::Completed;
}
let now = Instant::now();
if now >= deadline {
return if all_non_inverted {
WaitResult::Completed
} else {
WaitResult::TimedOut
};
}
let _ = self.pump_once();
if !self.runtime.is_settled() {
idle_backoff = Duration::ZERO;
continue;
}
let next_backoff = if idle_backoff.is_zero() {
MIN_IDLE_BACKOFF
} else {
idle_backoff.saturating_mul(2).min(MAX_IDLE_BACKOFF)
};
idle_backoff = next_backoff;
let now = Instant::now();
if now >= deadline {
continue;
}
let remaining = deadline.saturating_duration_since(now);
let sleep_for = next_backoff.min(remaining);
if !sleep_for.is_zero() {
std::thread::sleep(sleep_for);
}
}
}
pub fn wait_for_existence(&mut self, selector: &Selector, timeout: Duration) -> bool {
let expectation = self.expect_exists(selector.clone());
self.wait_for(&[expectation], WaitOptions::new(timeout)) == WaitResult::Completed
}
pub fn wait_for_nonexistence(&mut self, selector: &Selector, timeout: Duration) -> bool {
let expectation = self.expect_not_exists(selector.clone());
self.wait_for(&[expectation], WaitOptions::new(timeout)) == WaitResult::Completed
}
pub fn wait_for_value_eq(
&mut self,
selector: &Selector,
value: impl Into<String>,
timeout: Duration,
) -> bool {
let expectation = self.expect_value_eq(selector.clone(), value);
self.wait_for(&[expectation], WaitOptions::new(timeout)) == WaitResult::Completed
}
pub fn wait_for_ui_focus(&mut self, selector: &Selector, timeout: Duration) -> bool {
const MIN_IDLE_BACKOFF: Duration = Duration::from_millis(1);
const MAX_IDLE_BACKOFF: Duration = Duration::from_millis(16);
let deadline = Instant::now() + timeout;
let mut idle_backoff = Duration::ZERO;
loop {
if self.matches_ui_focus(selector) {
return true;
}
if Instant::now() >= deadline {
return false;
}
let _ = self.pump_once();
if !self.runtime.is_settled() {
idle_backoff = Duration::ZERO;
continue;
}
let next_backoff = if idle_backoff.is_zero() {
MIN_IDLE_BACKOFF
} else {
idle_backoff.saturating_mul(2).min(MAX_IDLE_BACKOFF)
};
idle_backoff = next_backoff;
std::thread::sleep(
next_backoff.min(deadline.saturating_duration_since(Instant::now())),
);
}
}
fn evaluate_expectation(&mut self, expectation: &Expectation) -> bool {
match &expectation.kind {
ExpectationKind::Exists(selector) => !self.matching_ids(selector).is_empty(),
ExpectationKind::NotExists(selector) => self.matching_ids(selector).is_empty(),
ExpectationKind::ValueEquals { selector, value } => {
let ids = self.matching_ids(selector);
if ids.len() != 1 {
return false;
}
self.tree[ids[0]].value() == Some(value.as_str())
}
ExpectationKind::UiFocus(selector) => self.matches_ui_focus(selector),
}
}
fn sync_tree(&mut self) {
const MAX_SYNC_PUMPS: usize = 8;
for _ in 0..MAX_SYNC_PUMPS {
if !self.runtime.has_pending_semantic_update() {
return;
}
self.pump_once();
}
}
fn matching_ids(&mut self, selector: &Selector) -> Vec<NodeId> {
self.sync_tree();
self.validate_selector_scope(selector);
self.tree.matching(selector)
}
pub fn resolve_elements(&mut self, selector: &Selector) -> ElementSet<R> {
let ids = self.matching_ids(selector);
let revision = self.tree.revision();
let elements = ids
.into_iter()
.map(|id| ElementRef::new(id, self.tree[id].clone(), revision))
.collect();
ElementSet::new(elements)
}
pub(crate) fn resolve_single(&mut self, selector: &Selector) -> ElementRef<R> {
let results = self.resolve_elements(selector);
match results.len() {
1 => results[0].clone(),
0 => panic!(
"waterui-testing selector {} resolved 0 nodes, expected exactly 1 on revision {}",
selector.describe(),
self.tree.revision()
),
n => panic!(
"waterui-testing selector {} resolved {n} nodes, expected exactly 1; candidates: {}",
selector.describe(),
results.debug_summary(3)
),
}
}
pub fn perform_action(
&mut self,
node_id: NodeId,
action: AccessibilityAction,
data: Option<AccessibilityActionData>,
) -> bool {
let handled = self.queue_action(node_id, action, data);
self.settle();
handled
}
pub fn queue_action(
&mut self,
node_id: NodeId,
action: AccessibilityAction,
data: Option<AccessibilityActionData>,
) -> bool {
let request = AccessibilityActionRequest {
target_tree: AccessibilityTreeId::ROOT,
target_node: node_id.as_accesskit(),
action,
data,
};
self.runtime.perform_accessibility_action(request)
}
pub(crate) fn perform_action_expect(
&mut self,
node_id: NodeId,
action: AccessibilityAction,
data: Option<AccessibilityActionData>,
) {
assert!(
self.perform_action(node_id, action, data),
"waterui-testing: accessibility action {action:?} on {} was not handled by the runtime — the target does not support this action",
self.describe_node(node_id),
);
}
fn describe_node(&self, node_id: NodeId) -> String {
self.tree.node(node_id).map_or_else(
|| format!("node id={} (no longer in tree)", node_id.as_u64()),
|node| ElementAnchor::new(node_id, node.clone(), self.tree.revision()).debug_summary(),
)
}
pub fn clear_ui_focus(&mut self) {
if self.queue_clear_ui_focus() {
self.settle();
}
}
pub fn queue_clear_ui_focus(&mut self) -> bool {
self.runtime.clear_ui_focus()
}
pub fn text_input(&mut self, text: impl Into<String>) {
self.queue_text_input(text);
self.settle();
}
pub fn queue_text_input(&mut self, text: impl Into<String>) {
self.runtime
.push_input_event(driver::text_input_event(text.into()));
}
pub fn press_named_key(&mut self, key: impl Into<String>) {
self.press_named_key_with(key, Modifiers::default());
}
pub fn press_named_key_with(&mut self, key: impl Into<String>, modifiers: Modifiers) {
self.queue_key_press(KeyCode::Named(key.into()), modifiers);
self.settle();
}
pub fn press_character_key(&mut self, key: impl Into<String>) {
self.press_character_key_with(key, Modifiers::default());
}
pub fn press_character_key_with(&mut self, key: impl Into<String>, modifiers: Modifiers) {
self.queue_key_press(KeyCode::Character(key.into()), modifiers);
self.settle();
}
pub fn queue_key_press(&mut self, key: KeyCode, modifiers: Modifiers) {
self.runtime
.push_input_event(driver::key_press_event(key, modifiers));
}
pub fn settle(&mut self) {
const SETTLE_CAP: Duration = Duration::from_secs(1);
const SETTLE_WALL_CAP: Duration = Duration::from_secs(5);
let mut remaining = SETTLE_CAP;
let wall_deadline = Instant::now() + SETTLE_WALL_CAP;
loop {
let _ = self.pump_once();
if !self.runtime.is_settled() {
remaining = remaining.saturating_sub(VIRTUAL_FRAME);
if remaining.is_zero() {
return;
}
continue;
}
loop {
if waterui::task::outstanding_local_tasks() == 0 || Instant::now() >= wall_deadline
{
return;
}
std::thread::sleep(VIRTUAL_FRAME);
if self.pump_held() || !self.runtime.is_settled() {
break;
}
}
}
}
fn pump_held(&mut self) -> bool {
self.pump_step(Duration::ZERO)
}
#[must_use]
pub fn is_settled(&self) -> bool {
self.runtime.is_settled()
}
fn tick(&mut self, step: Duration) -> Instant {
let next = self
.clock
.map_or_else(Instant::now, |current| current + step);
self.clock = Some(next);
next
}
fn pump_once(&mut self) -> bool {
self.pump_step(VIRTUAL_FRAME)
}
fn pump_step(&mut self, step: Duration) -> bool {
let _ = crate::executor::drain_parked_local_work();
let at = self.tick(step);
let outcome = self.runtime.pump_at(at, false);
let rebuilt = outcome.rebuilt;
let _ = self.apply_pump_result(outcome);
rebuilt
}
fn apply_pump_result(&mut self, outcome: DriverPumpResult) -> Option<Snapshot> {
self.ui_focus = outcome.ui_focus;
if let Some(update) = outcome.tree_update {
self.tree = TreeSnapshot::from_update(self.revision, update);
self.revision = self
.revision
.checked_add(1)
.expect("waterui-testing tree revision overflow");
} else {
assert!(
!self.tree.nodes().is_empty(),
"waterui-testing did not receive an accessibility tree update after mount"
);
}
outcome.snapshot
}
fn matches_ui_focus(&mut self, selector: &Selector) -> bool {
let ids = self.matching_ids(selector);
ids.len() == 1 && self.ui_focus == Some(ids[0])
}
pub(crate) fn assert_current_element(&self, element: &ElementRef<R>, context: &str) {
self.assert_current_anchor(&element.anchor(), context);
}
pub(crate) fn assert_current_anchor(&self, anchor: &ElementAnchor, context: &str) {
assert!(
anchor.revision() == self.tree.revision(),
"waterui-testing stale element handle during {context}: handle revision {} does not match current tree revision {}; re-query the element before interacting. handle={}",
anchor.revision(),
self.tree.revision(),
anchor.debug_summary()
);
assert!(
self.tree.node(anchor.id()).is_some(),
"waterui-testing missing current node for handle during {context}: handle={} is not present in revision {}",
anchor.debug_summary(),
self.tree.revision()
);
}
fn validate_selector_scope(&self, selector: &Selector) {
if let Some(scope) = selector.scope() {
self.assert_current_anchor(scope.handle(), "scoped query");
}
}
pub(crate) fn tap_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Click, None);
}
pub(crate) fn focus_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Focus, None);
}
pub(crate) fn set_text_node(&mut self, node_id: NodeId, value: impl Into<String>) {
self.perform_action_expect(
node_id,
AccessibilityAction::SetValue,
Some(AccessibilityActionData::Value(
value.into().into_boxed_str(),
)),
);
}
pub(crate) fn increment_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Increment, None);
}
pub(crate) fn decrement_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Decrement, None);
}
pub(crate) fn scroll_down_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::ScrollDown, None);
}
pub(crate) fn expand_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Expand, None);
}
pub(crate) fn collapse_node(&mut self, node_id: NodeId) {
self.perform_action_expect(node_id, AccessibilityAction::Collapse, None);
}
}
#[allow(
clippy::missing_panics_doc,
reason = "assertion helpers intentionally panic with WaterUI-specific diagnostics"
)]
impl SemanticApp<HeadlessRuntime> {
pub fn hover_at(&mut self, x: f32, y: f32) {
self.queue_hover_at(x, y);
self.settle();
}
pub fn queue_hover_at(&mut self, x: f32, y: f32) {
self.runtime
.push_input_event(driver::pointer_move_event(x, y));
}
pub fn tap_at(&mut self, x: f32, y: f32) {
self.runtime
.push_input_event(driver::pointer_down_event(x, y));
self.runtime
.push_input_event(driver::pointer_up_event(x, y));
self.settle();
}
pub fn pointer_down_at(&mut self, x: f32, y: f32) {
self.queue_pointer_down_at(x, y);
self.settle();
}
pub fn queue_pointer_down_at(&mut self, x: f32, y: f32) {
self.runtime
.push_input_event(driver::pointer_down_event(x, y));
}
pub fn secondary_click_at(&mut self, x: f32, y: f32) {
self.queue_secondary_click(x, y);
self.settle();
}
pub fn queue_secondary_click(&mut self, x: f32, y: f32) {
for event in driver::secondary_click_events(x, y) {
self.runtime.push_input_event(event);
}
}
pub fn pointer_up_at(&mut self, x: f32, y: f32) {
self.queue_pointer_up_at(x, y);
self.settle();
}
pub fn queue_pointer_up_at(&mut self, x: f32, y: f32) {
self.runtime
.push_input_event(driver::pointer_up_event(x, y));
}
pub(crate) fn drag_from_to(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
self.drag_from_to_with(from_x, from_y, to_x, to_y, DragOptions::default());
}
pub fn drag_from_to_with(
&mut self,
from_x: f32,
from_y: f32,
to_x: f32,
to_y: f32,
options: DragOptions,
) {
self.dispatch_drag(from_x, from_y, to_x, to_y, options);
self.settle();
}
pub fn queue_drag_from_to_with(
&mut self,
from_x: f32,
from_y: f32,
to_x: f32,
to_y: f32,
options: DragOptions,
) {
self.dispatch_drag(from_x, from_y, to_x, to_y, options);
}
fn dispatch_drag(
&mut self,
from_x: f32,
from_y: f32,
to_x: f32,
to_y: f32,
options: DragOptions,
) {
let steps = options.steps.max(1);
self.runtime
.push_input_event(driver::pointer_down_event(from_x, from_y));
for step in 1..=steps {
let t = f32::from(step) / f32::from(steps);
let x = (to_x - from_x).mul_add(t, from_x);
let y = (to_y - from_y).mul_add(t, from_y);
self.runtime
.push_input_event(driver::pointer_move_event(x, y));
if options.frame_per_step {
let _ = self.pump_step(VIRTUAL_FRAME);
}
}
self.runtime
.push_input_event(driver::pointer_up_event(to_x, to_y));
}
pub fn scroll_at(&mut self, x: f32, y: f32, dx: f32, dy: f32, is_line_delta: bool) {
self.queue_scroll_at(x, y, dx, dy, is_line_delta);
self.settle();
}
pub fn queue_scroll_at(&mut self, x: f32, y: f32, dx: f32, dy: f32, is_line_delta: bool) {
self.runtime
.push_input_event(driver::scroll_event(x, y, dx, dy, is_line_delta));
}
pub fn magnify_at(&mut self, x: f32, y: f32, factor: f32) {
self.queue_magnify_at(x, y, factor);
self.settle();
}
pub fn queue_magnify_at(&mut self, x: f32, y: f32, factor: f32) {
for event in driver::magnification_events(x, y, factor) {
self.runtime.push_input_event(event);
}
}
pub(crate) fn pump_frame_at(&mut self, at: Instant) -> FrameTiming {
self.clock = Some(at);
let _ = crate::executor::drain_parked_local_work();
let started_at = Instant::now();
let outcome = RuntimeDriver::pump_at(&mut self.runtime, at, false);
let timing = FrameTiming {
total: outcome.profile.total.max(started_at.elapsed()),
rebuilt: outcome.rebuilt,
profile: outcome.profile,
resources: self.resources.sample(),
};
let _ = self.apply_pump_result(outcome);
timing
}
}