use std::collections::{BTreeMap, HashSet};
use plushie_core::diagnostic::Diagnostic;
use plushie_core::protocol::{PropValue, TreeNode};
#[cfg(any(test, feature = "wire"))]
use plushie_core::tree_walk::walk;
use plushie_core::tree_walk::{MAX_TREE_DEPTH, TreeTransform, WalkCtx};
#[cfg(any(test, feature = "wire"))]
pub fn normalize(tree: &TreeNode) -> (TreeNode, Vec<Diagnostic>) {
let mut result = tree.clone();
let mut transform = NormalizeTransform::new();
let mut ctx = WalkCtx::default();
walk(&mut result, &mut [&mut transform], &mut ctx);
let (warnings, _ctx) = finalize_a11y(&mut result, ctx);
(result, warnings)
}
const MAX_WIDGET_ID_LEN: usize = 1024;
fn validate_widget_id(id: &str, type_name: &str, warnings: &mut Vec<Diagnostic>) {
if id.is_empty() {
return;
}
if id.len() > MAX_WIDGET_ID_LEN {
let detail = format!(
"{type_name} ID is {} bytes, exceeds {} (reason=too_long)",
id.len(),
MAX_WIDGET_ID_LEN,
);
warnings.push(Diagnostic::WidgetIdInvalid {
reason: "too_long".to_string(),
type_name: type_name.to_string(),
id: id.to_string(),
detail,
});
return;
}
if id.contains('/') {
let detail = format!(
"ID \"{id}\" contains reserved character '/'. Use container \
scoping instead. (reason=reserved_char)"
);
warnings.push(Diagnostic::WidgetIdInvalid {
reason: "reserved_char".to_string(),
type_name: type_name.to_string(),
id: id.to_string(),
detail,
});
}
if id.contains('#') {
let detail = format!(
"ID \"{id}\" contains reserved character '#'. '#' is reserved \
for window-qualified paths. (reason=reserved_char)"
);
warnings.push(Diagnostic::WidgetIdInvalid {
reason: "reserved_char".to_string(),
type_name: type_name.to_string(),
id: id.to_string(),
detail,
});
}
}
pub(crate) fn finalize_a11y(tree: &mut TreeNode, mut ctx: WalkCtx) -> (Vec<Diagnostic>, WalkCtx) {
let declared_ids = collect_ids(tree);
let radio_groups = collect_radio_groups(tree);
rewrite_a11y_in_place(tree, "", &declared_ids, &radio_groups, &mut ctx.warnings, 0);
check_missing_accessible_name(tree, &mut ctx.warnings);
let warnings = std::mem::take(&mut ctx.warnings);
(warnings, ctx)
}
pub(crate) struct NormalizeTransform<'a> {
seen_ids: HashSet<String>,
scope_stack: Vec<usize>,
truncate_children: Vec<bool>,
memo_stack: Vec<Option<MemoFrame>>,
memo_cache: Option<&'a mut super::memo_cache::MemoCache>,
}
struct MemoFrame {
scoped_id: String,
deps_hash: u64,
was_hit: bool,
}
impl<'a> NormalizeTransform<'a> {
#[cfg(any(test, feature = "wire"))]
pub(crate) fn new() -> Self {
Self::with_memo_cache(None)
}
pub(crate) fn with_memo_cache(
memo_cache: Option<&'a mut super::memo_cache::MemoCache>,
) -> Self {
Self {
seen_ids: HashSet::new(),
scope_stack: Vec::new(),
truncate_children: Vec::new(),
memo_stack: Vec::new(),
memo_cache,
}
}
}
impl TreeTransform for NormalizeTransform<'_> {
fn enter(&mut self, node: &mut TreeNode, ctx: &mut WalkCtx) {
let prev_scope_len = ctx.scope.len();
let type_name = &node.type_name;
let is_auto = node.id.starts_with("auto:");
let is_window = type_name == "window";
if !is_auto {
validate_widget_id(&node.id, &node.type_name, &mut ctx.warnings);
if node.id.is_empty()
&& !matches!(
node.type_name.as_str(),
"__widget__" | "__memo__" | "__noop__"
)
{
ctx.warnings.push(Diagnostic::EmptyId {
type_name: node.type_name.clone(),
});
}
}
let scoped_id = if is_auto || node.id.is_empty() {
node.id.clone()
} else if ctx.scope.is_empty() {
ctx.scope.push_str(&node.id);
node.id.clone()
} else {
if !ctx.scope.ends_with('#') {
ctx.scope.push('/');
}
ctx.scope.push_str(&node.id);
ctx.scope.clone()
};
if !is_auto && !scoped_id.is_empty() && !self.seen_ids.insert(scoped_id.clone()) {
ctx.warnings.push(Diagnostic::DuplicateId {
id: scoped_id.clone(),
window_id: None,
});
}
if is_window {
ctx.scope.push('#');
}
node.id = scoped_id;
let memo_frame = self.consult_memo(node);
let truncate = memo_frame.as_ref().is_some_and(|f| f.was_hit);
self.scope_stack.push(prev_scope_len);
self.truncate_children.push(truncate);
self.memo_stack.push(memo_frame);
}
fn exit(&mut self, node: &mut TreeNode, ctx: &mut WalkCtx) {
if let Some(Some(frame)) = self.memo_stack.pop() {
self.finish_memo(node, frame);
}
if let Some(prev_len) = self.scope_stack.pop() {
ctx.scope.truncate(prev_len);
}
self.truncate_children.pop();
}
fn skip_children(&self, _node: &TreeNode, _ctx: &WalkCtx) -> bool {
self.truncate_children.last().copied().unwrap_or(false)
}
}
impl NormalizeTransform<'_> {
fn consult_memo(&mut self, node: &mut TreeNode) -> Option<MemoFrame> {
if node.type_name != "__memo__" {
return None;
}
let cache = self.memo_cache.as_deref_mut()?;
let deps_hash = node
.props
.get_value("__memo_deps__")
.and_then(|v| v.as_u64())?;
cache.mark_live(&node.id);
if let Some(cached) = cache.get(&node.id, deps_hash) {
node.children = cached.to_vec();
return Some(MemoFrame {
scoped_id: node.id.clone(),
deps_hash,
was_hit: true,
});
}
Some(MemoFrame {
scoped_id: node.id.clone(),
deps_hash,
was_hit: false,
})
}
fn finish_memo(&mut self, node: &TreeNode, frame: MemoFrame) {
if frame.was_hit {
return;
}
if let Some(cache) = self.memo_cache.as_deref_mut() {
cache.insert(frame.scoped_id, frame.deps_hash, node.children.clone());
}
}
}
fn collect_ids(node: &TreeNode) -> HashSet<String> {
fn walk(node: &TreeNode, out: &mut HashSet<String>) {
if !node.id.is_empty() && !node.id.starts_with("auto:") {
out.insert(node.id.clone());
}
for child in &node.children {
walk(child, out);
}
}
let mut ids = HashSet::new();
walk(node, &mut ids);
ids
}
type RadioGroupKey = (String, String);
#[derive(Clone, Default)]
struct RadioGroupInfo {
ids: Vec<String>,
active_descendant: Option<String>,
}
fn collect_radio_groups(root: &TreeNode) -> BTreeMap<RadioGroupKey, RadioGroupInfo> {
fn walk(node: &TreeNode, scope: &str, groups: &mut BTreeMap<RadioGroupKey, RadioGroupInfo>) {
let next_scope = child_scope_of(node, scope);
if node.type_name == "radio"
&& let Some(group) = node.props.get_str("group")
{
let key = (scope.to_string(), group.to_string());
let info = groups.entry(key).or_default();
info.ids.push(node.id.clone());
if let (Some(value), Some(selected)) =
(node.props.get_str("value"), node.props.get_str("selected"))
&& value == selected
{
info.active_descendant = Some(node.id.clone());
}
}
for child in &node.children {
walk(child, &next_scope, groups);
}
}
let mut groups = BTreeMap::new();
walk(root, "", &mut groups);
groups
}
fn child_scope_of(node: &TreeNode, scope: &str) -> String {
let is_auto = node.id.starts_with("auto:");
let is_window = node.type_name == "window";
if is_window {
format!("{}#", node.id)
} else if is_auto || node.id.is_empty() {
scope.to_string()
} else {
node.id.clone()
}
}
fn rewrite_a11y_in_place(
node: &mut TreeNode,
scope: &str,
declared: &HashSet<String>,
radio_groups: &BTreeMap<RadioGroupKey, RadioGroupInfo>,
warnings: &mut Vec<Diagnostic>,
depth: usize,
) {
if depth > MAX_TREE_DEPTH {
return;
}
let next_scope = child_scope_of(node, scope);
node.props = apply_a11y_rewrites(node, scope, declared, radio_groups, warnings);
for child in &mut node.children {
rewrite_a11y_in_place(
child,
&next_scope,
declared,
radio_groups,
warnings,
depth + 1,
);
}
}
const VALIDATABLE_WIDGETS: &[&str] = &[
"text_input",
"text_editor",
"checkbox",
"pick_list",
"combo_box",
];
fn required_from_props(type_name: &str, props: &plushie_core::protocol::Props) -> Option<bool> {
if !VALIDATABLE_WIDGETS.contains(&type_name) {
return None;
}
props.get_value("required").and_then(|v| v.as_bool())
}
fn invalid_from_props(
type_name: &str,
props: &plushie_core::protocol::Props,
) -> (Option<bool>, Option<String>) {
if !VALIDATABLE_WIDGETS.contains(&type_name) {
return (None, None);
}
let Some(v) = props.get_value("validation") else {
return (None, None);
};
if let Some(s) = v.as_str() {
return match s {
"valid" => (Some(false), None),
"pending" => (None, None),
_ => (None, None),
};
}
if let Some(arr) = v.as_array()
&& arr.len() == 2
&& arr[0].as_str() == Some("invalid")
{
let msg = arr[1].as_str().map(str::to_string);
return (Some(true), msg);
}
if let Some(obj) = v.as_object() {
let state = obj.get("state").and_then(|s| s.as_str());
let message = obj
.get("message")
.and_then(|s| s.as_str())
.map(str::to_string);
return match state {
Some("valid") => (Some(false), None),
Some("pending") => (None, None),
Some("invalid") => (Some(true), message),
_ => (None, None),
};
}
(None, None)
}
fn apply_a11y_rewrites(
node: &TreeNode,
scope: &str,
declared: &HashSet<String>,
radio_groups: &BTreeMap<RadioGroupKey, RadioGroupInfo>,
warnings: &mut Vec<Diagnostic>,
) -> plushie_core::protocol::Props {
let radio_info = if node.type_name == "radio" {
node.props.get_str("group").and_then(|g| {
radio_groups
.get(&(scope.to_string(), g.to_string()))
.cloned()
})
} else {
None
};
let required_prop = required_from_props(&node.type_name, &node.props);
let (invalid_prop, error_text) = invalid_from_props(&node.type_name, &node.props);
let a11y_obj = node
.props
.get_value("a11y")
.and_then(|v| v.as_object().cloned());
if a11y_obj.is_none()
&& radio_info.is_none()
&& required_prop.is_none()
&& invalid_prop.is_none()
&& error_text.is_none()
{
return node.props.clone();
}
let mut obj = a11y_obj.unwrap_or_default();
for key in [
"labelled_by",
"described_by",
"error_message",
"active_descendant",
] {
if let Some(v) = obj.get(key).cloned()
&& let Some(s) = v.as_str()
{
let rewritten = resolve_ref(s, scope);
if !declared.contains(&rewritten) && !s.is_empty() {
warnings.push(Diagnostic::A11yRefUnresolved {
id: node.id.clone(),
key: key.to_string(),
value: s.to_string(),
is_member: false,
});
}
obj.insert(key.to_string(), serde_json::Value::String(rewritten));
}
}
if let Some(v) = obj.get("radio_group").cloned()
&& let Some(arr) = v.as_array()
{
let rewritten: Vec<serde_json::Value> = arr
.iter()
.filter_map(|item| item.as_str())
.map(|s| {
let r = resolve_ref(s, scope);
if !declared.contains(&r) && !s.is_empty() {
warnings.push(Diagnostic::A11yRefUnresolved {
id: node.id.clone(),
key: "radio_group".to_string(),
value: s.to_string(),
is_member: true,
});
}
serde_json::Value::String(r)
})
.collect();
obj.insert(
"radio_group".to_string(),
serde_json::Value::Array(rewritten),
);
}
if let Some(info) = radio_info {
if !obj.contains_key("radio_group") {
let arr: Vec<serde_json::Value> = info
.ids
.into_iter()
.map(serde_json::Value::String)
.collect();
obj.insert("radio_group".to_string(), serde_json::Value::Array(arr));
}
if !obj.contains_key("active_descendant")
&& let Some(active_descendant) = info.active_descendant
{
obj.insert(
"active_descendant".to_string(),
serde_json::Value::String(active_descendant),
);
}
}
if required_prop == Some(true) && !obj.contains_key("required") {
obj.insert("required".to_string(), serde_json::Value::Bool(true));
}
if let Some(inv) = invalid_prop
&& !obj.contains_key("invalid")
{
obj.insert("invalid".to_string(), serde_json::Value::Bool(inv));
}
if let Some(msg) = error_text
&& !obj.contains_key("error_message")
{
obj.insert("error_message".to_string(), serde_json::Value::String(msg));
}
install_a11y(&node.props, obj)
}
fn install_a11y(
base: &plushie_core::protocol::Props,
obj: serde_json::Map<String, serde_json::Value>,
) -> plushie_core::protocol::Props {
let mut map = base.as_prop_map().clone();
map.remove("a11y");
map.insert("a11y", PropValue::from(serde_json::Value::Object(obj)));
plushie_core::protocol::Props::from(map)
}
fn resolve_ref(raw: &str, scope: &str) -> String {
if raw.is_empty() || scope.is_empty() {
return raw.to_string();
}
if raw.contains('#') || raw.contains('/') {
return raw.to_string();
}
if scope.ends_with('#') {
format!("{scope}{raw}")
} else {
format!("{scope}/{raw}")
}
}
fn check_missing_accessible_name(node: &TreeNode, warnings: &mut Vec<Diagnostic>) {
fn walk(node: &TreeNode, warnings: &mut Vec<Diagnostic>) {
if widget_requires_accessible_name(&node.type_name) && !has_accessible_name(node) {
warnings.push(Diagnostic::MissingAccessibleName {
type_name: node.type_name.clone(),
id: node.id.clone(),
});
}
for child in &node.children {
walk(child, warnings);
}
}
walk(node, warnings);
}
fn widget_requires_accessible_name(type_name: &str) -> bool {
matches!(
type_name,
"button" | "toggler" | "checkbox" | "pointer_area" | "image" | "svg" | "qr_code"
)
}
fn has_accessible_name(node: &TreeNode) -> bool {
if matches!(node.type_name.as_str(), "image" | "svg")
&& node
.props
.get_value("decorative")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return true;
}
if node.props.get_str("label").is_some_and(|s| !s.is_empty()) {
return true;
}
if matches!(node.type_name.as_str(), "image" | "svg" | "qr_code")
&& node.props.get_str("alt").is_some_and(|s| !s.is_empty())
{
return true;
}
if node.type_name == "qr_code"
&& node
.props
.get_str("description")
.is_some_and(|s| !s.is_empty())
{
return true;
}
if let Some(a11y) = node.props.get_value("a11y") {
if a11y
.get("label")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
{
return true;
}
if a11y
.get("labelled_by")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
{
return true;
}
if node.type_name == "qr_code"
&& a11y
.get("description")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
{
return true;
}
}
fn has_text_child(n: &TreeNode) -> bool {
for child in &n.children {
if child.type_name == "text"
&& child
.props
.get_str("content")
.is_some_and(|s| !s.is_empty())
{
return true;
}
if has_text_child(child) {
return true;
}
}
false
}
has_text_child(node)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn normalize_with_memo(
tree: &TreeNode,
cache: &mut super::super::memo_cache::MemoCache,
) -> (TreeNode, Vec<Diagnostic>) {
cache.begin_cycle();
let mut result = tree.clone();
let mut transform = NormalizeTransform::with_memo_cache(Some(cache));
let mut ctx = WalkCtx::default();
walk(&mut result, &mut [&mut transform], &mut ctx);
drop(transform);
cache.finish_cycle();
let (warnings, _ctx) = finalize_a11y(&mut result, ctx);
(result, warnings)
}
fn memo_node(key: &str, deps_hash: u64, inner: TreeNode) -> TreeNode {
let mut props = plushie_core::protocol::PropMap::new();
props.insert("__memo_deps__", deps_hash);
TreeNode {
id: format!("memo:{key}"),
type_name: "__memo__".to_string(),
props: plushie_core::protocol::Props::from(props),
children: vec![inner],
}
}
#[test]
fn memo_hit_reuses_cached_subtree() {
use super::super::memo_cache::MemoCache;
let mut cache = MemoCache::new();
let tree1 = node(
"root",
"column",
vec![memo_node(
"hdr",
42,
node("inner", "text", vec![node("leaf", "text", vec![])]),
)],
);
let (out1, _warn1) = normalize_with_memo(&tree1, &mut cache);
let memo1 = &out1.children[0];
assert_eq!(memo1.id, "root/memo:hdr");
assert_eq!(memo1.children[0].id, "root/memo:hdr/inner");
assert_eq!(memo1.children[0].children[0].id, "root/memo:hdr/inner/leaf");
let tree2 = node(
"root",
"column",
vec![memo_node(
"hdr",
42,
node("different", "text", vec![node("leaf2", "text", vec![])]),
)],
);
let (out2, _warn2) = normalize_with_memo(&tree2, &mut cache);
let memo2 = &out2.children[0];
assert_eq!(memo2.id, "root/memo:hdr");
assert_eq!(memo2.children[0].id, "root/memo:hdr/inner");
}
#[test]
fn memo_miss_on_deps_change_renormalizes() {
use super::super::memo_cache::MemoCache;
let mut cache = MemoCache::new();
let tree1 = node(
"root",
"column",
vec![memo_node("hdr", 1, node("inner_a", "text", vec![]))],
);
let (_out1, _w1) = normalize_with_memo(&tree1, &mut cache);
let tree2 = node(
"root",
"column",
vec![memo_node("hdr", 2, node("inner_b", "text", vec![]))],
);
let (out2, _w2) = normalize_with_memo(&tree2, &mut cache);
assert_eq!(out2.children[0].children[0].id, "root/memo:hdr/inner_b");
}
#[test]
fn nested_memos_each_memoize_independently() {
use super::super::memo_cache::MemoCache;
let mut cache = MemoCache::new();
let inner_memo = memo_node("inner", 10, node("leaf", "text", vec![]));
let outer_memo = memo_node("outer", 20, inner_memo);
let tree1 = node("root", "column", vec![outer_memo]);
let (out1, _w1) = normalize_with_memo(&tree1, &mut cache);
let outer1 = &out1.children[0];
assert_eq!(outer1.id, "root/memo:outer");
let inner1 = &outer1.children[0];
assert_eq!(inner1.id, "root/memo:outer/memo:inner");
assert_eq!(inner1.children[0].id, "root/memo:outer/memo:inner/leaf");
let inner_memo2 = memo_node("inner", 10, node("other_leaf", "text", vec![]));
let outer_memo2 = memo_node("outer", 20, inner_memo2);
let tree2 = node("root", "column", vec![outer_memo2]);
let (out2, _w2) = normalize_with_memo(&tree2, &mut cache);
let outer2 = &out2.children[0];
let inner2 = &outer2.children[0];
assert_eq!(inner2.children[0].id, "root/memo:outer/memo:inner/leaf");
}
fn node(id: &str, type_name: &str, children: Vec<TreeNode>) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_core::protocol::Props::from_json(json!({})),
children,
}
}
fn node_with_props(id: &str, type_name: &str, props: serde_json::Value) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_core::protocol::Props::from_json(props),
children: vec![],
}
}
fn a11y_role(node: &TreeNode) -> Option<String> {
node.props
.get_value("a11y")
.and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string))
}
#[test]
fn scope_buffer_survives_deep_nesting() {
let tree = node(
"main",
"window",
vec![
node(
"form",
"container",
vec![
node("a", "text_input", vec![]),
node(
"row",
"row",
vec![
node("b", "text_input", vec![]),
node("c", "text_input", vec![]),
],
),
node("d", "text_input", vec![]),
],
),
node("footer", "container", vec![node("e", "text", vec![])]),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
let form = &result.children[0];
assert_eq!(form.id, "main#form");
assert_eq!(form.children[0].id, "main#form/a");
let row = &form.children[1];
assert_eq!(row.id, "main#form/row");
assert_eq!(row.children[0].id, "main#form/row/b");
assert_eq!(row.children[1].id, "main#form/row/c");
assert_eq!(form.children[2].id, "main#form/d");
let footer = &result.children[1];
assert_eq!(footer.id, "main#footer");
assert_eq!(footer.children[0].id, "main#footer/e");
}
#[test]
fn normalize_is_deterministic_across_runs() {
let build = || {
node(
"main",
"window",
vec![node(
"form",
"container",
vec![
node("a", "text_input", vec![]),
node("b", "text_input", vec![]),
],
)],
)
};
let (a, _) = normalize(&build());
let (b, _) = normalize(&build());
let collect_ids = |n: &TreeNode, out: &mut Vec<String>| {
fn walk(n: &TreeNode, out: &mut Vec<String>) {
out.push(n.id.clone());
for c in &n.children {
walk(c, out);
}
}
walk(n, out);
};
let mut a_ids = Vec::new();
let mut b_ids = Vec::new();
collect_ids(&a, &mut a_ids);
collect_ids(&b, &mut b_ids);
assert_eq!(a_ids, b_ids);
}
#[test]
fn flat_tree_preserves_ids() {
let tree = node(
"root",
"column",
vec![node("a", "text", vec![]), node("b", "text", vec![])],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty());
assert_eq!(result.children[0].id, "root/a");
assert_eq!(result.children[1].id, "root/b");
}
#[test]
fn auto_ids_are_not_scoped() {
let tree = node(
"auto:col:1:1",
"column",
vec![node("btn", "button", vec![])],
);
let (result, warnings) = normalize(&tree);
assert_eq!(warnings.len(), 1);
assert!(matches!(
warnings[0].kind(),
plushie_core::DiagnosticKind::MissingAccessibleName
));
assert_eq!(result.children[0].id, "btn");
}
#[test]
fn nested_scoping() {
let tree = node(
"form",
"container",
vec![node(
"section",
"column",
vec![node("field", "text_input", vec![])],
)],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty());
assert_eq!(result.children[0].id, "form/section");
assert_eq!(result.children[0].children[0].id, "form/section/field");
}
#[test]
fn window_scopes_children_with_hash() {
let tree = node("main", "window", vec![node("col", "column", vec![])]);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty());
assert_eq!(result.id, "main");
assert_eq!(result.children[0].id, "main#col");
}
#[test]
fn window_nested_children_use_slash_after_hash() {
let tree = node(
"main",
"window",
vec![node(
"form",
"container",
vec![node("email", "text_input", vec![])],
)],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty());
assert_eq!(result.children[0].id, "main#form");
assert_eq!(result.children[0].children[0].id, "main#form/email");
}
#[test]
fn hash_in_id_produces_warning() {
let tree = node("bad#id", "text", vec![]);
let (_, warnings) = normalize(&tree);
assert_eq!(warnings.len(), 1);
match &warnings[0] {
Diagnostic::WidgetIdInvalid { reason, detail, .. } => {
assert_eq!(reason, "reserved_char");
assert!(detail.contains("reserved character '#'"));
}
other => panic!("unexpected diagnostic: {other:?}"),
}
}
#[test]
fn non_ascii_id_is_accepted() {
let tree = node("caf\u{00e9}", "text", vec![]);
let (result, warnings) = normalize(&tree);
assert!(
!warnings
.iter()
.any(|w| matches!(w, Diagnostic::WidgetIdInvalid { .. })),
"expected no widget_id_invalid diagnostic, got {warnings:?}"
);
assert_eq!(result.id, "caf\u{00e9}");
}
#[test]
fn control_character_id_is_accepted() {
let tree = node("has\tctrl", "text", vec![]);
let (result, warnings) = normalize(&tree);
assert!(
!warnings
.iter()
.any(|w| matches!(w, Diagnostic::WidgetIdInvalid { .. })),
"expected no widget_id_invalid diagnostic, got {warnings:?}"
);
assert_eq!(result.id, "has\tctrl");
}
#[test]
fn oversize_id_produces_warning() {
let huge = "a".repeat(2000);
let tree = node(&huge, "text", vec![]);
let (_, warnings) = normalize(&tree);
assert!(
warnings.iter().any(|w| matches!(
w,
Diagnostic::WidgetIdInvalid { reason, .. } if reason == "too_long"
)),
"expected too_long diagnostic, got {warnings:?}"
);
}
#[test]
fn auto_ids_bypass_all_id_validation() {
let tree = node("auto:col:\u{00e9}", "column", vec![]);
let (_, warnings) = normalize(&tree);
assert!(
!warnings
.iter()
.any(|w| matches!(w, Diagnostic::WidgetIdInvalid { .. })),
"auto IDs must not raise widget_id_invalid, got {warnings:?}"
);
}
#[test]
fn duplicate_ids_produce_warning() {
let tree = node(
"root",
"column",
vec![node("btn", "button", vec![]), node("btn", "button", vec![])],
);
let (_, warnings) = normalize(&tree);
assert!(
warnings
.iter()
.any(|w| matches!(w, Diagnostic::DuplicateId { .. }))
);
}
#[test]
fn reserved_slash_in_id_produces_warning() {
let tree = node("form/field", "text_input", vec![]);
let (_, warnings) = normalize(&tree);
assert_eq!(warnings.len(), 1);
match &warnings[0] {
Diagnostic::WidgetIdInvalid { reason, detail, .. } => {
assert_eq!(reason, "reserved_char");
assert!(detail.contains("reserved character"));
}
other => panic!("unexpected diagnostic: {other:?}"),
}
}
#[test]
fn excessive_depth_is_truncated_with_diagnostic() {
let mut tree = node("leaf", "text", vec![]);
for i in 0..(MAX_TREE_DEPTH + 20) {
tree = node(&format!("n{i}"), "container", vec![tree]);
}
let (_result, warnings) = normalize(&tree);
assert!(
warnings
.iter()
.any(|w| matches!(w, Diagnostic::TreeDepthExceeded { .. })),
"expected tree_depth_exceeded diagnostic; got {warnings:?}"
);
}
#[test]
fn auto_ids_skip_duplicate_check() {
let tree = node(
"root",
"column",
vec![
node("auto:text:1:1", "text", vec![]),
node("auto:text:1:1", "text", vec![]),
],
);
let (_, warnings) = normalize(&tree);
assert!(warnings.is_empty());
}
#[test]
fn a11y_role_not_populated_from_widget_type() {
let tree = node(
"root",
"column",
vec![node_with_props("save", "button", json!({}))],
);
let (result, _warnings) = normalize(&tree);
let btn = &result.children[0];
assert!(
a11y_role(btn).is_none(),
"native widgets should keep their native accessible roles"
);
}
#[test]
fn a11y_role_explicit_is_preserved() {
let tree = node(
"root",
"column",
vec![node_with_props(
"save",
"button",
json!({"a11y": {"role": "link"}}),
)],
);
let (result, _warnings) = normalize(&tree);
let btn = &result.children[0];
assert_eq!(a11y_role(btn).as_deref(), Some("link"));
}
#[test]
fn a11y_role_not_populated_for_builtin_widgets() {
for type_name in [
"button",
"canvas",
"checkbox",
"column",
"combo_box",
"container",
"float",
"floating",
"grid",
"image",
"overlay",
"pane_grid",
"pick_list",
"pin",
"pointer_area",
"progress_bar",
"qr_code",
"radio",
"responsive",
"rich_text",
"row",
"rule",
"scrollable",
"sensor",
"slider",
"stack",
"svg",
"table",
"text",
"text_editor",
"text_input",
"themer",
"tooltip",
"toggler",
"vertical_slider",
"window",
] {
let tree = node("root", "column", vec![node(type_name, type_name, vec![])]);
let (result, _warnings) = normalize(&tree);
let child = &result.children[0];
assert!(
a11y_role(child).is_none(),
"{type_name} should not get a normalizer-injected role"
);
}
}
#[test]
fn a11y_ref_rewritten_inside_scoped_container() {
let tree = node(
"form",
"container",
vec![
node_with_props("heading", "text", json!({"content": "Log in"})),
node_with_props(
"email",
"text_input",
json!({"a11y": {"labelled_by": "heading"}}),
),
],
);
let (result, warnings) = normalize(&tree);
assert!(
warnings.is_empty(),
"no diagnostics expected, got {warnings:?}"
);
let email = &result.children[1];
let labelled_by = email.props.get_value("a11y").and_then(|v| {
v.get("labelled_by")
.and_then(|r| r.as_str())
.map(str::to_string)
});
assert_eq!(labelled_by.as_deref(), Some("form/heading"));
}
#[test]
fn a11y_ref_unresolved_emits_diagnostic() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"a11y": {"labelled_by": "missing"}}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
warnings
.iter()
.any(|w| matches!(w, Diagnostic::A11yRefUnresolved { .. })),
"expected a11y_ref_unresolved diagnostic, got {warnings:?}"
);
}
#[test]
fn implicit_radio_group_populates_radio_group_a11y() {
let tree = node(
"form",
"container",
vec![
node_with_props("r1", "radio", json!({"group": "flavor"})),
node_with_props("r2", "radio", json!({"group": "flavor"})),
node_with_props("r3", "radio", json!({"group": "flavor"})),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
for child in &result.children {
let group = child
.props
.get_value("a11y")
.and_then(|v| v.get("radio_group").cloned())
.and_then(|v| v.as_array().cloned());
let group = group.expect("radio_group should be populated");
let ids: Vec<String> = group
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
assert_eq!(ids, vec!["form/r1", "form/r2", "form/r3"]);
}
}
#[test]
fn implicit_radio_group_populates_active_descendant() {
let tree = node(
"form",
"container",
vec![
node_with_props(
"r1",
"radio",
json!({"group": "flavor", "value": "vanilla", "selected": "chocolate"}),
),
node_with_props(
"r2",
"radio",
json!({"group": "flavor", "value": "chocolate", "selected": "chocolate"}),
),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
for child in &result.children {
let active_descendant = child.props.get_value("a11y").and_then(|v| {
v.get("active_descendant")
.and_then(|v| v.as_str())
.map(str::to_string)
});
assert_eq!(active_descendant.as_deref(), Some("form/r2"));
}
}
#[test]
fn implicit_radio_group_without_selection_has_no_active_descendant() {
let tree = node(
"form",
"container",
vec![
node_with_props("r1", "radio", json!({"group": "flavor"})),
node_with_props("r2", "radio", json!({"group": "flavor"})),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
for child in &result.children {
let active_descendant = child
.props
.get_value("a11y")
.and_then(|v| v.get("active_descendant").cloned());
assert!(
active_descendant.is_none(),
"unselected radio group should not infer active_descendant"
);
}
}
#[test]
fn missing_accessible_name_diagnostic_for_icon_only_button() {
let tree = node("root", "column", vec![node("save", "button", vec![])]);
let (_result, warnings) = normalize(&tree);
assert!(
warnings.iter().any(|w| matches!(
w,
Diagnostic::MissingAccessibleName { id, .. } if id == "root/save"
)),
"expected missing_accessible_name for icon-only button, got {warnings:?}"
);
}
#[test]
fn button_with_text_child_does_not_warn() {
let text_child = TreeNode {
id: "auto:text:1".to_string(),
type_name: "text".to_string(),
props: plushie_core::protocol::Props::from_json(json!({"content": "Save"})),
children: vec![],
};
let button = TreeNode {
id: "save".to_string(),
type_name: "button".to_string(),
props: plushie_core::protocol::Props::from_json(json!({})),
children: vec![text_child],
};
let tree = node("root", "column", vec![button]);
let (_result, warnings) = normalize(&tree);
assert!(
warnings.is_empty(),
"button with text child should be named; got {warnings:?}"
);
}
#[test]
fn checkbox_with_label_prop_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"agree",
"checkbox",
json!({"label": "I agree", "checked": false}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
}
fn has_missing_name(warnings: &[Diagnostic], scoped_id: &str) -> bool {
warnings.iter().any(|w| {
matches!(
w,
Diagnostic::MissingAccessibleName { id, .. } if id == scoped_id
)
})
}
#[test]
fn image_without_alt_or_decorative_warns() {
let tree = node(
"root",
"column",
vec![node_with_props(
"logo",
"image",
json!({"source": "/logo.png"}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
has_missing_name(&warnings, "root/logo"),
"expected missing_accessible_name for unlabelled image, got {warnings:?}"
);
}
#[test]
fn image_with_alt_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"logo",
"image",
json!({"source": "/logo.png", "alt": "Company logo"}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
!has_missing_name(&warnings, "root/logo"),
"got {warnings:?}"
);
}
#[test]
fn decorative_image_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"deco",
"image",
json!({"source": "/divider.png", "decorative": true}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
!has_missing_name(&warnings, "root/deco"),
"got {warnings:?}"
);
}
#[test]
fn svg_without_alt_or_decorative_warns() {
let tree = node(
"root",
"column",
vec![node_with_props(
"icon",
"svg",
json!({"source": "/icon.svg"}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
has_missing_name(&warnings, "root/icon"),
"expected missing_accessible_name for unlabelled svg, got {warnings:?}"
);
}
#[test]
fn svg_with_a11y_label_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"icon",
"svg",
json!({"source": "/icon.svg", "a11y": {"label": "Settings"}}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
!has_missing_name(&warnings, "root/icon"),
"got {warnings:?}"
);
}
#[test]
fn qr_code_without_name_or_description_warns() {
let tree = node(
"root",
"column",
vec![node_with_props(
"wifi",
"qr_code",
json!({"data": "WIFI:T:WPA;..."}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
has_missing_name(&warnings, "root/wifi"),
"expected missing_accessible_name for unlabelled qr_code, got {warnings:?}"
);
}
#[test]
fn qr_code_with_description_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"wifi",
"qr_code",
json!({"data": "WIFI:T:WPA;...", "description": "Scan to join wifi"}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
!has_missing_name(&warnings, "root/wifi"),
"got {warnings:?}"
);
}
#[test]
fn qr_code_with_alt_does_not_warn() {
let tree = node(
"root",
"column",
vec![node_with_props(
"wifi",
"qr_code",
json!({"data": "WIFI:T:WPA;...", "alt": "Wifi"}),
)],
);
let (_result, warnings) = normalize(&tree);
assert!(
!has_missing_name(&warnings, "root/wifi"),
"got {warnings:?}"
);
}
#[test]
fn empty_id_emits_empty_id_diagnostic() {
let tree = TreeNode {
id: String::new(),
type_name: "container".to_string(),
props: plushie_core::protocol::Props::from(plushie_core::protocol::PropMap::new()),
children: vec![],
};
let (_, warnings) = normalize(&tree);
assert!(
warnings
.iter()
.any(|w| matches!(w, Diagnostic::EmptyId { .. })),
"expected empty_id diagnostic, got {warnings:?}"
);
}
#[test]
fn required_prop_projects_to_a11y_required() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"required": true}),
)],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
let email = &result.children[0];
let required = email
.props
.get_value("a11y")
.and_then(|v| v.get("required").and_then(|r| r.as_bool()));
assert_eq!(required, Some(true));
}
#[test]
fn required_false_does_not_project() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"required": false}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let required = email
.props
.get_value("a11y")
.and_then(|v| v.get("required").cloned());
assert!(required.is_none(), "required=false should not project");
}
#[test]
fn required_skipped_for_non_validatable_widgets() {
let tree = node(
"form",
"container",
vec![node_with_props(
"save",
"button",
json!({"label": "Save", "required": true}),
)],
);
let (result, _warnings) = normalize(&tree);
let btn = &result.children[0];
let required = btn
.props
.get_value("a11y")
.and_then(|v| v.get("required").cloned());
assert!(
required.is_none(),
"required should not project on non-validatable widgets"
);
}
#[test]
fn required_explicit_a11y_wins() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"required": true, "a11y": {"required": false}}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let required = email
.props
.get_value("a11y")
.and_then(|v| v.get("required").and_then(|r| r.as_bool()));
assert_eq!(required, Some(false));
}
#[test]
fn validation_invalid_projects_to_a11y_invalid_and_error_message() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({
"validation": {"state": "invalid", "message": "Must be an email"},
}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let a11y = email
.props
.get_value("a11y")
.expect("a11y should be populated");
assert_eq!(a11y.get("invalid").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
a11y.get("error_message").and_then(|v| v.as_str()),
Some("Must be an email")
);
}
#[test]
fn validation_invalid_array_shape_accepted() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"validation": ["invalid", "Required field"]}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let a11y = email
.props
.get_value("a11y")
.expect("a11y should be populated");
assert_eq!(a11y.get("invalid").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
a11y.get("error_message").and_then(|v| v.as_str()),
Some("Required field")
);
}
#[test]
fn validation_valid_projects_false() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"validation": "valid"}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let invalid = email
.props
.get_value("a11y")
.and_then(|v| v.get("invalid").and_then(|b| b.as_bool()));
assert_eq!(invalid, Some(false));
}
#[test]
fn validation_pending_does_not_project() {
let tree = node(
"form",
"container",
vec![node_with_props(
"email",
"text_input",
json!({"validation": "pending"}),
)],
);
let (result, _warnings) = normalize(&tree);
let email = &result.children[0];
let a11y = email.props.get_value("a11y");
if let Some(a11y) = a11y {
assert!(
a11y.get("invalid").is_none(),
"pending should not project invalid"
);
}
}
#[test]
fn explicit_radio_group_is_not_overwritten_but_rewritten() {
let tree = node(
"form",
"container",
vec![
node_with_props("heading", "text", json!({"content": "Pick"})),
node_with_props(
"r1",
"radio",
json!({
"group": "flavor",
"a11y": {"radio_group": ["heading"]}
}),
),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
let r1 = &result.children[1];
let group = r1
.props
.get_value("a11y")
.and_then(|v| v.get("radio_group").cloned())
.and_then(|v| v.as_array().cloned())
.unwrap();
let ids: Vec<String> = group
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
assert_eq!(ids, vec!["form/heading"]);
}
#[test]
fn explicit_radio_group_still_gets_inferred_active_descendant() {
let tree = node(
"form",
"container",
vec![
node_with_props("heading", "text", json!({"content": "Pick"})),
node_with_props(
"r1",
"radio",
json!({
"group": "flavor",
"value": "vanilla",
"selected": "vanilla",
"a11y": {"radio_group": ["heading"]}
}),
),
],
);
let (result, warnings) = normalize(&tree);
assert!(warnings.is_empty(), "got {warnings:?}");
let r1 = &result.children[1];
let active_descendant = r1.props.get_value("a11y").and_then(|v| {
v.get("active_descendant")
.and_then(|v| v.as_str())
.map(str::to_string)
});
assert_eq!(active_descendant.as_deref(), Some("form/r1"));
}
}