use std::time::Instant;
use accesskit::{Action, ActionRequest, Node, NodeId, Role, TreeId, TreeUpdate};
use nami::Binding;
use waterui::ViewExt as _;
use waterui_controls::button::button;
use waterui_controls::menu::CommandExt as _;
use waterui_core::AnyView;
use waterui_core::handler::AnyViewBuilder;
use waterui_form::picker::color::ColorPicker;
use waterui_graphics::Color;
use waterui_layout::frame::Frame;
use waterui_layout::stack::vstack;
use super::{MinimalTestTheme, test_environment};
use crate::HeadlessRuntime;
use crate::platform::{InputEvent, PointerButton, PointerKind};
const WINDOW_SIZE: f32 = 160.0;
fn find_by_label<'a>(
update: &'a TreeUpdate,
role: Role,
label: &str,
) -> Option<(NodeId, &'a Node)> {
update.nodes.iter().find_map(|(id, node)| {
(node.role() == role && node.label() == Some(label)).then_some((*id, node))
})
}
fn act(runtime: &mut HeadlessRuntime, action: Action, target: NodeId) -> bool {
runtime.perform_accessibility_action(ActionRequest {
action,
target_node: target,
target_tree: TreeId::ROOT,
data: None,
})
}
fn secondary_click(x: f32, y: f32) -> [InputEvent; 2] {
[
InputEvent::PointerDown {
id: 1,
kind: PointerKind::Mouse,
x,
y,
button: PointerButton::Secondary,
},
InputEvent::PointerUp {
id: 1,
kind: PointerKind::Mouse,
x,
y,
button: PointerButton::Secondary,
},
]
}
#[test]
fn secondary_click_merges_the_context_menu_popup_into_the_tree() {
let copied = Binding::container(false);
let copied_for_view = copied.clone();
let builder = AnyViewBuilder::<AnyView>::new(move || {
let copied = copied_for_view.clone();
AnyView::new(
Frame::new(button("host").action(|| {}))
.width(WINDOW_SIZE)
.height(WINDOW_SIZE)
.context_menu(vec!["Copy".action(move || copied.set(true))]),
)
});
let mut runtime = HeadlessRuntime::new_for_tests(
test_environment(),
builder,
WINDOW_SIZE as u32,
WINDOW_SIZE as u32,
MinimalTestTheme::default(),
);
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the first frame must publish an accessibility tree");
assert!(
find_by_label(&update, Role::Button, "Copy").is_none(),
"menu items must not emit before the menu opens"
);
let (_, host) = find_by_label(&update, Role::Button, "host")
.expect("the host button must emit an accessibility node");
let bounds = host.bounds().expect("the host button has frame bounds");
let (x, y) = ((bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0);
for event in secondary_click(x as f32, y as f32) {
runtime.push_input_event(event);
}
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the click frame must publish an accessibility tree");
let (copy, copy_node) = find_by_label(&update, Role::Button, "Copy")
.expect("the context menu's items must merge into the returned tree");
assert!(copy_node.supports_action(Action::Click));
if cfg!(debug_assertions) {
assert!(
find_by_label(&update, Role::Button, "Inspect element").is_some(),
"the appended Inspect element item must merge into the returned tree"
);
}
assert!(act(&mut runtime, Action::Click, copy));
assert!(
copied.get(),
"clicking the merged popup item must fire its command"
);
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the activation frame must publish an accessibility tree");
assert!(
find_by_label(&update, Role::Button, "Copy").is_none(),
"the popup's nodes must leave the merged tree once it closes"
);
}
#[test]
fn a_window_with_a_pending_frame_is_not_settled() {
let mut runtime = HeadlessRuntime::new_for_tests(
test_environment(),
AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(
Frame::new(button("host").action(|| {}))
.width(WINDOW_SIZE)
.height(WINDOW_SIZE)
.context_menu(vec!["Copy".action(|| {})]),
)
}),
WINDOW_SIZE as u32,
WINDOW_SIZE as u32,
MinimalTestTheme::default(),
);
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the first frame must publish an accessibility tree");
assert!(
runtime.is_settled(),
"a runtime with no pending frame settles"
);
let (host, _) =
find_by_label(&update, Role::Button, "host").expect("the host button is missing");
assert!(act(&mut runtime, Action::Focus, host));
assert!(
!runtime.is_settled(),
"a main window with a pending frame must not report settled"
);
assert!(
runtime.has_pending_semantic_update(),
"a pending frame is a state change requested but not yet flushed"
);
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the pending frame must publish an accessibility tree");
assert!(runtime.is_settled(), "the main window's pending frame ran");
let (_, host) =
find_by_label(&update, Role::Button, "host").expect("the host button must still emit");
let bounds = host.bounds().expect("the host button has frame bounds");
let (x, y) = ((bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0);
for event in secondary_click(x as f32, y as f32) {
runtime.push_input_event(event);
}
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the click frame must publish the merged tree");
let (copy, _) = find_by_label(&update, Role::Button, "Copy")
.expect("the context menu's items must merge into the returned tree");
assert!(act(&mut runtime, Action::Click, copy));
assert!(
!runtime.is_settled(),
"a popup with a pending frame must not report settled"
);
assert!(
runtime.has_pending_semantic_update(),
"a pending popup frame is a state change requested but not yet flushed"
);
let _ = runtime.pump_at(false, Instant::now());
assert!(
runtime.is_settled(),
"the popup's pending frame ran and the runtime settled"
);
}
#[test]
fn a_popup_only_change_publishes_the_merged_tree() {
let tint = Binding::container(Color::srgb(0, 0, 0));
let tint_for_view = tint.clone();
let mut runtime = HeadlessRuntime::new_for_tests(
test_environment(),
AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(vstack((ColorPicker::new("Tint", &tint_for_view),)))
}),
320,
240,
MinimalTestTheme::default(),
);
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the first frame must publish an accessibility tree");
let (trigger, _) =
find_by_label(&update, Role::Button, "Tint").expect("the color picker is missing");
assert!(
find_by_label(&update, Role::Button, "Red").is_none(),
"swatches must not appear before the picker opens"
);
assert!(act(&mut runtime, Action::Click, trigger));
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the picker's first frame must publish an accessibility tree");
let (swatch, _) = find_by_label(&update, Role::Button, "Red")
.expect("the swatch panel must merge into the returned tree");
assert!(act(&mut runtime, Action::Focus, swatch));
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("a popup-only change must still publish the merged tree");
assert!(
find_by_label(&update, Role::Button, "Red").is_some(),
"the published update must carry the popup's nodes"
);
assert!(
find_by_label(&update, Role::Button, "Tint").is_some(),
"the published update must still carry the clean main window's nodes"
);
assert_eq!(
update.focus, swatch,
"the merged focus must follow the focused popup item"
);
assert!(act(&mut runtime, Action::Click, swatch));
let update = runtime
.pump_at(false, Instant::now())
.tree_update
.expect("the close frame must publish an accessibility tree");
assert_eq!(
update.focus, trigger,
"the merged focus must return to the main tree once the popup closes"
);
}
fn pump_until_settled(runtime: &mut HeadlessRuntime) -> Option<TreeUpdate> {
let mut last = None;
for _ in 0..64 {
let result = runtime.pump_at(false, Instant::now());
if let Some(update) = result.tree_update {
last = Some(update);
}
if runtime.is_settled() {
break;
}
}
assert!(
!runtime.has_pending_semantic_update(),
"headless runtime never settled"
);
last
}
#[test]
fn a_clean_pump_publishes_no_tree_update() {
let copied = Binding::container(false);
let copied_for_view = copied.clone();
let builder = AnyViewBuilder::<AnyView>::new(move || {
let copied = copied_for_view.clone();
AnyView::new(
Frame::new(button("host").action(|| {}))
.width(WINDOW_SIZE)
.height(WINDOW_SIZE)
.context_menu(vec!["Copy".action(move || copied.set(true))]),
)
});
let mut runtime = HeadlessRuntime::new_for_tests(
test_environment(),
builder,
WINDOW_SIZE as u32,
WINDOW_SIZE as u32,
MinimalTestTheme::default(),
);
let update = pump_until_settled(&mut runtime)
.expect("the first frame must publish an accessibility tree");
assert!(
runtime.pump_at(false, Instant::now()).tree_update.is_none(),
"a settled pump with no popup must publish nothing"
);
let (_, host) =
find_by_label(&update, Role::Button, "host").expect("the host button is missing");
let bounds = host.bounds().expect("the host button has frame bounds");
let (x, y) = ((bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0);
for event in secondary_click(x as f32, y as f32) {
runtime.push_input_event(event);
}
let update =
pump_until_settled(&mut runtime).expect("the click frame must publish the merged tree");
assert!(
find_by_label(&update, Role::Button, "Copy").is_some(),
"the menu's window must be merged before the clean-pump check"
);
assert!(
runtime.pump_at(false, Instant::now()).tree_update.is_none(),
"a settled pump with an open popup must publish nothing"
);
}