/// DreamStack Panel IR Emitter — generates compact JSON IR for ESP32 LVGL panels. /// /// Takes the same Program + SignalGraph inputs as JsEmitter but outputs /// a JSON IR that the on-device LVGL runtime can parse and render. use std::collections::HashMap; use ds_parser::*; use ds_analyzer::{SignalGraph, SignalKind}; /// Panel IR emitter — produces JSON describing the UI tree, signals, and events. pub struct IrEmitter { /// Signal name → integer ID mapping signal_ids: HashMap, /// Next signal ID to assign next_signal_id: u16, /// Next node ID to assign next_node_id: u16, } impl IrEmitter { pub fn new() -> Self { IrEmitter { signal_ids: HashMap::new(), next_signal_id: 0, next_node_id: 0, } } /// Generate Panel IR JSON from a DreamStack program + signal graph. pub fn emit_ir(program: &Program, graph: &SignalGraph) -> String { let mut emitter = IrEmitter::new(); // Phase 1: Assign signal IDs for node in &graph.nodes { emitter.assign_signal_id(&node.name); } // Phase 2: Build signal list let signals = emitter.emit_signals(program, graph); // Phase 3: Build derived signals let derived = emitter.emit_derived(graph); // Phase 4: Build UI tree from views let root = emitter.emit_views(program, graph); // Phase 5: Build timers from `every` declarations let timers = emitter.emit_timers(program); // Assemble the IR format!( r#"{{"t":"ui","signals":[{}],"derived":[{}],"timers":[{}],"root":{}}}"#, signals, derived, timers, root ) } fn assign_signal_id(&mut self, name: &str) -> u16 { if let Some(&id) = self.signal_ids.get(name) { id } else { let id = self.next_signal_id; self.signal_ids.insert(name.to_string(), id); self.next_signal_id += 1; id } } fn get_signal_id(&self, name: &str) -> Option { self.signal_ids.get(name).copied() } fn next_node(&mut self) -> u16 { let id = self.next_node_id; self.next_node_id += 1; id } /// Emit the signals array: [{"id":0,"v":72,"type":"int"}, ...] fn emit_signals(&self, program: &Program, graph: &SignalGraph) -> String { let mut entries = Vec::new(); for node in &graph.nodes { if !matches!(node.kind, SignalKind::Source) { continue; } let id = match self.get_signal_id(&node.name) { Some(id) => id, None => continue, }; // Find the initial value from the Let declaration let (value_str, type_str) = self.find_initial_value(program, &node.name); entries.push(format!( r#"{{"id":{},"v":{},"type":"{}"}}"#, id, value_str, type_str )); } entries.join(",") } /// Find initial value of a signal from the program's Let declarations. fn find_initial_value(&self, program: &Program, name: &str) -> (String, &'static str) { for decl in &program.declarations { if let Declaration::Let(let_decl) = decl { if let_decl.name == name { return self.expr_to_value(&let_decl.value); } } } ("null".to_string(), "null") } /// Convert an expression to a JSON value + type string. fn expr_to_value(&self, expr: &Expr) -> (String, &'static str) { match expr { Expr::IntLit(n) => (n.to_string(), "int"), Expr::FloatLit(n) => (n.to_string(), "float"), Expr::BoolLit(b) => (b.to_string(), "bool"), Expr::StringLit(s) => { let text = self.string_lit_to_plain(s); (format!(r#""{}""#, escape_json(&text)), "str") } Expr::List(items) => { let vals: Vec = items.iter() .map(|item| self.expr_to_value(item).0) .collect(); (format!("[{}]", vals.join(",")), "list") } Expr::Record(fields) => { let entries: Vec = fields.iter() .map(|(k, v)| format!(r#""{}":{}"#, k, self.expr_to_value(v).0)) .collect(); (format!("{{{}}}", entries.join(",")), "map") } _ => ("null".to_string(), "null"), } } /// Emit derived signals: [{"id":3,"expr":"s0 * 2","deps":[0]}, ...] fn emit_derived(&self, graph: &SignalGraph) -> String { let mut entries = Vec::new(); for node in &graph.nodes { if !matches!(node.kind, SignalKind::Derived) { continue; } let id = match self.get_signal_id(&node.name) { Some(id) => id, None => continue, }; let deps: Vec = node.dependencies.iter() .filter_map(|dep| self.get_signal_id(&dep.signal_name).map(|id| id.to_string())) .collect(); entries.push(format!( r#"{{"id":{},"deps":[{}]}}"#, id, deps.join(",") )); } entries.join(",") } /// Emit UI tree from views. fn emit_views(&mut self, program: &Program, graph: &SignalGraph) -> String { // Find the main view (first view, or one named "main") for decl in &program.declarations { if let Declaration::View(view) = decl { return self.emit_view_expr(&view.body, graph); } } // No view found — empty container r#"{"t":"col","c":[]}"#.to_string() } /// Emit timers from `every` declarations. fn emit_timers(&self, program: &Program) -> String { let mut timers = Vec::new(); for decl in &program.declarations { if let Declaration::Every(every) = decl { let ms = match &every.interval_ms { Expr::IntLit(n) => *n as u32, Expr::FloatLit(f) => *f as u32, _ => 1000, // default to 1s }; let action = self.expr_to_action(&every.body); timers.push(format!(r#"{{"ms":{},"action":{}}}"#, ms, action)); } } timers.join(",") } /// Emit a view expression as an IR node. fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String { match expr { Expr::Container(container) => self.emit_container(container, graph), Expr::Element(element) => self.emit_element(element, graph), Expr::When(cond, then_expr, else_expr) => { self.emit_conditional(cond, then_expr, else_expr.as_deref(), graph) } Expr::Each(item, list, body) | Expr::ForIn { item, iter: list, body, .. } => { self.emit_each(item, list, body, graph) } Expr::ComponentUse { name, props, children } => { self.emit_component_use(name, props, children, graph) } Expr::StringLit(s) => { let id = self.next_node(); let text = self.string_lit_to_ir_text(s); format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text)) } _ => { // Fallback: try to emit as a label with the expression value let id = self.next_node(); format!(r#"{{"t":"lbl","id":{},"text":"?"}}"#, id) } } } /// Emit a container (column, row, stack, etc.) fn emit_container(&mut self, container: &Container, graph: &SignalGraph) -> String { let id = self.next_node(); let kind = match &container.kind { ContainerKind::Column => "col", ContainerKind::Row => "row", ContainerKind::Stack => "stk", ContainerKind::List => "lst", ContainerKind::Panel => "pnl", ContainerKind::Form => "col", ContainerKind::Scene => "col", // scene → col fallback for IR ContainerKind::Custom(_) => "col", }; let children: Vec = container.children.iter() .map(|child| self.emit_view_expr(child, graph)) .collect(); // Extract style props let style = self.extract_style_props(&container.props); let mut parts = vec![ format!(r#""t":"{}""#, kind), format!(r#""id":{}"#, id), format!(r#""c":[{}]"#, children.join(",")), ]; if !style.is_empty() { parts.push(format!(r#""style":{{{}}}"#, style)); } // Gap and padding from props for (key, val) in &container.props { match key.as_str() { "gap" => if let Expr::IntLit(n) = val { parts.push(format!(r#""gap":{}"#, n)); }, "pad" | "padding" => if let Expr::IntLit(n) = val { parts.push(format!(r#""pad":{}"#, n)); }, _ => {} } } format!("{{{}}}", parts.join(",")) } /// Emit an element (text, button, input, slider, etc.) fn emit_element(&mut self, element: &Element, graph: &SignalGraph) -> String { let id = self.next_node(); let tag = element.tag.as_str(); match tag { "text" | "label" | "heading" | "h1" | "h2" | "h3" | "p" | "span" => { self.emit_label(id, element, graph) } "button" | "btn" => { self.emit_button(id, element, graph) } "input" | "textfield" => { self.emit_input(id, element, graph) } "slider" | "range" => { self.emit_slider(id, element, graph) } "toggle" | "switch" | "checkbox" => { self.emit_switch(id, element, graph) } "image" | "img" => { self.emit_image(id, element, graph) } "progress" | "bar" => { self.emit_progress(id, element, graph) } _ => { // Unknown element → warn and label fallback eprintln!(" ⚠ Unknown element type '{}' — rendered as label", tag); let text = self.args_to_text(&element.args); format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text)) } } } fn emit_label(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let text = self.args_to_text(&element.args); let style = self.extract_style_props(&element.props); let mut parts = vec![ format!(r#""t":"lbl""#), format!(r#""id":{}"#, id), format!(r#""text":"{}""#, escape_json(&text)), ]; // Font size from tag match element.tag.as_str() { "h1" | "heading" => parts.push(r#""size":28"#.to_string()), "h2" => parts.push(r#""size":22"#.to_string()), "h3" => parts.push(r#""size":18"#.to_string()), _ => {} } if !style.is_empty() { parts.push(format!(r#""style":{{{}}}"#, style)); } format!("{{{}}}", parts.join(",")) } fn emit_button(&mut self, id: u16, element: &Element, graph: &SignalGraph) -> String { let text = self.args_to_text(&element.args); let on_action = self.extract_event_action(element, graph); let mut parts = vec![ format!(r#""t":"btn""#), format!(r#""id":{}"#, id), format!(r#""text":"{}""#, escape_json(&text)), ]; if !on_action.is_empty() { parts.push(format!(r#""on":{{{}}}"#, on_action)); } let style = self.extract_style_props(&element.props); if !style.is_empty() { parts.push(format!(r#""style":{{{}}}"#, style)); } format!("{{{}}}", parts.join(",")) } fn emit_input(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let mut parts = vec![ format!(r#""t":"inp""#), format!(r#""id":{}"#, id), ]; for (key, val) in &element.props { match key.as_str() { "placeholder" => { let text = self.expr_to_text(val); parts.push(format!(r#""placeholder":"{}""#, escape_json(&text))); } "bind" => { if let Expr::Ident(name) = val { if let Some(sig_id) = self.get_signal_id(name) { parts.push(format!(r#""bind":{}"#, sig_id)); } } } _ => {} } } format!("{{{}}}", parts.join(",")) } fn emit_slider(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let mut parts = vec![ format!(r#""t":"sld""#), format!(r#""id":{}"#, id), ]; for (key, val) in &element.props { match key.as_str() { "min" => if let Expr::IntLit(n) = val { parts.push(format!(r#""min":{}"#, n)); }, "max" => if let Expr::IntLit(n) = val { parts.push(format!(r#""max":{}"#, n)); }, "bind" => { if let Expr::Ident(name) = val { if let Some(sig_id) = self.get_signal_id(name) { parts.push(format!(r#""bind":{}"#, sig_id)); } } } _ => {} } } format!("{{{}}}", parts.join(",")) } fn emit_switch(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let mut parts = vec![ format!(r#""t":"sw""#), format!(r#""id":{}"#, id), ]; for (key, val) in &element.props { if key == "bind" { if let Expr::Ident(name) = val { if let Some(sig_id) = self.get_signal_id(name) { parts.push(format!(r#""bind":{}"#, sig_id)); } } } } format!("{{{}}}", parts.join(",")) } fn emit_image(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let src = self.args_to_text(&element.args); format!(r#"{{"t":"img","id":{},"src":"{}"}}"#, id, escape_json(&src)) } fn emit_progress(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { let mut parts = vec![ format!(r#""t":"bar""#), format!(r#""id":{}"#, id), ]; for (key, val) in &element.props { match key.as_str() { "min" => if let Expr::IntLit(n) = val { parts.push(format!(r#""min":{}"#, n)); }, "max" => if let Expr::IntLit(n) = val { parts.push(format!(r#""max":{}"#, n)); }, "bind" => { if let Expr::Ident(name) = val { if let Some(sig_id) = self.get_signal_id(name) { parts.push(format!(r#""bind":{}"#, sig_id)); } } } _ => {} } } format!("{{{}}}", parts.join(",")) } /// Emit a conditional (when/if). fn emit_conditional( &mut self, cond: &Expr, then_expr: &Expr, else_expr: Option<&Expr>, graph: &SignalGraph ) -> String { let id = self.next_node(); let cond_str = self.expr_to_condition(cond); let then_node = self.emit_view_expr(then_expr, graph); let mut parts = vec![ format!(r#""t":"cond""#), format!(r#""id":{}"#, id), format!(r#""if":"{}""#, escape_json(&cond_str)), format!(r#""then":{}"#, then_node), ]; if let Some(else_e) = else_expr { let else_node = self.emit_view_expr(else_e, graph); parts.push(format!(r#""else":{}"#, else_node)); } format!("{{{}}}", parts.join(",")) } /// Emit an each loop. fn emit_each(&mut self, item: &str, list: &Expr, body: &Expr, graph: &SignalGraph) -> String { let id = self.next_node(); let list_ref = self.expr_to_signal_ref(list); let body_node = self.emit_view_expr(body, graph); format!( r#"{{"t":"each","id":{},"item":"{}","list":"{}","body":{}}}"#, id, item, escape_json(&list_ref), body_node ) } /// Emit a component use — extract props and render with children. fn emit_component_use( &mut self, name: &str, props: &[(String, Expr)], children: &[Expr], graph: &SignalGraph ) -> String { let id = self.next_node(); let child_nodes: Vec = children.iter() .map(|c| self.emit_view_expr(c, graph)) .collect(); let mut parts = vec![ format!(r#""t":"pnl""#), format!(r#""id":{}"#, id), format!(r#""_comp":"{}""#, name), ]; // Extract key component props for (key, val) in props { match key.as_str() { "label" | "title" => { let text = self.expr_to_text(val); parts.push(format!(r#""text":"{}""#, escape_json(&text))); } "variant" => { let v = self.expr_to_text(val); parts.push(format!(r#""variant":"{}""#, escape_json(&v))); } _ => {} } } parts.push(format!(r#""c":[{}]"#, child_nodes.join(","))); format!("{{{}}}", parts.join(",")) } // ── Helpers ────────────────────────────────────────── /// Convert string lit args to IR text with signal references. fn args_to_text(&self, args: &[Expr]) -> String { if args.is_empty() { return String::new(); } args.iter().map(|a| self.expr_to_text(a)).collect::>().join(" ") } /// Convert an expression to display text, using {N} for signal references. fn expr_to_text(&self, expr: &Expr) -> String { match expr { Expr::StringLit(s) => self.string_lit_to_ir_text(s), Expr::Ident(name) => { if let Some(id) = self.get_signal_id(name) { format!("{{{}}}", id) } else { name.clone() } } Expr::IntLit(n) => n.to_string(), Expr::FloatLit(n) => n.to_string(), Expr::BoolLit(b) => b.to_string(), _ => "?".to_string(), } } /// Convert a StringLit to IR text: "Hello {name}" → "Hello {0}" fn string_lit_to_ir_text(&self, s: &StringLit) -> String { let mut out = String::new(); for seg in &s.segments { match seg { StringSegment::Literal(text) => out.push_str(text), StringSegment::Interpolation(expr) => { if let Expr::Ident(name) = expr.as_ref() { if let Some(id) = self.get_signal_id(name) { out.push_str(&format!("{{{}}}", id)); } else { out.push_str(&format!("{{{}}}", name)); } } else if let Expr::DotAccess(base, field) = expr.as_ref() { if let Expr::Ident(name) = base.as_ref() { if let Some(id) = self.get_signal_id(name) { out.push_str(&format!("{{{}.{}}}", id, field)); } else { out.push_str(&format!("{{{}.{}}}", name, field)); } } } else { out.push_str("{?}"); } } } } out } /// Convert a StringLit to plain text without signal references. fn string_lit_to_plain(&self, s: &StringLit) -> String { let mut out = String::new(); for seg in &s.segments { match seg { StringSegment::Literal(text) => out.push_str(text), StringSegment::Interpolation(expr) => { out.push_str(&self.expr_to_text(expr)); } } } out } /// Extract event actions from element props (on click, etc.) fn extract_event_action(&self, element: &Element, _graph: &SignalGraph) -> String { let mut actions = Vec::new(); for (key, val) in &element.props { // Check for "on" + event patterns: "click", "press", etc. let event_name = match key.as_str() { "click" | "on_click" => Some("click"), "press" | "on_press" => Some("press"), _ => None, }; if let Some(event) = event_name { let action = self.expr_to_action(val); actions.push(format!(r#""{}":{}"#, event, action)); } } // Also check for explicit on handlers in modifiers or inline props // Pattern: button "+" { on click { count += 1 } } // In the AST, this comes through as props with key "click" and value being the handler body actions.join(",") } /// Convert an expression to an action opcode. fn expr_to_action(&self, expr: &Expr) -> String { match expr { // count += 1 → inc Expr::Assign(target, AssignOp::AddAssign, val) => { if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) { if let Some(id) = self.get_signal_id(name) { return format!(r#"{{"op":"inc","s":{}}}"#, id); } } if let Expr::Ident(name) = target.as_ref() { if let Some(id) = self.get_signal_id(name) { let v = self.expr_to_value(val).0; return format!(r#"{{"op":"add","s":{},"v":{}}}"#, id, v); } } r#"{"op":"noop"}"#.to_string() } // count -= 1 → dec Expr::Assign(target, AssignOp::SubAssign, val) => { if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) { if let Some(id) = self.get_signal_id(name) { return format!(r#"{{"op":"dec","s":{}}}"#, id); } } if let Expr::Ident(name) = target.as_ref() { if let Some(id) = self.get_signal_id(name) { let v = self.expr_to_value(val).0; return format!(r#"{{"op":"sub","s":{},"v":{}}}"#, id, v); } } r#"{"op":"noop"}"#.to_string() } // count = expr → set Expr::Assign(target, AssignOp::Set, val) => { if let Expr::Ident(name) = target.as_ref() { if let Some(id) = self.get_signal_id(name) { // Check for toggle: x = !x if let Expr::UnaryOp(UnaryOp::Not, inner) = val.as_ref() { if let Expr::Ident(inner_name) = inner.as_ref() { if inner_name == name { return format!(r#"{{"op":"toggle","s":{}}}"#, id); } } } let v = self.expr_to_value(val).0; return format!(r#"{{"op":"set","s":{},"v":{}}}"#, id, v); } } r#"{"op":"noop"}"#.to_string() } // Block → multiple actions as an array Expr::Block(exprs) => { if exprs.len() == 1 { self.expr_to_action(&exprs[0]) } else { let actions: Vec = exprs.iter() .map(|e| self.expr_to_action(e)) .filter(|a| !a.contains("noop")) .collect(); if actions.len() == 1 { actions[0].clone() } else { format!("[{}]", actions.join(",")) } } } // Function call → remote action Expr::Call(name, _args) => { format!(r#"{{"op":"remote","name":"{}"}}"#, name) } _ => r#"{"op":"noop"}"#.to_string(), } } /// Convert an expression to a condition string for the runtime. fn expr_to_condition(&self, expr: &Expr) -> String { match expr { Expr::BinOp(left, op, right) => { let l = self.expr_to_signal_ref(left); let r = self.expr_to_signal_ref(right); let op_str = match op { BinOp::Gt => ">", BinOp::Lt => "<", BinOp::Gte => ">=", BinOp::Lte => "<=", BinOp::Eq => "==", BinOp::Neq => "!=", BinOp::And => "&&", BinOp::Or => "||", _ => "?", }; format!("{} {} {}", l, op_str, r) } Expr::Ident(name) => { if let Some(id) = self.get_signal_id(name) { format!("s{}", id) } else { name.clone() } } Expr::UnaryOp(UnaryOp::Not, inner) => { format!("!{}", self.expr_to_condition(inner)) } _ => "true".to_string(), } } /// Convert an expression to a signal reference string. fn expr_to_signal_ref(&self, expr: &Expr) -> String { match expr { Expr::Ident(name) => { if let Some(id) = self.get_signal_id(name) { format!("s{}", id) } else { name.clone() } } Expr::IntLit(n) => n.to_string(), Expr::FloatLit(n) => n.to_string(), Expr::BoolLit(b) => b.to_string(), Expr::StringLit(s) => format!(r#""{}""#, self.string_lit_to_plain(s)), _ => "?".to_string(), } } /// Extract style properties from props list. fn extract_style_props(&self, props: &[(String, Expr)]) -> String { let mut style_parts = Vec::new(); for (key, val) in props { match key.as_str() { "bg" | "background" | "backgroundColor" => { let v = self.expr_to_text(val); style_parts.push(format!(r#""bg":"{}""#, escape_json(&v))); } "fg" | "color" | "textColor" => { let v = self.expr_to_text(val); style_parts.push(format!(r#""fg":"{}""#, escape_json(&v))); } "size" | "fontSize" => { if let Expr::IntLit(n) = val { style_parts.push(format!(r#""size":{}"#, n)); } } "radius" | "borderRadius" => { if let Expr::IntLit(n) = val { style_parts.push(format!(r#""radius":{}"#, n)); } } "align" | "textAlign" => { let v = self.expr_to_text(val); style_parts.push(format!(r#""align":"{}""#, escape_json(&v))); } "width" => { if let Expr::IntLit(n) = val { style_parts.push(format!(r#""w":{}"#, n)); } } "height" => { if let Expr::IntLit(n) = val { style_parts.push(format!(r#""h":{}"#, n)); } } _ => {} } } style_parts.join(",") } } /// Escape a string for JSON. fn escape_json(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t") } #[cfg(test)] mod tests { use super::*; #[test] fn test_escape_json() { assert_eq!(escape_json(r#"hello "world""#), r#"hello \"world\""#); assert_eq!(escape_json("line1\nline2"), "line1\\nline2"); } #[test] fn test_simple_counter() { let source = r#" let count = 0 view main = column [ text "Count: {count}" button "+" { click: count += 1 } ] "#; let mut lexer = ds_parser::Lexer::new(source); let tokens = lexer.tokenize(); let mut parser = ds_parser::Parser::new(tokens); let program = parser.parse_program().unwrap(); let graph = ds_analyzer::SignalGraph::from_program(&program); let ir = IrEmitter::emit_ir(&program, &graph); // Should contain signal for count assert!(ir.contains(r#""id":0"#)); assert!(ir.contains(r#""v":0"#)); assert!(ir.contains(r#""type":"int""#)); // Should contain a label with signal reference assert!(ir.contains(r#""t":"lbl""#)); // Should contain a button assert!(ir.contains(r#""t":"btn""#)); println!("IR output: {}", ir); } // ── v0.8 IR Emitter Tests ─────────────────────────────── fn emit_ir(source: &str) -> String { let mut lexer = ds_parser::Lexer::new(source); let tokens = lexer.tokenize(); let mut parser = ds_parser::Parser::new(tokens); let program = parser.parse_program().unwrap(); let graph = ds_analyzer::SignalGraph::from_program(&program); IrEmitter::emit_ir(&program, &graph) } #[test] fn test_ir_multi_signal() { let ir = emit_ir("let count = 0\nlet doubled = count * 2\nview main = text count"); // Should contain at least one signal assert!(ir.contains(r#""v":0"#), "should have count signal with value 0"); assert!(ir.contains(r#""t":"lbl""#), "should have a label node"); } #[test] fn test_ir_container_children() { let ir = emit_ir("view main = column [\n text \"hello\"\n text \"world\"\n]"); assert!(ir.contains(r#""t":"col""#), "should emit column container"); assert!(ir.contains(r#""c":["#), "should have children array"); } #[test] fn test_ir_empty_view() { let ir = emit_ir("view main = text \"empty\""); // Minimal program — no signals, just a view assert!(ir.contains(r#""signals":[]"#) || ir.contains(r#""signals":["#), "should have signals array (empty or not)"); assert!(ir.contains(r#""t":"lbl""#), "should have label"); } #[test] fn test_ir_button_handler() { let ir = emit_ir("let x = 0\nview main = button \"Click\" { click: x += 1 }"); assert!(ir.contains(r#""t":"btn""#), "should emit button"); // Button should have click handler or action reference assert!(ir.contains("click") || ir.contains("act") || ir.contains("Click"), "should reference click action or label"); } }