use plushie::prelude::*;
use plushie::test::TestSession;
use serde_json::json;
struct Counter {
count: i32,
}
impl App for Counter {
type Model = Self;
fn init() -> (Self, Command) {
(Counter { count: 0 }, Command::none())
}
fn update(model: &mut Self, event: Event) -> Command {
match event.widget_match() {
Some(Click("inc")) => model.count += 1,
Some(Click("dec")) => model.count -= 1,
Some(Click("reset")) => model.count = 0,
_ => {}
}
Command::none()
}
fn view(model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main")
.title("Counter")
.child(
column()
.spacing(8.0)
.padding(16)
.child(text(&format!("{}", model.count)).id("display"))
.child(row().spacing(4.0).children([
button("inc", "+"),
button("dec", "-"),
button("reset", "Reset"),
])),
)
.into()
}
}
#[test]
fn counter_starts_at_zero() {
let session = TestSession::<Counter>::start();
assert_eq!(session.model().count, 0);
}
#[test]
fn counter_increments_on_click() {
let mut session = TestSession::<Counter>::start();
session.click("inc");
assert_eq!(session.model().count, 1);
}
#[test]
fn counter_decrements_on_click() {
let mut session = TestSession::<Counter>::start();
session.click("dec");
assert_eq!(session.model().count, -1);
}
#[test]
fn counter_multiple_clicks() {
let mut session = TestSession::<Counter>::start();
session.click("inc");
session.click("inc");
session.click("inc");
session.click("dec");
assert_eq!(session.model().count, 2);
}
#[test]
fn counter_reset() {
let mut session = TestSession::<Counter>::start();
session.click("inc");
session.click("inc");
session.click("reset");
assert_eq!(session.model().count, 0);
}
#[test]
fn counter_view_reflects_model() {
let mut session = TestSession::<Counter>::start();
session.assert_text("display", "0");
session.click("inc");
session.assert_text("display", "1");
session.click("inc");
session.assert_text("display", "2");
}
#[test]
fn counter_buttons_exist() {
let session = TestSession::<Counter>::start();
session.assert_exists("inc");
session.assert_exists("dec");
session.assert_exists("reset");
}
struct Form {
name: String,
agreed: bool,
volume: f64,
}
impl App for Form {
type Model = Self;
fn init() -> (Self, Command) {
(
Form {
name: String::new(),
agreed: false,
volume: 50.0,
},
Command::none(),
)
}
fn update(model: &mut Self, event: Event) -> Command {
match event.widget_match() {
Some(Input("name", text)) => model.name = text.to_string(),
Some(Toggle("agree", on)) => model.agreed = on,
Some(Slide("volume", vol)) => model.volume = vol,
Some(Submit("name", text)) => {
model.name = text.to_string();
}
_ => {}
}
Command::none()
}
fn view(model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main")
.child(
column()
.spacing(8.0)
.child(text_input("name", &model.name).placeholder("Your name"))
.child(checkbox("agree", model.agreed).label("I agree"))
.child(slider("volume", (0.0, 100.0), model.volume as f32))
.child(text(&format!("Name: {}", model.name)).id("name_display"))
.child(text(&format!("Agreed: {}", model.agreed)).id("agreed_display")),
)
.into()
}
}
#[test]
fn form_text_input() {
let mut session = TestSession::<Form>::start();
session.type_text("name", "Alice");
assert_eq!(session.model().name, "Alice");
session.assert_text("name_display", "Name: Alice");
}
#[test]
fn form_toggle() {
let mut session = TestSession::<Form>::start();
assert!(!session.model().agreed);
session.set_toggle("agree", true);
assert!(session.model().agreed);
session.assert_text("agreed_display", "Agreed: true");
}
#[test]
fn form_toggle_auto_flips_current_value() {
let mut session = TestSession::<Form>::start();
session.toggle("agree");
assert!(session.model().agreed);
session.toggle("agree");
assert!(!session.model().agreed);
}
#[test]
fn form_submit_reads_current_value_when_no_arg() {
let mut session = TestSession::<Form>::start();
session.type_text("name", "Alice");
session.submit("name");
assert_eq!(session.model().name, "Alice");
}
#[test]
fn form_slider() {
let mut session = TestSession::<Form>::start();
session.slide("volume", 75.0);
assert!((session.model().volume - 75.0).abs() < f64::EPSILON);
}
#[test]
fn form_submit() {
let mut session = TestSession::<Form>::start();
session.submit_with("name", "Bob");
assert_eq!(session.model().name, "Bob");
}
struct TodoItem {
id: String,
text: String,
done: bool,
}
struct TodoApp {
items: Vec<TodoItem>,
next_id: usize,
input: String,
}
impl App for TodoApp {
type Model = Self;
fn init() -> (Self, Command) {
(
TodoApp {
items: vec![
TodoItem {
id: "1".into(),
text: "Buy milk".into(),
done: false,
},
TodoItem {
id: "2".into(),
text: "Write tests".into(),
done: true,
},
],
next_id: 3,
input: String::new(),
},
Command::none(),
)
}
fn update(model: &mut Self, event: Event) -> Command {
match event.widget_match() {
Some(Input("new_todo", text)) => {
model.input = text.to_string();
}
Some(Submit("new_todo", text)) => {
let id = model.next_id.to_string();
model.next_id += 1;
model.items.push(TodoItem {
id,
text: text.to_string(),
done: false,
});
model.input.clear();
}
Some(Toggle("done", _)) => {
if let Some(item_id) = event.scope().and_then(|s| s.first())
&& let Some(item) = model.items.iter_mut().find(|i| i.id == *item_id)
{
item.done = !item.done;
}
}
Some(Click("delete")) => {
if let Some(item_id) = event.scope().and_then(|s| s.first()) {
model.items.retain(|i| i.id != *item_id);
}
}
_ => {}
}
Command::none()
}
fn view(model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main")
.title("Todos")
.child(
column()
.spacing(8.0)
.padding(16)
.child(text_input("new_todo", &model.input).placeholder("Add todo..."))
.child(
column()
.id("list")
.spacing(4.0)
.children(model.items.iter().map(|item| {
row()
.id(&item.id)
.spacing(8.0)
.child(checkbox("done", item.done).label(&item.text))
.child(button("delete", "X"))
})),
),
)
.into()
}
}
#[test]
fn todo_starts_with_items() {
let session = TestSession::<TodoApp>::start();
assert_eq!(session.model().items.len(), 2);
}
#[test]
fn todo_add_item_via_submit() {
let mut session = TestSession::<TodoApp>::start();
session.submit_with("new_todo", "Learn Rust");
assert_eq!(session.model().items.len(), 3);
assert_eq!(session.model().items[2].text, "Learn Rust");
}
#[test]
fn todo_text_input_updates_model() {
let mut session = TestSession::<TodoApp>::start();
session.type_text("new_todo", "Draft");
assert_eq!(session.model().input, "Draft");
}
struct CommandApp {
last_action: String,
}
impl App for CommandApp {
type Model = Self;
fn init() -> (Self, Command) {
(
CommandApp {
last_action: String::new(),
},
Command::none(),
)
}
fn update(model: &mut Self, event: Event) -> Command {
match event.widget_match() {
Some(Click("focus_email")) => {
model.last_action = "focus".into();
Command::focus("email")
}
Some(Click("quit")) => {
model.last_action = "quit".into();
Command::exit()
}
_ => Command::none(),
}
}
fn view(_model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main")
.child(
column()
.child(button("focus_email", "Focus Email"))
.child(button("quit", "Quit")),
)
.into()
}
}
#[test]
fn command_app_updates_model_on_interaction() {
let mut session = TestSession::<CommandApp>::start();
session.click("focus_email");
assert_eq!(session.model().last_action, "focus");
}
struct MixedEventApp {
clicks: usize,
inputs: Vec<String>,
selections: Vec<String>,
}
impl App for MixedEventApp {
type Model = Self;
fn init() -> (Self, Command) {
(
MixedEventApp {
clicks: 0,
inputs: vec![],
selections: vec![],
},
Command::none(),
)
}
fn update(model: &mut Self, event: Event) -> Command {
if let Event::Widget(w) = &event {
match (&w.event_type, w.scoped_id.id.as_str()) {
(EventType::Click, _) => model.clicks += 1,
(EventType::Input, _) => {
if let Some(text) = w.value_string() {
model.inputs.push(text);
}
}
(EventType::Select, _) => {
if let Some(val) = w.value_string() {
model.selections.push(val);
}
}
_ => {}
}
}
Command::none()
}
fn view(_model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main")
.child(
column()
.child(button("btn", "Click"))
.child(text_input("inp", ""))
.child(pick_list("sel", &["A", "B", "C"], None)),
)
.into()
}
}
#[test]
fn mixed_events_via_full_match() {
let mut session = TestSession::<MixedEventApp>::start();
session.click("btn");
session.click("btn");
session.type_text("inp", "hello");
session.select("sel", "B");
assert_eq!(session.model().clicks, 2);
assert_eq!(session.model().inputs, vec!["hello"]);
assert_eq!(session.model().selections, vec!["B"]);
}
#[test]
fn assert_exists_finds_widget() {
let session = TestSession::<Counter>::start();
session.assert_exists("inc");
}
#[test]
#[should_panic(expected = "expected widget nonexistent to exist")]
fn assert_exists_panics_for_missing_widget() {
let session = TestSession::<Counter>::start();
session.assert_exists("nonexistent");
}
#[test]
fn assert_not_exists_for_missing_widget() {
let session = TestSession::<Counter>::start();
session.assert_not_exists("nonexistent");
}
#[test]
#[should_panic(expected = "expected widget inc to NOT exist")]
fn assert_not_exists_panics_for_existing_widget() {
let session = TestSession::<Counter>::start();
session.assert_not_exists("inc");
}
#[test]
fn prop_reads_widget_property() {
let session = TestSession::<Form>::start();
let placeholder = session.prop_str("name", "placeholder");
assert_eq!(placeholder.as_deref(), Some("Your name"));
}
#[derive(Default)]
struct Importer {
chunks: Vec<String>,
done: bool,
}
impl App for Importer {
type Model = Self;
fn init() -> (Self, Command) {
(
Importer::default(),
Command::stream("import", |emitter| async move {
emitter.emit(json!("alpha"));
emitter.emit(json!("beta"));
emitter.emit(json!("gamma"));
Ok(json!({"count": 3}))
}),
)
}
fn update(model: &mut Self, event: Event) -> Command {
match event {
Event::Stream(s) if s.tag == "import" => {
if let Some(chunk) = s.value.as_str() {
model.chunks.push(chunk.to_string());
}
}
Event::Async(a) if a.tag == "import" => {
model.done = a.result.is_ok();
}
_ => {}
}
Command::none()
}
fn view(_model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main").child(text("importer")).into()
}
}
#[test]
fn stream_delivers_intermediate_emits_and_final_result() {
let session = TestSession::<Importer>::start();
assert_eq!(
session.model().chunks,
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
);
assert!(session.model().done);
}
struct LoopApp {
ticks: u32,
}
impl App for LoopApp {
type Model = Self;
fn init() -> (Self, Command) {
(LoopApp { ticks: 0 }, Command::none())
}
fn update(model: &mut Self, event: Event) -> Command {
if let Event::Timer(t) = &event
&& t.tag == "tick"
{
model.ticks += 1;
return Command::dispatch(Event::Timer(plushie::event::TimerEvent {
tag: "tick".into(),
timestamp: 0,
}));
}
Command::none()
}
fn view(_model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
window("main").child(text("loop")).into()
}
}
#[derive(Default)]
struct LifecycleApp {
fetched: Option<String>,
ticks: u32,
ticking: bool,
}
impl App for LifecycleApp {
type Model = Self;
fn init() -> (Self, Command) {
(
Self::default(),
Command::task("startup_fetch", || async { Ok(json!({"greeting": "hi"})) }),
)
}
fn update(model: &mut Self, event: Event) -> Command {
if let Some(a) = event.as_async()
&& a.tag == "startup_fetch"
&& let Ok(value) = &a.result
&& let Some(g) = value.get("greeting").and_then(|v| v.as_str())
{
model.fetched = Some(g.to_string());
model.ticking = true;
}
if let Event::Timer(t) = &event
&& t.tag == "tick"
{
model.ticks += 1;
}
Command::none()
}
fn subscribe(model: &Self) -> Vec<Subscription> {
if model.ticking {
vec![Subscription::every(
std::time::Duration::from_millis(16),
"tick",
)]
} else {
Vec::new()
}
}
fn view(model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
let header = match &model.fetched {
Some(g) => g.clone(),
None => "loading".into(),
};
window("main")
.child(
column()
.spacing(8.0)
.child(text(&header).id("header"))
.child(text(&format!("ticks: {}", model.ticks)).id("counter")),
)
.into()
}
}
#[test]
fn lifecycle_init_async_subscribe_timer_view() {
use plushie::runtime_internals::SubOp;
let mut session = TestSession::<LifecycleApp>::start();
assert_eq!(session.model().fetched.as_deref(), Some("hi"));
assert!(session.model().ticking);
session.rerender();
session.assert_text("header", "hi");
session.assert_text("counter", "ticks: 0");
session.advance_subscriptions();
let started_tick = session
.last_subscription_ops()
.iter()
.any(|op| matches!(op, SubOp::StartTimer { tag, .. } if tag == "tick"));
assert!(
started_tick,
"expected a Subscribe op for the `tick` subscription after the fetch settled, got: {:?}",
session.last_subscription_ops(),
);
session.dispatch(Event::Timer(plushie::event::TimerEvent {
tag: "tick".into(),
timestamp: 16,
}));
session.dispatch(Event::Timer(plushie::event::TimerEvent {
tag: "tick".into(),
timestamp: 32,
}));
assert_eq!(session.model().ticks, 2);
session.assert_text("counter", "ticks: 2");
}
#[test]
fn dispatch_depth_limit_stops_pathological_update_loop() {
let mut session = TestSession::<LoopApp>::start().allow_diagnostics();
session.dispatch(Event::Timer(plushie::event::TimerEvent {
tag: "tick".into(),
timestamp: 0,
}));
let ticks = session.model().ticks;
assert!(ticks > 0, "expected at least one tick");
assert!(
ticks <= plushie::runtime_config::DISPATCH_DEPTH_LIMIT as u32 + 1,
"update ran {ticks} times; expected at most {}",
plushie::runtime_config::DISPATCH_DEPTH_LIMIT as u32 + 1
);
let diags = session.typed_diagnostics();
assert!(
diags
.iter()
.any(|d| matches!(d, plushie_core::Diagnostic::DispatchLoopExceeded { .. })),
"expected DispatchLoopExceeded diagnostic; got {diags:?}"
);
}