#![cfg(all(feature = "desktop", not(alloc_frugal)))]
use rust_widgets::app::dispatch_trigger;
use rust_widgets::core::ObjectId;
use rust_widgets::json::{
bind_published_event, clear_global_handlers, invoke_global_handler, json_event_binding,
register_global_handler, BoundJsonLayout, EventHandlerContext, JsonEventBinding, JsonLoader,
};
use rust_widgets::platform::WidgetTriggerKind;
use rust_widgets::widget::capability::WidgetFactory;
use rust_widgets::WidgetTriggerEvent;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn with_clean_handlers(body: impl FnOnce()) {
clear_global_handlers();
body();
clear_global_handlers();
}
static CROSS_THREAD_COUNTER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serialised_cross_thread_counter(body: impl FnOnce()) {
let _guard = CROSS_THREAD_COUNTER_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
body();
}
fn counting_handler(name: &str) -> Arc<AtomicUsize> {
let calls = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&calls);
register_global_handler(name, move |_ctx: &EventHandlerContext| {
sink.fetch_add(1, Ordering::SeqCst);
});
calls
}
fn mounted_id(bound: &BoundJsonLayout, name: &str) -> ObjectId {
let declared = bound.id(name).unwrap_or_else(|| panic!("'{name}' must be bound by its id"));
assert!(
rust_widgets::widget::runtime::is_mounted(declared),
"'{name}' (id {declared}) was not mounted, so no signal can be raised on it"
);
declared
}
fn emit_click(bound: &BoundJsonLayout, name: &str) {
let id = mounted_id(bound, name);
rust_widgets::widget::runtime::with_widget_mut(id, |widget| {
widget.base().clicked.emit();
});
}
#[allow(dead_code)]
fn fire(name: &str, kind: WidgetTriggerKind) -> bool {
let ctx = EventHandlerContext::new(WidgetTriggerEvent { widget_id: 1, kind });
invoke_global_handler(name, &ctx)
}
#[test]
fn a_published_name_declared_under_events_is_wired() {
with_clean_handlers(|| {
let calls = counting_handler("on_go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_go"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("a published-name binding must load");
emit_click(&bound, "b");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"`events.clicked` must reach the handler on a real click, or the published route \
resolves the name and then wires nothing"
);
});
}
#[test]
fn a_compatibility_key_still_reaches_its_handler() {
with_clean_handlers(|| {
let calls = counting_handler("on_change_legacy");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"slider":{"id":"s","on_change":"on_change_legacy"}}]}}}"#;
let bound = JsonLoader::load(json).expect("a compatibility binding must load");
let fired = dispatch_trigger(mounted_id(&bound, "s"), WidgetTriggerKind::ValueChanged);
assert!(fired, "`on_change` must reach the value callback table");
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn closed_reaches_the_handle_through_both_routes() {
with_clean_handlers(|| {
let published = counting_handler("on_closed_pub");
let legacy = counting_handler("on_closed_leg");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,
"events":{"closed":"on_closed_pub"},"on_close":"on_closed_leg"}}"#;
let bound = JsonLoader::load(json).expect("a `closed` binding must load, not be refused");
let id = mounted_id(&bound, "w");
assert!(
rust_widgets::close_widget(id),
"a mounted widget must receive its own close signal"
);
assert_eq!(
published.load(Ordering::SeqCst),
1,
"`events.closed` must reach the handler on the widget's closed signal"
);
assert_eq!(legacy.load(Ordering::SeqCst), 1, "`on_close` must reach the same channel");
});
}
#[test]
fn both_routes_can_be_declared_on_one_node() {
with_clean_handlers(|| {
let published = counting_handler("on_pub");
let legacy = counting_handler("on_leg");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_pub"},"on_change":"on_leg"}}]}}}"#;
let bound = JsonLoader::load(json).expect("both routes on one node must load");
emit_click(&bound, "b");
dispatch_trigger(mounted_id(&bound, "b"), WidgetTriggerKind::ValueChanged);
assert_eq!(published.load(Ordering::SeqCst), 1, "the published route must fire");
assert_eq!(legacy.load(Ordering::SeqCst), 1, "the compatibility route too");
});
}
#[test]
fn distinct_published_names_reach_distinct_signals() {
with_clean_handlers(|| {
let clicked = counting_handler("on_clicked");
let canceled = counting_handler("on_canceled");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_clicked","canceled":"on_canceled"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("two published names must load");
let id = mounted_id(&bound, "b");
rust_widgets::widget::runtime::with_widget_mut(id, |widget| {
rust_widgets::widget::Widget::event_signal_dyn(widget, "canceled")
.expect("`button` publishes `canceled`")
.emit_unit();
});
assert_eq!(
clicked.load(Ordering::SeqCst),
0,
"a cancel must not run the `clicked` handler"
);
assert_eq!(canceled.load(Ordering::SeqCst), 1, "a cancel must run the `canceled` handler");
emit_click(&bound, "b");
assert_eq!(clicked.load(Ordering::SeqCst), 1, "a click must run the `clicked` handler");
assert_eq!(
canceled.load(Ordering::SeqCst),
1,
"a click must not run the `canceled` handler"
);
});
}
#[test]
fn a_handler_receives_the_event_payload() {
use std::sync::Mutex;
clear_global_handlers();
let seen: Arc<Mutex<Vec<i64>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
register_global_handler("on_value", move |ctx: &EventHandlerContext| {
if let rust_widgets::widget::capability::CapabilityValue::Int(value) = ctx.payload {
sink.lock().unwrap().push(value);
}
});
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"slider":{"id":"s","min":0,"max":100,
"events":{"value_changed":"on_value"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("a payload binding must load");
let id = mounted_id(&bound, "s");
rust_widgets::widget::capability::write_widget_property_by_id(
id,
"value",
rust_widgets::widget::capability::CapabilityValue::Int(42),
)
.expect("the slider accepts a value write");
let captured = seen.lock().unwrap().clone();
assert_eq!(
captured,
vec![42],
"the handler must receive the value the control emitted, not a default"
);
clear_global_handlers();
}
#[test]
fn two_bindings_on_one_node_both_fire() {
with_clean_handlers(|| {
let first = counting_handler("on_first");
let second = counting_handler("on_second");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_first"},"on_click":"on_second"}}]}}}"#;
let bound = JsonLoader::load(json).expect("two bindings on one node must load");
emit_click(&bound, "b");
assert_eq!(
first.load(Ordering::SeqCst),
1,
"`on_click` must not have replaced the \
published binding"
);
assert_eq!(
second.load(Ordering::SeqCst),
1,
"and the published binding must not have \
replaced it either"
);
});
}
#[test]
fn a_value_event_is_not_wired_to_the_click_callback() {
with_clean_handlers(|| {
let calls = counting_handler("on_slide");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"slider":{"id":"s","min":0,"max":100,
"events":{"value_changed":"on_slide"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("a payload-carrying published name must load");
let id = mounted_id(&bound, "s");
rust_widgets::widget::runtime::with_widget_mut(id, |widget| {
widget.base().clicked.emit();
});
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"`value_changed` must not be wired to the click signal"
);
rust_widgets::widget::capability::write_widget_property_by_id(
id,
"value",
rust_widgets::widget::capability::CapabilityValue::Int(50),
)
.expect("a slider accepts a `value` write");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a real value change must reach the `value_changed` handler"
);
});
}
#[test]
fn the_callback_choice_follows_the_declared_intent() {
let factory = WidgetFactory::new_with_defaults();
let mut checked = 0usize;
for capability in factory.capabilities() {
for schema in capability.events {
let binding = json_event_binding(capability.canonical_name, schema.name);
let JsonEventBinding::Published { has_payload, name, marker } = binding else {
panic!("a published name must produce a Published binding, not a marker");
};
assert_eq!(
name, schema.name,
"the binding must carry the table's own spelling of the name"
);
assert_eq!(
has_payload,
schema.payload.is_some(),
"`{}.{}` declares {} but the binding says {}",
capability.canonical_name,
schema.name,
if schema.payload.is_some() { "a payload" } else { "no payload" },
if has_payload { "a payload" } else { "no payload" }
);
assert_eq!(
binding.marker(),
marker,
"`{}.{}` must report the marker its binding carries",
capability.canonical_name,
schema.name
);
assert_eq!(
binding.uses_value_callback(),
marker.uses_value_callback(),
"`{}.{}` must be wired to the callback its marker implies, not its payload",
capability.canonical_name,
schema.name
);
if marker.unroutable_reason().is_some() {
assert!(
!bind_published_event(0u64, capability.canonical_name, schema.name, "h"),
"`{}.{}` names an unroutable trigger and must be refused",
capability.canonical_name,
schema.name
);
}
checked += 1;
}
}
assert!(
checked > 300,
"the table has 300+ (control, event) pairs; only {checked} were checked"
);
}
#[test]
fn an_unpublished_name_cannot_be_subscribed_while_a_published_one_can() {
let hub = rust_widgets::signal::CustomSignalHub::new();
let factory = WidgetFactory::new_with_defaults();
factory
.connect_event("button", "clicked", &hub, || {})
.expect("`button` publishes `clicked`, so the loader's published route can accept it");
assert!(
factory.connect_event("button", "clikced", &hub, || {}).is_err(),
"`clikced` is not a published name, which is exactly why a document declaring it must not \
end up with a live binding"
);
}
#[test]
fn a_typo_in_an_event_name_does_not_cost_the_node_its_other_binding() {
with_clean_handlers(|| {
let good = counting_handler("on_good");
counting_handler("on_typo");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clikced":"on_typo","clicked":"on_good"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("a refused binding must not fail the load");
assert!(
bound.id("b").is_some(),
"the button must still be registered: one bad handler name is not a broken layout"
);
emit_click(&bound, "b");
assert_eq!(
good.load(Ordering::SeqCst),
1,
"the correct binding on the same node must still fire"
);
});
}
#[test]
fn a_cross_thread_emission_is_reported_rather_than_silently_skipped() {
serialised_cross_thread_counter(|| {
with_clean_handlers(|| {
rust_widgets::json::reset_cross_thread_skips();
let calls = counting_handler("on_go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_go"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("the document must load");
let id = mounted_id(&bound, "b");
let signal = rust_widgets::widget::runtime::with_widget(id, |widget| {
widget.base().clicked.clone()
})
.expect("the button must be mounted");
let worker = std::thread::spawn(move || signal.emit());
worker.join().expect("the worker must not panic");
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"a GUI handler must not run on a worker thread"
);
assert_eq!(
rust_widgets::json::cross_thread_skips(),
1,
"the off-thread emission must be recorded, so it is no longer a silent drop"
);
rust_widgets::json::reset_cross_thread_skips();
});
});
}
#[test]
fn a_differently_named_thread_cannot_bypass_affinity_by_sharing_a_name() {
serialised_cross_thread_counter(|| {
with_clean_handlers(|| {
rust_widgets::json::reset_cross_thread_skips();
let bound_calls = counting_handler("go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"go"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("the document must load");
let id = mounted_id(&bound, "b");
let signal = rust_widgets::widget::runtime::with_widget(id, |widget| {
widget.base().clicked.clone()
})
.expect("the button must be mounted");
let worker_name = std::thread::current()
.name()
.map(str::to_owned)
.unwrap_or_else(|| "test-thread".to_string());
let worker_calls = std::sync::Arc::new(AtomicUsize::new(0));
let worker_sink = std::sync::Arc::clone(&worker_calls);
let worker = std::thread::Builder::new()
.name(worker_name)
.spawn(move || {
rust_widgets::json::register_global_handler("go", move |_ctx| {
worker_sink.fetch_add(1, Ordering::SeqCst);
});
signal.emit();
})
.expect("spawn the same-named worker");
worker.join().expect("the worker must not panic");
assert_eq!(
worker_calls.load(Ordering::SeqCst),
0,
"a same-named thread is still a different thread; its handler must not run"
);
assert_eq!(
bound_calls.load(Ordering::SeqCst),
0,
"and the bound thread's handler runs here"
);
assert!(
rust_widgets::json::cross_thread_skips() >= 1,
"the cross-thread emission must be reported even when the name collides"
);
rust_widgets::json::reset_cross_thread_skips();
});
});
}
#[test]
fn a_load_time_binding_is_released_with_its_control() {
with_clean_handlers(|| {
counting_handler("on_go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_go"}}}]}}}"#;
let bound = JsonLoader::load(json).expect("the document must load");
let id = mounted_id(&bound, "b");
assert_eq!(
rust_widgets::json::retained_binding_count(id),
1,
"the published `events` binding must be retained (not leaked via `forget`), keyed by the \
control"
);
rust_widgets::json::release_widget_bindings(id);
assert_eq!(
rust_widgets::json::retained_binding_count(id),
0,
"releasing the control's bindings must drop its retained tokens"
);
});
}
#[test]
fn detaching_a_node_releases_its_bindings() {
with_clean_handlers(|| {
counting_handler("on_go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_go"}}}]}}}"#;
let mut bound = JsonLoader::load(json).expect("the document must load");
let id = mounted_id(&bound, "b");
assert_eq!(rust_widgets::json::retained_binding_count(id), 1);
let removed = bound.detach(id);
assert!(removed.contains(&id), "the node must be detached");
assert_eq!(
rust_widgets::json::retained_binding_count(id),
0,
"`detach` must release the node's retained bindings, or a destroyed control's wires leak"
);
});
}
#[test]
fn a_declared_binding_is_not_reported_as_an_unknown_property() {
with_clean_handlers(|| {
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go",
"events":{"clicked":"on_a"},"on_close":"on_b","on_selection_changed":"on_c"}}]}}}"#;
let layout = JsonLoader::load(json).expect("the document must load");
assert!(!layout.is_empty(), "the document's identified widgets must be registered");
});
}
#[test]
fn a_releasable_binding_can_be_taken_back_without_destroying_the_control() {
with_clean_handlers(|| {
let calls = counting_handler("on_go");
let json = r#"{"window":{"id":"w","title":"T","width":400,"height":300,"layout":{
"type":"vbox","children":[{"button":{"id":"b","text":"Go"}}]}}}"#;
let bound = JsonLoader::load(json).expect("the document must load");
let id = mounted_id(&bound, "b");
let mut first = JsonLoader::bind_one_releasable(
id,
json_event_binding("button", "clicked"),
"on_go".to_string(),
)
.expect("a published route must yield a releasable token");
let mut second = JsonLoader::bind_one_releasable(
id,
json_event_binding("button", "clicked"),
"on_go".to_string(),
)
.expect("a second binding must also yield a token");
emit_click(&bound, "b");
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"binding is additive: two declarations are two subscriptions"
);
first.release();
assert!(!first.is_live(), "a released token is no longer live");
emit_click(&bound, "b");
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"releasing one token must leave the other subscription live"
);
second.release();
emit_click(&bound, "b");
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"with both tokens released the handler must no longer run"
);
});
}