use std::collections::HashMap;
use plushie_core::Selector;
use plushie_core::key::{EffectKind, KeyPress, MouseButton};
use plushie_core::protocol::{TreeNode, canonical_tree_hash};
use serde_json::Value;
use crate::App;
use crate::automation::Element;
use crate::command::Command;
use crate::event::{AsyncEvent, EffectEvent, EffectResult, Event, EventType, WidgetEvent};
use crate::runtime;
use crate::runtime::subscriptions::{SubOp, SubscriptionManager};
use crate::subscription::Subscription;
use crate::widget::{EventResult, Interception, WidgetStateStore};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
Asc,
Desc,
}
impl SortDir {
fn as_str(self) -> &'static str {
match self {
SortDir::Asc => "asc",
SortDir::Desc => "desc",
}
}
}
pub struct TestSession<A: App> {
model: A::Model,
tree: TreeNode,
widget_store: WidgetStateStore,
memo_cache: runtime::MemoCache,
widget_view_cache: runtime::WidgetViewCache,
async_results: HashMap<String, Result<Value, Value>>,
effect_stubs: HashMap<String, EffectResult>,
diagnostics: Vec<plushie_core::Diagnostic>,
fail_on_diagnostics: bool,
sub_manager: SubscriptionManager,
last_sub_ops: Vec<SubOp>,
pending_async: Vec<(String, crate::command::AsyncTaskFn)>,
pending_streams: Vec<(String, crate::command::StreamTaskFn)>,
issued_ops: Vec<crate::command::RendererOp>,
}
impl<A: App> TestSession<A> {
pub fn start() -> Self {
let (model, init_cmd) = A::init();
let mut widget_store = WidgetStateStore::new();
let mut memo_cache = runtime::MemoCache::new();
let mut widget_view_cache = runtime::WidgetViewCache::new();
let (tree, warnings) = runtime::prepare_tree::<A>(
&model,
&mut widget_store,
&mut memo_cache,
&mut widget_view_cache,
);
let mut session = Self {
model,
tree,
widget_store,
memo_cache,
widget_view_cache,
async_results: HashMap::new(),
effect_stubs: HashMap::new(),
diagnostics: warnings,
fail_on_diagnostics: true,
sub_manager: SubscriptionManager::new(),
last_sub_ops: Vec::new(),
pending_async: Vec::new(),
pending_streams: Vec::new(),
issued_ops: Vec::new(),
};
session.execute_command(init_cmd);
session.run_pending_async();
session
}
pub fn allow_diagnostics(mut self) -> Self {
self.fail_on_diagnostics = false;
self
}
pub fn model(&self) -> &A::Model {
&self.model
}
pub fn model_mut(&mut self) -> &mut A::Model {
&mut self.model
}
fn resolve(&self, selector: impl Into<Selector>) -> &TreeNode {
let sel = selector.into();
sel.find(&self.tree).unwrap_or_else(|| {
let ids = collect_tree_ids(&self.tree);
if ids.is_empty() {
panic!("widget not found: {sel}\n tree has no IDs");
}
panic!(
"widget not found: {sel}\n available IDs:\n {}",
ids.join("\n ")
);
})
}
pub fn click(&mut self, selector: impl Into<Selector>) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(EventType::Click, &id, Value::Null));
}
pub fn type_text(&mut self, selector: impl Into<Selector>, text: &str) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Input,
&id,
Value::String(text.to_string()),
));
}
pub fn toggle(&mut self, selector: impl Into<Selector>) {
let node = self.resolve(selector);
let id = node.id.clone();
let current = node
.prop_bool("checked")
.or_else(|| node.prop_bool("is_toggled"))
.unwrap_or(false);
self.dispatch(widget_event(EventType::Toggle, &id, Value::Bool(!current)));
}
pub fn set_toggle(&mut self, selector: impl Into<Selector>, checked: bool) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(EventType::Toggle, &id, Value::Bool(checked)));
}
pub fn select(&mut self, selector: impl Into<Selector>, value: &str) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Select,
&id,
Value::String(value.to_string()),
));
}
pub fn submit(&mut self, selector: impl Into<Selector>) {
let node = self.resolve(selector);
let id = node.id.clone();
let text = node.prop_str("value").unwrap_or("").to_string();
self.dispatch(widget_event(EventType::Submit, &id, Value::String(text)));
}
pub fn submit_with(&mut self, selector: impl Into<Selector>, text: &str) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Submit,
&id,
Value::String(text.to_string()),
));
}
pub fn slide(&mut self, selector: impl Into<Selector>, value: f64) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Slide,
&id,
serde_json::json!(value),
));
}
pub fn paste(&mut self, selector: impl Into<Selector>, text: &str) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Paste,
&id,
Value::String(text.to_string()),
));
}
pub fn scroll(&mut self, selector: impl Into<Selector>, delta_x: f32, delta_y: f32) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Scroll,
&id,
serde_json::json!({"delta_x": delta_x, "delta_y": delta_y}),
));
}
pub fn sort(&mut self, selector: impl Into<Selector>, column: &str, direction: SortDir) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Sort,
&id,
serde_json::json!({
"column": column,
"direction": direction.as_str(),
}),
));
}
pub fn pane_focus_cycle(&mut self, selector: impl Into<Selector>) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(EventType::PaneFocusCycle, &id, Value::Null));
}
pub fn press(&mut self, key: impl Into<KeyPress>) {
let kp = key.into();
self.dispatch(key_event(
crate::event::KeyEventType::Press,
&kp.key,
kp.modifiers,
));
}
pub fn release(&mut self, key: impl Into<KeyPress>) {
let kp = key.into();
self.dispatch(key_event(
crate::event::KeyEventType::Release,
&kp.key,
kp.modifiers,
));
}
pub fn type_key(&mut self, key: impl Into<KeyPress>) {
let kp = key.into();
self.dispatch(key_event(
crate::event::KeyEventType::Press,
&kp.key,
kp.modifiers,
));
self.dispatch(key_event(
crate::event::KeyEventType::Release,
&kp.key,
kp.modifiers,
));
}
pub fn canvas_press(
&mut self,
selector: impl Into<Selector>,
x: f32,
y: f32,
button: impl Into<MouseButton>,
) {
let id = self.resolve(selector).id.clone();
let btn = button.into();
self.dispatch(widget_event(
EventType::Press,
&id,
serde_json::json!({"x": x, "y": y, "button": btn.wire_name(), "pointer": "mouse"}),
));
}
pub fn canvas_release(
&mut self,
selector: impl Into<Selector>,
x: f32,
y: f32,
button: impl Into<MouseButton>,
) {
let id = self.resolve(selector).id.clone();
let btn = button.into();
self.dispatch(widget_event(
EventType::Release,
&id,
serde_json::json!({"x": x, "y": y, "button": btn.wire_name(), "pointer": "mouse"}),
));
}
pub fn canvas_move(&mut self, selector: impl Into<Selector>, x: f32, y: f32) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Move,
&id,
serde_json::json!({"x": x, "y": y, "pointer": "mouse"}),
));
}
pub fn canvas_touch_press(
&mut self,
selector: impl Into<Selector>,
x: f32,
y: f32,
finger: u64,
) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Press,
&id,
serde_json::json!({"x": x, "y": y, "button": "left", "pointer": "touch", "finger": finger}),
));
}
pub fn canvas_touch_release(
&mut self,
selector: impl Into<Selector>,
x: f32,
y: f32,
finger: u64,
) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Release,
&id,
serde_json::json!({"x": x, "y": y, "button": "left", "pointer": "touch", "finger": finger}),
));
}
pub fn canvas_touch_move(
&mut self,
selector: impl Into<Selector>,
x: f32,
y: f32,
finger: u64,
) {
let id = self.resolve(selector).id.clone();
self.dispatch(widget_event(
EventType::Move,
&id,
serde_json::json!({"x": x, "y": y, "pointer": "touch", "finger": finger}),
));
}
pub fn dispatch(&mut self, event: Event) {
let cmd = match self.widget_store.intercept_event(&event) {
Some(Interception {
result: EventResult::Consumed,
..
}) => {
Command::None
}
Some(Interception {
result: EventResult::Emit { family, value },
widget_id,
outer_scope,
window_id,
}) => {
let new_event = Event::Widget(WidgetEvent {
event_type: crate::event::family_to_event_type(&family),
scoped_id: plushie_core::ScopedId::new(widget_id, outer_scope, Some(window_id)),
value,
});
A::update(&mut self.model, new_event)
}
Some(Interception {
result: EventResult::Ignored,
..
})
| None => A::update(&mut self.model, event),
};
self.execute_command(cmd);
self.run_pending_async();
let (tree, warnings) = runtime::prepare_tree::<A>(
&self.model,
&mut self.widget_store,
&mut self.memo_cache,
&mut self.widget_view_cache,
);
self.tree = tree;
self.diagnostics.extend(warnings);
let subs = A::subscribe(&self.model);
let ops = self.sub_manager.sync(subs);
if !ops.is_empty() {
self.last_sub_ops = ops;
}
}
pub fn rerender(&mut self) {
let (tree, warnings) = runtime::prepare_tree::<A>(
&self.model,
&mut self.widget_store,
&mut self.memo_cache,
&mut self.widget_view_cache,
);
self.tree = tree;
self.diagnostics.extend(warnings);
}
fn execute_command(&mut self, cmd: Command) {
self.execute_command_at_depth(cmd, 0);
}
fn execute_command_at_depth(&mut self, cmd: Command, depth: usize) {
if depth >= runtime::DISPATCH_DEPTH_LIMIT {
let diag = plushie_core::Diagnostic::DispatchLoopExceeded {
depth: depth + 1,
limit: runtime::DISPATCH_DEPTH_LIMIT,
};
log::error!("{diag}");
self.diagnostics.push(diag);
return;
}
match cmd {
Command::None | Command::Exit => {}
Command::Batch(cmds) => {
for c in cmds {
self.execute_command_at_depth(c, depth);
}
}
Command::Async { tag, task } => {
self.pending_async.push((tag, task));
}
Command::Stream { tag, task } => {
self.pending_streams.push((tag, task));
}
Command::SendAfter { event, .. } => {
let cmd = A::update(&mut self.model, *event);
self.execute_command_at_depth(cmd, depth + 1);
}
Command::Cancel { tag } => {
self.pending_async.retain(|(t, _)| t != &tag);
self.pending_streams.retain(|(t, _)| t != &tag);
}
Command::Renderer(op) => {
if let crate::command::RendererOp::Effect {
ref tag,
ref request,
..
} = op
{
let kind = request.kind();
if let Some(result) = self.effect_stubs.get(kind).cloned() {
let event = Event::Effect(EffectEvent {
tag: tag.clone(),
result,
});
let cmd = A::update(&mut self.model, event);
self.execute_command_at_depth(cmd, depth + 1);
return;
}
}
log::trace!("TestSession: recording renderer op: {op:?}");
self.issued_ops.push(op);
}
}
}
pub fn await_async(
&self,
tag: &str,
_timeout: std::time::Duration,
) -> Option<&Result<Value, Value>> {
self.async_results.get(tag)
}
pub fn advance_frame(&mut self, timestamp: u64) {
self.dispatch(Event::System(crate::event::SystemEvent {
event_type: crate::event::SystemEventType::AnimationFrame,
tag: None,
value: Some(serde_json::json!({ "timestamp": timestamp })),
id: None,
window_id: None,
}));
}
pub fn skip_transitions(&mut self) {
self.advance_frame(10_000);
}
pub fn register_effect_stub(&mut self, kind: EffectKind, response: EffectResult) {
self.effect_stubs
.insert(kind.wire_name().to_string(), response);
}
pub fn unregister_effect_stub(&mut self, kind: EffectKind) {
self.effect_stubs.remove(kind.wire_name());
}
pub fn reset(&mut self) {
let (model, init_cmd) = A::init();
self.model = model;
self.widget_store = WidgetStateStore::new();
self.memo_cache = runtime::MemoCache::new();
self.widget_view_cache = runtime::WidgetViewCache::new();
self.async_results.clear();
self.effect_stubs.clear();
self.sub_manager = SubscriptionManager::new();
self.last_sub_ops.clear();
self.pending_async.clear();
self.pending_streams.clear();
self.issued_ops.clear();
let (tree, warnings) = runtime::prepare_tree::<A>(
&self.model,
&mut self.widget_store,
&mut self.memo_cache,
&mut self.widget_view_cache,
);
self.tree = tree;
self.diagnostics = warnings;
self.execute_command(init_cmd);
self.run_pending_async();
}
pub fn find(&self, selector: impl Into<Selector>) -> Option<Element<'_>> {
let sel = selector.into();
sel.find(&self.tree).map(Element::new)
}
pub fn find_all(&self, selector: impl Into<Selector>) -> Vec<Element<'_>> {
let sel = selector.into();
sel.find_all(&self.tree)
.into_iter()
.map(Element::new)
.collect()
}
pub fn find_focused(&self) -> Option<Element<'_>> {
self.find(Selector::focused())
}
pub fn text_content(&self, selector: impl Into<Selector>) -> Option<String> {
self.find(selector)?.text().map(|s| s.to_string())
}
pub fn prop_str(&self, selector: impl Into<Selector>, key: &str) -> Option<String> {
self.find(selector)?.prop_str(key).map(|s| s.to_string())
}
pub fn prop(&self, selector: impl Into<Selector>, key: &str) -> Option<Value> {
self.find(selector)?.prop(key)
}
pub fn tree(&self) -> &TreeNode {
&self.tree
}
pub fn tree_hash(&self) -> String {
canonical_tree_hash(Some(&self.tree)).expect("tree serialization failed")
}
pub fn tree_snapshot(&self) -> String {
serde_json::to_string_pretty(&self.tree).expect("tree serialization failed")
}
pub fn window<'a>(&'a mut self, window_id: &str) -> WindowScope<'a, A> {
WindowScope {
session: self,
window_id: window_id.to_string(),
}
}
pub fn run_pending_async(&mut self) {
loop {
let async_batch: Vec<_> = std::mem::take(&mut self.pending_async);
let stream_batch: Vec<_> = std::mem::take(&mut self.pending_streams);
if async_batch.is_empty() && stream_batch.is_empty() {
break;
}
for (tag, task) in async_batch {
let result = run_async_sync(&tag, task);
self.async_results.insert(tag.clone(), result.clone());
let event = Event::Async(AsyncEvent { tag, result });
let cmd = A::update(&mut self.model, event);
self.execute_command(cmd);
}
for (tag, task) in stream_batch {
let emitter = crate::command::StreamEmitter::buffered(&tag);
let result = run_stream_sync(&tag, task, emitter.clone());
self.async_results.insert(tag.clone(), result.clone());
for value in emitter.drain_buffer() {
let event = Event::Stream(crate::event::StreamEvent {
tag: tag.clone(),
value,
});
let cmd = A::update(&mut self.model, event);
self.execute_command(cmd);
}
let event = Event::Async(AsyncEvent { tag, result });
let cmd = A::update(&mut self.model, event);
self.execute_command(cmd);
}
}
}
pub fn cancel_pending(&mut self, tag: &str) {
self.pending_async.retain(|(t, _)| t != tag);
self.pending_streams.retain(|(t, _)| t != tag);
}
pub fn pending_async_count(&self) -> usize {
self.pending_async.len()
}
pub fn advance_subscriptions(&mut self) {
let new_subs = A::subscribe(&self.model);
self.last_sub_ops = self.sub_manager.sync(new_subs);
}
pub fn active_subscriptions(&self) -> &[Subscription] {
self.sub_manager.active()
}
pub fn last_subscription_ops(&self) -> &[SubOp] {
&self.last_sub_ops
}
pub fn diagnostics(&self) -> Vec<String> {
self.diagnostics.iter().map(|d| d.to_string()).collect()
}
pub fn drain_diagnostics(&mut self) -> Vec<String> {
std::mem::take(&mut self.diagnostics)
.into_iter()
.map(|d| d.to_string())
.collect()
}
pub fn typed_diagnostics(&self) -> Vec<plushie_core::Diagnostic> {
self.diagnostics.clone()
}
pub fn has_diagnostic(&self, kind: plushie_core::DiagnosticKind) -> bool {
self.diagnostics.iter().any(|d| d.kind() == kind)
}
pub fn issued_ops(&self) -> &[crate::command::RendererOp] {
&self.issued_ops
}
pub fn drain_issued_ops(&mut self) -> Vec<crate::command::RendererOp> {
std::mem::take(&mut self.issued_ops)
}
pub fn assert_exists(&self, selector: impl Into<Selector>) {
let sel = selector.into();
assert!(
sel.find(&self.tree).is_some(),
"expected widget {sel} to exist in the view tree"
);
}
pub fn assert_not_exists(&self, selector: impl Into<Selector>) {
let sel = selector.into();
assert!(
sel.find(&self.tree).is_none(),
"expected widget {sel} to NOT exist in the view tree"
);
}
pub fn assert_text(&self, selector: impl Into<Selector>, expected: &str) {
let sel = selector.into();
let actual = sel
.find(&self.tree)
.and_then(|n| Element::new(n).text().map(|s| s.to_string()));
assert_eq!(
actual.as_deref(),
Some(expected),
"expected widget {sel} to display \"{expected}\", got {actual:?}",
);
}
pub fn assert_prop(&self, selector: impl Into<Selector>, key: &str, expected: &Value) {
let sel = selector.into();
let actual = sel.find(&self.tree).and_then(|n| n.props.get_value(key));
assert_eq!(
actual.as_ref(),
Some(expected),
"expected widget {sel} prop \"{key}\" to be {expected}, got {actual:?}"
);
}
pub fn assert_role(&self, selector: impl Into<Selector>, expected: &str) {
let sel = selector.into();
let elem = self
.find(sel.clone())
.unwrap_or_else(|| panic!("assert_role: element not found: {sel}"));
let actual = elem.inferred_role();
assert_eq!(actual, expected, "role mismatch for {sel}");
}
pub fn resolved_a11y(
&self,
selector: impl Into<Selector>,
) -> Option<plushie_core::types::A11y> {
let sel = selector.into();
let node = sel.find(&self.tree)?;
Some(resolve_a11y_for_node(node))
}
pub fn assert_a11y(&self, selector: impl Into<Selector>, expected: &Value) {
let sel = selector.into();
let resolved = self
.resolved_a11y(sel.clone())
.unwrap_or_else(|| panic!("assert_a11y: element not found: {sel}"));
let actual = Value::from(
<plushie_core::types::A11y as plushie_core::types::PlushieType>::wire_encode(&resolved),
);
let expected_obj = expected
.as_object()
.expect("assert_a11y: expected value must be a JSON object");
let actual_obj = actual
.as_object()
.unwrap_or_else(|| panic!("assert_a11y: resolved a11y is not an object for {sel}"));
for (key, expected_val) in expected_obj {
match actual_obj.get(key) {
Some(actual_val) if actual_val == expected_val => {}
Some(actual_val) => panic!(
"assert_a11y: a11y.{key} mismatch for {sel}\n expected: {expected_val}\n actual: {actual_val}\n full a11y: {actual}"
),
None => panic!(
"assert_a11y: a11y.{key} not found on {sel}\n expected: {expected_val}\n full a11y: {actual}"
),
}
}
}
pub fn assert_no_diagnostics(&self) {
if !self.diagnostics.is_empty() {
let details: Vec<_> = self
.diagnostics
.iter()
.map(|d| format!(" - {d}"))
.collect();
panic!(
"expected no diagnostics, but found:\n{}",
details.join("\n")
);
}
}
}
impl<A: App> TestSession<A>
where
A::Model: PartialEq + std::fmt::Debug,
{
pub fn assert_model(&self, expected: &A::Model) {
assert_eq!(self.model(), expected, "model mismatch");
}
}
impl<A: App> Drop for TestSession<A> {
fn drop(&mut self) {
if self.fail_on_diagnostics && !self.diagnostics.is_empty() {
if !std::thread::panicking() {
let details: Vec<_> = self
.diagnostics
.iter()
.map(|d| format!(" - {d}"))
.collect();
panic!(
"TestSession: diagnostics detected on drop (use allow_diagnostics() to opt out):\n{}",
details.join("\n")
);
}
}
}
}
pub fn assert_tree_hash<A: App>(session: &TestSession<A>, name: &str, golden_dir: &str) {
let hash = session.tree_hash();
let golden_path = std::path::Path::new(golden_dir);
let resolved_dir = if golden_path.is_absolute() {
golden_path.to_path_buf()
} else {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_path)
};
let path = format!("{}/{name}.sha256", resolved_dir.display());
let golden_dir = resolved_dir.display().to_string();
let update = std::env::var("PLUSHIE_UPDATE_SNAPSHOTS")
.map(|v| v == "1")
.unwrap_or(false);
if update || !std::path::Path::new(&path).exists() {
std::fs::create_dir_all(&golden_dir).ok();
std::fs::write(&path, &hash).unwrap_or_else(|e| {
panic!("failed to write golden file {path}: {e}");
});
return;
}
let stored = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read golden file {path}: {e}"));
let expected = stored.trim();
if hash != expected {
eprintln!(
"[debug] tree for {name}:\n{}",
serde_json::to_string_pretty(&session.tree).unwrap_or_default()
);
}
assert_eq!(
hash, expected,
"tree hash mismatch for \"{name}\" (run with PLUSHIE_UPDATE_SNAPSHOTS=1 to update)"
);
}
pub struct WindowScope<'a, A: App> {
session: &'a mut TestSession<A>,
window_id: String,
}
impl<A: App> WindowScope<'_, A> {
pub fn click(&mut self, selector: impl Into<Selector>) {
let id = self.session.resolve(selector).id.clone();
let event = widget_event_in_window(EventType::Click, &id, Value::Null, &self.window_id);
self.session.dispatch(event);
}
pub fn type_text(&mut self, selector: impl Into<Selector>, text: &str) {
let id = self.session.resolve(selector).id.clone();
let event = widget_event_in_window(
EventType::Input,
&id,
Value::String(text.to_string()),
&self.window_id,
);
self.session.dispatch(event);
}
pub fn opened(&mut self) {
self.session.dispatch(window_lifecycle(
&self.window_id,
crate::event::WindowEventType::Opened,
));
}
pub fn closed(&mut self) {
self.session.dispatch(window_lifecycle(
&self.window_id,
crate::event::WindowEventType::CloseRequested,
));
self.session.dispatch(window_lifecycle(
&self.window_id,
crate::event::WindowEventType::Closed,
));
}
pub fn resized(&mut self, width: f32, height: f32) {
let event = Event::Window(crate::event::WindowEvent {
event_type: crate::event::WindowEventType::Resized,
window_id: self.window_id.clone(),
x: None,
y: None,
width: Some(width),
height: Some(height),
path: None,
scale_factor: None,
});
self.session.dispatch(event);
}
pub fn focused(&mut self) {
self.session.dispatch(window_lifecycle(
&self.window_id,
crate::event::WindowEventType::Focused,
));
}
pub fn unfocused(&mut self) {
self.session.dispatch(window_lifecycle(
&self.window_id,
crate::event::WindowEventType::Unfocused,
));
}
}
fn widget_event_in_window(event_type: EventType, id: &str, value: Value, window_id: &str) -> Event {
let mut parsed = plushie_core::ScopedId::parse(id);
if parsed.window_id.is_none() {
parsed = plushie_core::ScopedId::new(parsed.id, parsed.scope, Some(window_id.to_string()));
}
Event::Widget(WidgetEvent {
event_type,
scoped_id: parsed,
value,
})
}
fn window_lifecycle(window_id: &str, event_type: crate::event::WindowEventType) -> Event {
Event::Window(crate::event::WindowEvent {
event_type,
window_id: window_id.to_string(),
x: None,
y: None,
width: None,
height: None,
path: None,
scale_factor: None,
})
}
pub struct WidgetTestSession<W: crate::widget::Widget> {
inner: TestSession<WidgetHarness<W>>,
}
pub struct WidgetHarness<W: crate::widget::Widget> {
widget_id: String,
props: plushie_core::protocol::PropMap,
events: Vec<(String, Value)>,
_marker: std::marker::PhantomData<W>,
}
impl<W: crate::widget::Widget> App for WidgetHarness<W> {
type Model = Self;
fn init() -> (Self, Command) {
(
Self {
widget_id: String::new(),
props: plushie_core::protocol::PropMap::new(),
events: Vec::new(),
_marker: std::marker::PhantomData,
},
Command::None,
)
}
fn update(model: &mut Self, event: Event) -> Command {
if let Event::Widget(ref w) = event {
model
.events
.push((w.event_type.as_family().to_string(), w.value.clone()));
}
Command::None
}
fn view(model: &Self, widgets: &mut crate::widget::WidgetRegistrar) -> crate::ViewList {
use crate::ui::*;
let mut wv = crate::widget::WidgetView::<W>::new(&model.widget_id);
for (key, value) in model.props.iter() {
wv = wv.prop(key, value.clone());
}
window("main")
.child(column().child(wv.register(widgets)))
.into()
}
}
impl<W: crate::widget::Widget> WidgetTestSession<W> {
pub fn start(id: &str) -> Self {
let mut session = TestSession::<WidgetHarness<W>>::start();
session.model_mut().widget_id = id.to_string();
session.rerender();
let _ = session.drain_diagnostics();
Self { inner: session }
}
pub fn start_with_props(
id: &str,
props: Vec<(&str, plushie_core::protocol::PropValue)>,
) -> Self {
let mut session = TestSession::<WidgetHarness<W>>::start();
session.model_mut().widget_id = id.to_string();
for (key, value) in props {
session.model_mut().props.insert(key, value);
}
let _ = session.drain_diagnostics(); session.rerender();
session.dispatch(Event::System(crate::event::SystemEvent {
event_type: crate::event::SystemEventType::AnimationFrame,
tag: None,
value: None,
id: None,
window_id: None,
}));
Self { inner: session }
}
pub fn events(&self) -> &[(String, Value)] {
&self.inner.model().events
}
pub fn last_event(&self) -> Option<&(String, Value)> {
self.inner.model().events.last()
}
pub fn drain_events(&mut self) -> Vec<(String, Value)> {
std::mem::take(&mut self.inner.model_mut().events)
}
pub fn session(&self) -> &TestSession<WidgetHarness<W>> {
&self.inner
}
pub fn session_mut(&mut self) -> &mut TestSession<WidgetHarness<W>> {
&mut self.inner
}
pub fn click(&mut self, selector: impl Into<Selector>) {
self.inner.click(selector);
}
pub fn type_text(&mut self, selector: impl Into<Selector>, text: &str) {
self.inner.type_text(selector, text);
}
pub fn toggle(&mut self, selector: impl Into<Selector>) {
self.inner.toggle(selector);
}
pub fn set_toggle(&mut self, selector: impl Into<Selector>, checked: bool) {
self.inner.set_toggle(selector, checked);
}
pub fn slide(&mut self, selector: impl Into<Selector>, value: f64) {
self.inner.slide(selector, value);
}
pub fn press(&mut self, key: impl Into<KeyPress>) {
self.inner.press(key);
}
pub fn find(&self, selector: impl Into<Selector>) -> Option<Element<'_>> {
self.inner.find(selector)
}
pub fn assert_exists(&self, selector: impl Into<Selector>) {
self.inner.assert_exists(selector);
}
pub fn assert_text(&self, selector: impl Into<Selector>, expected: &str) {
self.inner.assert_text(selector, expected);
}
}
fn widget_event(event_type: EventType, id: &str, value: Value) -> Event {
Event::Widget(WidgetEvent {
event_type,
scoped_id: plushie_core::ScopedId::parse(id),
value,
})
}
fn run_async_sync(tag: &str, task_fn: crate::command::AsyncTaskFn) -> Result<Value, Value> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create test tokio runtime");
rt.block_on(async move {
use futures::FutureExt;
let future = (task_fn)();
match std::panic::AssertUnwindSafe(future).catch_unwind().await {
Ok(result) => result,
Err(payload) => {
let msg = panic_message(&*payload);
log::error!("async task `{tag}` panicked: {msg}");
Err(serde_json::json!({ "error": "panic", "message": msg }))
}
}
})
}
fn run_stream_sync(
tag: &str,
task_fn: crate::command::StreamTaskFn,
emitter: crate::command::StreamEmitter,
) -> Result<Value, Value> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create test tokio runtime");
rt.block_on(async move {
use futures::FutureExt;
let future = (task_fn)(emitter);
match std::panic::AssertUnwindSafe(future).catch_unwind().await {
Ok(result) => result,
Err(payload) => {
let msg = panic_message(&*payload);
log::error!("stream task `{tag}` panicked: {msg}");
Err(serde_json::json!({ "error": "panic", "message": msg }))
}
}
})
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str {
payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("(non-string panic)")
}
fn key_event(
event_type: crate::event::KeyEventType,
key: &plushie_core::Key,
modifiers: crate::types::KeyModifiers,
) -> Event {
let text = if event_type == crate::event::KeyEventType::Press {
match key {
plushie_core::Key::Char(c) => Some(c.to_string()),
_ => None,
}
} else {
None
};
Event::Key(crate::event::KeyEvent {
event_type,
key: key.clone(),
modified_key: None,
physical_key: None,
location: crate::event::KeyLocation::Standard,
modifiers,
text,
repeat: false,
captured: false,
window_id: Some("main".to_string()),
})
}
fn resolve_a11y_for_node(node: &TreeNode) -> plushie_core::types::A11y {
use plushie_core::types::A11y;
use plushie_core::types::PlushieType;
let explicit = A11y::extract(&node.props, "a11y").unwrap_or_default();
let mut inferred = A11y::default();
match node.type_name.as_str() {
"text_input" | "text_editor" | "combo_box" | "pick_list" => {
if let Some(placeholder) = node.props.get_str("placeholder") {
inferred.description = Some(placeholder.to_string());
}
}
"image" | "svg" | "qr_code" => {
if let Some(alt) = node.props.get_str("alt") {
inferred.label = Some(alt.to_string());
}
}
_ => {}
}
A11y::merge(&inferred, &explicit)
}
fn collect_tree_ids(tree: &TreeNode) -> Vec<String> {
fn walk(node: &TreeNode, out: &mut Vec<String>) {
if !node.id.is_empty() {
out.push(node.id.clone());
}
for child in &node.children {
walk(child, out);
}
}
let mut ids = Vec::new();
walk(tree, &mut ids);
ids
}