use nalgebra_glm::{Vec2, Vec4};
use winit::keyboard::KeyCode;
use crate::ecs::input::resources::MouseState;
use crate::ecs::ui::components::UiWidgetState;
use crate::ecs::ui::resources::UiEvent;
use crate::ecs::world::World;
use super::driver::UiTestDriver;
use super::types::{AssertKind, EventMatch, ScheduledAction, TestAction, UiTestResult};
impl UiTestDriver {
fn clear_frame_input(&self, world: &mut World) {
world.resources.input.mouse.state = MouseState::empty();
world.resources.input.mouse.position_delta = Vec2::zeros();
world.resources.input.mouse.wheel_delta = Vec2::zeros();
world.resources.input.keyboard.just_pressed_keys.clear();
world.resources.input.keyboard.just_released_keys.clear();
world.resources.input.keyboard.frame_keys.clear();
world.resources.input.keyboard.frame_chars.clear();
}
pub fn step(&mut self, world: &mut World) {
if !self.active {
return;
}
self.captured_events
.extend(world.resources.retained_ui.frame_events.iter().cloned());
self.clear_frame_input(world);
if self.pending_release {
self.pending_release = false;
world
.resources
.input
.mouse
.state
.insert(MouseState::LEFT_JUST_RELEASED);
}
if self.pending_right_release {
self.pending_right_release = false;
world
.resources
.input
.mouse
.state
.insert(MouseState::RIGHT_JUST_RELEASED);
}
if !self.pending_key_releases.is_empty() {
let keys = std::mem::take(&mut self.pending_key_releases);
for key in &keys {
world
.resources
.input
.keyboard
.keystates
.insert(*key, winit::event::ElementState::Released);
world
.resources
.input
.keyboard
.just_released_keys
.insert(*key);
world
.resources
.input
.keyboard
.frame_keys
.push((*key, false));
}
}
self.process_frame_actions(world);
self.frame += 1;
if self.cursor >= self.actions.len()
&& !self.pending_release
&& !self.pending_right_release
&& self.pending_key_releases.is_empty()
{
self.active = false;
self.print_results();
if self.exit_on_complete {
let code = if self.all_passed() { 0 } else { 1 };
std::process::exit(code);
}
}
}
fn process_frame_actions(&mut self, world: &mut World) {
while self.cursor < self.actions.len() && self.actions[self.cursor].frame == self.frame {
let action = self.actions[self.cursor].action.clone();
self.execute_action(world, action);
self.cursor += 1;
}
}
pub(super) fn resolve_entity_center(&self, world: &World, test_id: &str) -> Option<Vec2> {
let entity = *world.resources.retained_ui.test_id_map.get(test_id)?;
let node = world.ui.get_ui_layout_node(entity)?;
Some(node.computed_rect.center())
}
fn resolve_raw_entity_center(&self, world: &World, entity: freecs::Entity) -> Option<Vec2> {
let node = world.ui.get_ui_layout_node(entity)?;
Some(node.computed_rect.center())
}
fn inject_click_at(&mut self, world: &mut World, position: Vec2) {
let delta = position - world.resources.input.mouse.position;
world.resources.input.mouse.position = position;
world.resources.input.mouse.position_delta = delta;
world
.resources
.input
.mouse
.state
.insert(MouseState::MOVED | MouseState::LEFT_JUST_PRESSED | MouseState::LEFT_CLICKED);
self.pending_release = true;
}
fn execute_action(&mut self, world: &mut World, action: TestAction) {
match action {
TestAction::MoveMouse(position) => {
let delta = position - world.resources.input.mouse.position;
world.resources.input.mouse.position = position;
world.resources.input.mouse.position_delta = delta;
world.resources.input.mouse.state.insert(MouseState::MOVED);
}
TestAction::MoveMouseToEntity(test_id) => {
if let Some(center) = self.resolve_entity_center(world, &test_id) {
let delta = center - world.resources.input.mouse.position;
world.resources.input.mouse.position = center;
world.resources.input.mouse.position_delta = delta;
world.resources.input.mouse.state.insert(MouseState::MOVED);
} else {
eprintln!("[UI_TEST] WARNING: entity '{test_id}' not found for move_mouse_to");
}
}
TestAction::MousePress => {
world
.resources
.input
.mouse
.state
.insert(MouseState::LEFT_JUST_PRESSED | MouseState::LEFT_CLICKED);
}
TestAction::MouseRelease => {
world
.resources
.input
.mouse
.state
.insert(MouseState::LEFT_JUST_RELEASED);
}
TestAction::RightMousePress => {
world
.resources
.input
.mouse
.state
.insert(MouseState::RIGHT_JUST_PRESSED | MouseState::RIGHT_CLICKED);
}
TestAction::RightMouseRelease => {
world
.resources
.input
.mouse
.state
.insert(MouseState::RIGHT_JUST_RELEASED);
}
TestAction::ClickAt(position) => {
self.inject_click_at(world, position);
}
TestAction::ClickAtEntity(test_id) => {
if let Some(center) = self.resolve_entity_center(world, &test_id) {
self.inject_click_at(world, center);
} else {
eprintln!("[UI_TEST] WARNING: entity '{test_id}' not found for click_entity");
}
}
TestAction::RightClickAt(position) => {
let delta = position - world.resources.input.mouse.position;
world.resources.input.mouse.position = position;
world.resources.input.mouse.position_delta = delta;
world.resources.input.mouse.state.insert(
MouseState::MOVED | MouseState::RIGHT_JUST_PRESSED | MouseState::RIGHT_CLICKED,
);
self.pending_right_release = true;
}
TestAction::RightClickAtEntity(test_id) => {
if let Some(center) = self.resolve_entity_center(world, &test_id) {
let delta = center - world.resources.input.mouse.position;
world.resources.input.mouse.position = center;
world.resources.input.mouse.position_delta = delta;
world.resources.input.mouse.state.insert(
MouseState::MOVED
| MouseState::RIGHT_JUST_PRESSED
| MouseState::RIGHT_CLICKED,
);
self.pending_right_release = true;
} else {
eprintln!(
"[UI_TEST] WARNING: entity '{test_id}' not found for right_click_entity"
);
}
}
TestAction::DragEntity(test_id, end_position) => {
if let Some(center) = self.resolve_entity_center(world, &test_id) {
let delta = center - world.resources.input.mouse.position;
world.resources.input.mouse.position = center;
world.resources.input.mouse.position_delta = delta;
world.resources.input.mouse.state.insert(
MouseState::MOVED
| MouseState::LEFT_JUST_PRESSED
| MouseState::LEFT_CLICKED,
);
let release_frame = self.frame + 1;
self.actions.push(ScheduledAction {
frame: release_frame,
action: TestAction::MoveMouse(end_position),
});
self.actions.push(ScheduledAction {
frame: release_frame,
action: TestAction::MouseRelease,
});
self.actions.sort_by_key(|a| a.frame);
} else {
eprintln!("[UI_TEST] WARNING: entity '{test_id}' not found for drag_entity");
}
}
TestAction::SetSliderValue(test_id, target_value) => {
let entity = world
.resources
.retained_ui
.test_id_map
.get(&test_id)
.copied();
if let Some(entity) = entity {
let slider_info = if let Some(UiWidgetState::Slider(data)) =
world.ui.get_ui_widget_state(entity)
{
let rect = world
.ui
.get_ui_layout_node(entity)
.map(|node| node.computed_rect);
rect.map(|rect| {
let range = data.max - data.min;
let normalized = if data.logarithmic && data.min > 0.0 && range > 0.0 {
((target_value / data.min).ln() / (data.max / data.min).ln())
.clamp(0.0, 1.0)
} else if range.abs() > f32::EPSILON {
((target_value - data.min) / range).clamp(0.0, 1.0)
} else {
0.0
};
let target_x = rect.min.x + normalized * rect.width();
let target_y = rect.center().y;
Vec2::new(target_x, target_y)
})
} else {
None
};
if let Some(position) = slider_info {
self.inject_click_at(world, position);
} else {
eprintln!(
"[UI_TEST] WARNING: '{test_id}' is not a slider or has no layout"
);
}
} else {
eprintln!(
"[UI_TEST] WARNING: entity '{test_id}' not found for set_slider_value"
);
}
}
TestAction::SelectDropdownOption(test_id, option_index) => {
let entity = world
.resources
.retained_ui
.test_id_map
.get(&test_id)
.copied();
if let Some(entity) = entity {
let option_count = if let Some(UiWidgetState::Dropdown(data)) =
world.ui.get_ui_widget_state(entity)
{
data.options.len()
} else {
0
};
if option_count > 0 {
if let Some(center) = self.resolve_entity_center(world, &test_id) {
self.inject_click_at(world, center);
}
let nav_start = self.frame + 2;
let arrow_count = if option_index == 0 {
option_count
} else {
option_index
};
for step in 0..arrow_count {
self.actions.push(ScheduledAction {
frame: nav_start + step as u64,
action: TestAction::PressAndReleaseKey(KeyCode::ArrowDown),
});
}
self.actions.push(ScheduledAction {
frame: nav_start + arrow_count as u64,
action: TestAction::PressAndReleaseKey(KeyCode::Enter),
});
self.actions.sort_by_key(|a| a.frame);
} else {
eprintln!(
"[UI_TEST] WARNING: '{test_id}' is not a dropdown or has no options"
);
}
} else {
eprintln!(
"[UI_TEST] WARNING: entity '{test_id}' not found for select_dropdown_option"
);
}
}
TestAction::SelectTab(test_id, tab_index) => {
let entity = world
.resources
.retained_ui
.test_id_map
.get(&test_id)
.copied();
if let Some(entity) = entity {
let tab_entity = if let Some(UiWidgetState::TabBar(data)) =
world.ui.get_ui_widget_state(entity)
{
data.tab_entities.get(tab_index).copied()
} else {
None
};
if let Some(tab_entity) = tab_entity {
if let Some(center) = self.resolve_raw_entity_center(world, tab_entity) {
self.inject_click_at(world, center);
} else {
eprintln!(
"[UI_TEST] WARNING: tab {tab_index} in '{test_id}' has no layout"
);
}
} else {
eprintln!(
"[UI_TEST] WARNING: '{test_id}' is not a tab bar or index {tab_index} out of range"
);
}
} else {
eprintln!("[UI_TEST] WARNING: entity '{test_id}' not found for select_tab");
}
}
TestAction::TypeChars(chars) => {
for character in &chars {
world.resources.input.keyboard.frame_chars.push(*character);
}
}
TestAction::PressAndReleaseKey(key) => {
world
.resources
.input
.keyboard
.keystates
.insert(key, winit::event::ElementState::Pressed);
world.resources.input.keyboard.just_pressed_keys.insert(key);
world.resources.input.keyboard.frame_keys.push((key, true));
self.pending_key_releases.push(key);
}
TestAction::KeyDown(key) => {
world
.resources
.input
.keyboard
.keystates
.insert(key, winit::event::ElementState::Pressed);
world.resources.input.keyboard.just_pressed_keys.insert(key);
world.resources.input.keyboard.frame_keys.push((key, true));
}
TestAction::KeyUp(key) => {
world
.resources
.input
.keyboard
.keystates
.insert(key, winit::event::ElementState::Released);
world
.resources
.input
.keyboard
.just_released_keys
.insert(key);
world.resources.input.keyboard.frame_keys.push((key, false));
}
TestAction::Scroll(delta) => {
world.resources.input.mouse.wheel_delta = delta;
world
.resources
.input
.mouse
.state
.insert(MouseState::SCROLLED);
}
TestAction::Screenshot(_path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::ecs::world::commands::capture_screenshot_to_path(world, _path);
}
TestAction::Assert(name, kind) => {
let result = self.evaluate_assertion(world, &name, &kind);
self.results.push(result);
}
TestAction::Log(message) => {
eprintln!("[UI_TEST] {message}");
}
TestAction::Exit(success) => {
self.active = false;
self.print_results();
let code = if success { 0 } else { 1 };
std::process::exit(code);
}
}
}
fn evaluate_assertion(&self, world: &World, name: &str, kind: &AssertKind) -> UiTestResult {
match kind {
AssertKind::Visible(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let visible = entity
.and_then(|entity| world.ui.get_ui_layout_node(*entity))
.is_some_and(|node| node.visible);
UiTestResult {
name: name.to_string(),
passed: visible,
message: if visible {
format!("'{test_id}' is visible")
} else {
format!("'{test_id}' expected visible but was not")
},
}
}
AssertKind::NotVisible(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let visible = entity
.and_then(|entity| world.ui.get_ui_layout_node(*entity))
.is_some_and(|node| node.visible);
UiTestResult {
name: name.to_string(),
passed: !visible,
message: if !visible {
format!("'{test_id}' is not visible")
} else {
format!("'{test_id}' expected not visible but was visible")
},
}
}
AssertKind::Focused(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let focused = entity.is_some_and(|entity| {
world.resources.retained_ui.focused_entity == Some(*entity)
});
UiTestResult {
name: name.to_string(),
passed: focused,
message: if focused {
format!("'{test_id}' is focused")
} else {
format!("'{test_id}' expected focused but was not")
},
}
}
AssertKind::NotFocused(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let focused = entity.is_some_and(|entity| {
world.resources.retained_ui.focused_entity == Some(*entity)
});
UiTestResult {
name: name.to_string(),
passed: !focused,
message: if !focused {
format!("'{test_id}' is not focused")
} else {
format!("'{test_id}' expected not focused but was focused")
},
}
}
AssertKind::Hovered(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let hovered = entity
.and_then(|entity| world.ui.get_ui_node_interaction(*entity))
.is_some_and(|interaction| interaction.hovered);
UiTestResult {
name: name.to_string(),
passed: hovered,
message: if hovered {
format!("'{test_id}' is hovered")
} else {
format!("'{test_id}' expected hovered but was not")
},
}
}
AssertKind::NotHovered(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let hovered = entity
.and_then(|entity| world.ui.get_ui_node_interaction(*entity))
.is_some_and(|interaction| interaction.hovered);
UiTestResult {
name: name.to_string(),
passed: !hovered,
message: if !hovered {
format!("'{test_id}' is not hovered")
} else {
format!("'{test_id}' expected not hovered but was hovered")
},
}
}
AssertKind::Disabled(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let disabled = entity
.and_then(|entity| world.ui.get_ui_node_interaction(*entity))
.is_some_and(|interaction| interaction.disabled);
UiTestResult {
name: name.to_string(),
passed: disabled,
message: if disabled {
format!("'{test_id}' is disabled")
} else {
format!("'{test_id}' expected disabled but was not")
},
}
}
AssertKind::NotDisabled(test_id) => {
let entity = world.resources.retained_ui.test_id_map.get(test_id);
let disabled = entity
.and_then(|entity| world.ui.get_ui_node_interaction(*entity))
.is_some_and(|interaction| interaction.disabled);
UiTestResult {
name: name.to_string(),
passed: !disabled,
message: if !disabled {
format!("'{test_id}' is enabled")
} else {
format!("'{test_id}' expected enabled but was disabled")
},
}
}
AssertKind::TextEquals(test_id, expected) => {
let actual = self.get_text_content(world, test_id);
let passed = actual.as_deref() == Some(expected.as_str());
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{test_id}' text equals '{expected}'")
} else {
format!(
"'{test_id}' expected text '{expected}', got '{}'",
actual.as_deref().unwrap_or("<none>")
)
},
}
}
AssertKind::WidgetValueF32(prop_name, expected, tolerance) => {
let actual: f32 = world.ui_prop(prop_name);
let passed = (actual - expected).abs() <= *tolerance;
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{prop_name}' value {actual} matches expected {expected}")
} else {
format!(
"'{prop_name}' expected {expected} (tolerance {tolerance}), got {actual}"
)
},
}
}
AssertKind::WidgetValueBool(prop_name, expected) => {
let actual: bool = world.ui_prop(prop_name);
let passed = actual == *expected;
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{prop_name}' is {expected}")
} else {
format!("'{prop_name}' expected {expected}, got {actual}")
},
}
}
AssertKind::WidgetValueUsize(prop_name, expected) => {
let actual: usize = world.ui_prop(prop_name);
let passed = actual == *expected;
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{prop_name}' is {expected}")
} else {
format!("'{prop_name}' expected {expected}, got {actual}")
},
}
}
AssertKind::WidgetValueString(prop_name, expected) => {
let actual: String = world.ui_prop(prop_name);
let passed = actual == *expected;
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{prop_name}' equals '{expected}'")
} else {
format!("'{prop_name}' expected '{expected}', got '{actual}'")
},
}
}
AssertKind::WidgetValueVec4(prop_name, expected, tolerance) => {
let actual: Vec4 = world.ui_prop(prop_name);
let diff = (actual - expected).norm();
let passed = diff <= *tolerance;
UiTestResult {
name: name.to_string(),
passed,
message: if passed {
format!("'{prop_name}' matches expected value")
} else {
format!(
"'{prop_name}' expected [{:.3}, {:.3}, {:.3}, {:.3}], got [{:.3}, {:.3}, {:.3}, {:.3}]",
expected.x,
expected.y,
expected.z,
expected.w,
actual.x,
actual.y,
actual.z,
actual.w,
)
},
}
}
AssertKind::EventFired(event_match) => {
let found = self.captured_events.iter().any(|event| match event_match {
EventMatch::ButtonClicked => matches!(event, UiEvent::ButtonClicked(_)),
EventMatch::SliderChanged => matches!(event, UiEvent::SliderChanged { .. }),
EventMatch::ToggleChanged => matches!(event, UiEvent::ToggleChanged { .. }),
EventMatch::TextInputSubmitted => {
matches!(event, UiEvent::TextInputSubmitted { .. })
}
EventMatch::TextInputChanged => {
matches!(event, UiEvent::TextInputChanged { .. })
}
EventMatch::CheckboxChanged => {
matches!(event, UiEvent::CheckboxChanged { .. })
}
EventMatch::DropdownChanged => {
matches!(event, UiEvent::DropdownChanged { .. })
}
EventMatch::RadioChanged => matches!(event, UiEvent::RadioChanged { .. }),
EventMatch::TabChanged => matches!(event, UiEvent::TabChanged { .. }),
EventMatch::DragValueChanged => {
matches!(event, UiEvent::DragValueChanged { .. })
}
EventMatch::MenuItemClicked => {
matches!(event, UiEvent::MenuItemClicked { .. })
}
EventMatch::ColorPickerChanged => {
matches!(event, UiEvent::ColorPickerChanged { .. })
}
EventMatch::SelectableLabelClicked => {
matches!(event, UiEvent::SelectableLabelClicked { .. })
}
EventMatch::ContextMenuItemClicked => {
matches!(event, UiEvent::ContextMenuItemClicked { .. })
}
EventMatch::TreeNodeSelected => {
matches!(event, UiEvent::TreeNodeSelected { .. })
}
EventMatch::TreeNodeToggled => {
matches!(event, UiEvent::TreeNodeToggled { .. })
}
EventMatch::ModalClosed => matches!(event, UiEvent::ModalClosed { .. }),
EventMatch::CommandPaletteExecuted => {
matches!(event, UiEvent::CommandPaletteExecuted { .. })
}
EventMatch::TileTabActivated => {
matches!(event, UiEvent::TileTabActivated { .. })
}
EventMatch::TileTabClosed => matches!(event, UiEvent::TileTabClosed { .. }),
EventMatch::TileSplitterMoved => {
matches!(event, UiEvent::TileSplitterMoved { .. })
}
EventMatch::CanvasClicked => matches!(event, UiEvent::CanvasClicked { .. }),
EventMatch::VirtualListItemClicked => {
matches!(event, UiEvent::VirtualListItemClicked { .. })
}
EventMatch::TextAreaChanged => {
matches!(event, UiEvent::TextAreaChanged { .. })
}
EventMatch::RichTextEditorChanged => {
matches!(event, UiEvent::RichTextEditorChanged { .. })
}
EventMatch::DataGridFilterChanged => {
matches!(event, UiEvent::DataGridFilterChanged { .. })
}
EventMatch::RangeSliderChanged => {
matches!(event, UiEvent::RangeSliderChanged { .. })
}
EventMatch::DataGridCellEdited => {
matches!(event, UiEvent::DataGridCellEdited { .. })
}
EventMatch::BreadcrumbClicked => {
matches!(event, UiEvent::BreadcrumbClicked { .. })
}
EventMatch::SplitterChanged => {
matches!(event, UiEvent::SplitterChanged { .. })
}
EventMatch::MultiSelectChanged => {
matches!(event, UiEvent::MultiSelectChanged { .. })
}
EventMatch::DatePickerChanged => {
matches!(event, UiEvent::DatePickerChanged { .. })
}
EventMatch::DragStarted => matches!(event, UiEvent::DragStarted { .. }),
EventMatch::DragDropped => matches!(event, UiEvent::DragDropped { .. }),
EventMatch::DragCancelled => matches!(event, UiEvent::DragCancelled { .. }),
});
UiTestResult {
name: name.to_string(),
passed: found,
message: if found {
format!("Event {event_match:?} was fired")
} else {
format!("Event {event_match:?} expected but not fired")
},
}
}
}
}
fn get_text_content(&self, world: &World, test_id: &str) -> Option<String> {
let entity = *world.resources.retained_ui.test_id_map.get(test_id)?;
let text_slot = world.ui_text_slot_for_entity(entity)?;
world
.resources
.text_cache
.get_text(text_slot)
.map(|s| s.to_string())
}
pub(super) fn print_results(&self) {
eprintln!();
eprintln!("=== UI Test: {} ===", self.test_name);
let mut pass_count = 0;
let mut fail_count = 0;
for result in &self.results {
if result.passed {
pass_count += 1;
eprintln!(" PASS: {} - {}", result.name, result.message);
} else {
fail_count += 1;
eprintln!(" FAIL: {} - {}", result.name, result.message);
}
}
eprintln!();
eprintln!(
"Results: {} passed, {} failed, {} total",
pass_count,
fail_count,
self.results.len()
);
if fail_count == 0 {
eprintln!("ALL TESTS PASSED");
} else {
eprintln!("TESTS FAILED");
}
eprintln!("========================");
eprintln!();
}
}