Skip to main content

webui_handler/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Handler implementation for Rust.
5//!
6//! This crate provides functionality to process and render WebUI protocols
7//! into final HTML output based on provided data.
8
9pub mod plugin;
10pub mod route_handler;
11pub mod route_matcher;
12pub(crate) mod route_renderer;
13
14use plugin::HandlerPlugin;
15use serde_json::Value;
16use std::collections::{HashMap, HashSet};
17use thiserror::Error;
18use webui_expressions::{evaluate_with_resolver, ExpressionError};
19use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol};
20use webui_state::find_value_by_dotted_path;
21
22/// Error types for the WebUI handler.
23#[derive(Debug, Error)]
24pub enum HandlerError {
25    #[error("Rendering error: {0}")]
26    Rendering(String),
27
28    #[error("Missing fragment: {0}")]
29    MissingFragment(String),
30
31    #[error("Missing data field: {0}")]
32    MissingData(String),
33
34    #[error("Type error: {0}")]
35    TypeError(String),
36
37    #[error("Protocol error: {0}")]
38    Protocol(#[from] webui_protocol::ProtocolError),
39
40    #[error("Evaluation error: {0}")]
41    Evaluation(String),
42
43    #[error("I/O error: {0}")]
44    Io(#[from] std::io::Error),
45
46    #[error("Writer error: {0}")]
47    Writer(String),
48}
49
50pub type Result<T> = std::result::Result<T, HandlerError>;
51
52/// Interface for writing rendered output
53pub trait ResponseWriter {
54    /// Write content to the output
55    fn write(&mut self, content: &str) -> Result<()>;
56
57    /// Finalize the output
58    fn end(&mut self) -> Result<()>;
59}
60
61/// Options controlling how the handler renders a protocol.
62///
63/// The handler performs server-side route matching: matched routes are rendered
64/// visible with content; non-matched routes are rendered hidden and empty.
65pub struct RenderOptions<'a> {
66    /// The fragment ID to start rendering from (e.g., `"index.html"`).
67    pub entry_id: &'a str,
68    /// The URL path to match routes against (e.g., `"/contacts/42"`).
69    pub request_path: &'a str,
70}
71
72impl<'a> RenderOptions<'a> {
73    /// Create render options for the given entry fragment and request path.
74    #[must_use]
75    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
76        Self {
77            entry_id,
78            request_path,
79        }
80    }
81}
82
83/// The main WebUI handler that processes protocols and renders them.
84///
85/// The handler is stateless: plugin instances are created per-render from
86/// the stored factory function, allowing concurrent renders with `&self`.
87pub struct WebUIHandler {
88    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
89}
90
91/// Context object for processing WebUI fragments
92struct WebUIProcessContext<'a> {
93    protocol: &'a WebUIProtocol,
94    state: &'a Value,
95    #[allow(dead_code)]
96    depth: usize,
97    writer: &'a mut dyn ResponseWriter,
98    local_vars: HashMap<String, Value>,
99    /// Accumulates component attribute values between attrStart and the component fragment.
100    component_attrs: HashMap<String, Value>,
101    /// URL path for server-side route matching.
102    request_path: String,
103    /// Base path for resolving relative route paths (`./`).
104    /// Updated as the handler descends into nested matched routes.
105    route_base: String,
106    /// Component names visited during rendering (for selective f-template emission
107    /// and CSS module dedup — only the first render of each component emits
108    /// its `<style type="module">` tag).
109    rendered_components: HashSet<String>,
110    /// Per-render plugin instance created from the handler's factory.
111    plugin: Option<Box<dyn HandlerPlugin>>,
112    /// Current position in the route tree for outlet-based rendering.
113    /// Contains the children of the currently matched route fragment.
114    route_children: Vec<webui_protocol::WebUiFragmentRoute>,
115    /// Entry fragment ID — used to compute the initial inventory at head_end.
116    entry_id: String,
117}
118
119/// Convert hyphenated name to camelCase (e.g., "data-title" → "dataTitle").
120fn convert_hyphen_to_camel_case(name: &str) -> String {
121    let mut result = String::with_capacity(name.len());
122    let mut capitalize_next = false;
123    for ch in name.chars() {
124        if ch == '-' {
125            capitalize_next = true;
126        } else if capitalize_next {
127            result.extend(ch.to_uppercase());
128            capitalize_next = false;
129        } else {
130            result.push(ch);
131        }
132    }
133    result
134}
135
136/// Convert camelCase to kebab-case (e.g., "totalContacts" → "total-contacts").
137fn camel_to_kebab(name: &str) -> String {
138    let mut result = String::with_capacity(name.len() + 4);
139    for ch in name.chars() {
140        if ch.is_uppercase() && !result.is_empty() {
141            result.push('-');
142            for lc in ch.to_lowercase() {
143                result.push(lc);
144            }
145        } else {
146            result.push(ch);
147        }
148    }
149    result
150}
151
152/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
153fn component_attr_name(name: &str) -> String {
154    let stripped = name.strip_prefix(':').unwrap_or(name);
155    if stripped.contains('-') {
156        convert_hyphen_to_camel_case(stripped)
157    } else {
158        stripped.to_string()
159    }
160}
161
162impl WebUIHandler {
163    /// Create a new WebUI handler with no plugin.
164    pub fn new() -> Self {
165        Self {
166            plugin_factory: None,
167        }
168    }
169
170    /// Create a new WebUI handler with a plugin factory.
171    ///
172    /// Each render call creates a fresh plugin instance from the factory,
173    /// enabling concurrent renders with `&self`.
174    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
175        Self {
176            plugin_factory: Some(factory),
177        }
178    }
179
180    /// Process a WebUI protocol with the provided state and write the output to the given writer.
181    ///
182    /// `options.entry_id` selects the fragment to start rendering from.
183    /// `options.request_path` controls server-side route matching.
184    pub fn handle(
185        &self,
186        protocol: &WebUIProtocol,
187        state: &Value,
188        options: &RenderOptions<'_>,
189        writer: &mut dyn ResponseWriter,
190    ) -> Result<()> {
191        if !protocol.fragments.contains_key(options.entry_id) {
192            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
193        }
194
195        let mut context = WebUIProcessContext {
196            protocol,
197            state,
198            depth: 0,
199            writer,
200            local_vars: HashMap::new(),
201            component_attrs: HashMap::new(),
202            request_path: options.request_path.to_string(),
203            route_base: "/".to_string(),
204            rendered_components: HashSet::new(),
205            plugin: self.plugin_factory.map(|f| f()),
206            route_children: Vec::new(),
207            entry_id: options.entry_id.to_string(),
208        };
209        self.process_fragment_id(options.entry_id, &mut context)?;
210
211        writer.end()?;
212
213        Ok(())
214    }
215
216    /// Like `handle()`, but pushes a component scope so the plugin emits
217    /// binding markers. Use this when rendering a component outside the
218    /// normal page render flow (e.g., re-rendering a route component with
219    /// modified state).
220    pub fn handle_as_component(
221        &self,
222        protocol: &WebUIProtocol,
223        state: &Value,
224        entry_id: &str,
225        writer: &mut dyn ResponseWriter,
226    ) -> Result<()> {
227        if !protocol.fragments.contains_key(entry_id) {
228            return Err(HandlerError::MissingFragment(entry_id.to_string()));
229        }
230
231        let mut context = WebUIProcessContext {
232            protocol,
233            state,
234            depth: 0,
235            writer,
236            local_vars: HashMap::new(),
237            component_attrs: HashMap::new(),
238            request_path: String::new(),
239            route_base: "/".to_string(),
240            rendered_components: HashSet::new(),
241            plugin: self.plugin_factory.map(|f| f()),
242            route_children: Vec::new(),
243            entry_id: entry_id.to_string(),
244        };
245
246        if let Some(p) = &mut context.plugin {
247            p.push_scope();
248        }
249
250        self.process_fragment_id(entry_id, &mut context)?;
251
252        if let Some(p) = &mut context.plugin {
253            p.pop_scope();
254        }
255
256        writer.end()?;
257
258        Ok(())
259    }
260
261    /// Process a fragment by its ID.
262    ///
263    /// The `context` parameter contains scope-local variables that are accessible during rendering,
264    /// such as loop iteration variables. This is separate from the global `state`.
265    fn process_fragment_id(
266        &self,
267        fragment_id: &str,
268        context: &mut WebUIProcessContext,
269    ) -> Result<()> {
270        if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
271            self.process_fragment(&fragment_list.fragments, context)
272        } else {
273            Err(HandlerError::MissingFragment(fragment_id.to_string()))
274        }
275    }
276
277    /// Process a vector of fragments.
278    ///
279    /// The `context` maintains scope-specific variables that can be accessed by fragments
280    /// during rendering, while `state` contains the global application state.
281    fn process_fragment(
282        &self,
283        fragments: &[WebUIFragment],
284        context: &mut WebUIProcessContext,
285    ) -> Result<()> {
286        // Pre-scan: find the best matching route among sibling routes by specificity.
287        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
288        // Resolves relative paths (`./`) using the current route_base.
289        let best_route = route_renderer::find_best_route_match(
290            fragments,
291            &context.request_path,
292            &context.route_base,
293        );
294
295        for item in fragments {
296            match item.fragment.as_ref() {
297                Some(Fragment::Raw(raw)) => {
298                    context.writer.write(&raw.value)?;
299                }
300                Some(Fragment::Component(component)) => {
301                    self.process_component(component, context)?;
302                }
303                Some(Fragment::ForLoop(for_loop)) => {
304                    self.process_for_loop(for_loop, context)?;
305                }
306                Some(Fragment::Signal(signal)) => {
307                    self.process_signal(signal, context)?;
308                }
309                Some(Fragment::IfCond(if_cond)) => {
310                    self.process_if(if_cond, context)?;
311                }
312                Some(Fragment::Attribute(attr)) => {
313                    self.process_attribute(attr, context)?;
314                }
315                Some(Fragment::Plugin(plugin_frag)) => {
316                    if let Some(p) = &mut context.plugin {
317                        p.on_plugin_data(&plugin_frag.data, context.writer)?;
318                    }
319                }
320                Some(Fragment::Route(route_frag)) => {
321                    self.process_route(route_frag, &best_route, context)?;
322                }
323                Some(Fragment::Outlet(_)) => {
324                    self.process_outlet(context)?;
325                }
326                None => {}
327            }
328        }
329        Ok(())
330    }
331
332    /// Process an `<outlet />` directive.
333    ///
334    /// Matches children from the currently active route's `children` field
335    /// against the request path, renders the matched child `<webui-route>`
336    /// elements directly at this position (no wrapper element).
337    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
338        let mut children = std::mem::take(&mut context.route_children);
339        if children.is_empty() {
340            return Ok(());
341        }
342
343        // Find the best matching child route
344        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
345        for (idx, child) in children.iter().enumerate() {
346            let resolved = route_matcher::resolve_route_path(&child.path, &context.route_base);
347            if let Some(m) =
348                route_matcher::match_single_route(&resolved, &context.request_path, child.exact)
349            {
350                let is_better = best
351                    .as_ref()
352                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
353                if is_better {
354                    best = Some((idx, m));
355                }
356            }
357        }
358
359        // Extract grandchildren from the matched child to avoid cloning.
360        // We swap out the children vec so we can move it into context without
361        // cloning, then swap an empty vec back for the sibling rendering pass.
362        let grandchildren = if let Some((idx, _)) = &best {
363            std::mem::take(&mut children[*idx].children)
364        } else {
365            Vec::new()
366        };
367
368        if let Some((idx, ref rm)) = best {
369            let matched_child = &children[idx];
370            let comp = &matched_child.fragment_id;
371
372            if !comp.is_empty() {
373                let saved_route_base = context.route_base.clone();
374                let saved_route_children = std::mem::take(&mut context.route_children);
375
376                if rm.consumed_segments > 0 {
377                    context.route_base = route_matcher::compute_route_base(
378                        &context.request_path,
379                        rm.consumed_segments,
380                    );
381                }
382
383                context.route_children = grandchildren;
384
385                // Emit matched <webui-route>
386                context.writer.write("<webui-route")?;
387                if !matched_child.path.is_empty() {
388                    context.writer.write(" path=\"")?;
389                    context.writer.write(&matched_child.path)?;
390                    context.writer.write("\"")?;
391                }
392                context.writer.write(" component=\"")?;
393                context.writer.write(comp)?;
394                context.writer.write("\"")?;
395                if matched_child.exact {
396                    context.writer.write(" exact")?;
397                }
398                context.writer.write(" active>")?;
399
400                context.writer.write("<")?;
401                context.writer.write(comp)?;
402                route_renderer::emit_state_attributes(context.state, context.writer)?;
403                context.writer.write(">")?;
404
405                self.process_component(
406                    &webui_protocol::WebUIFragmentComponent {
407                        fragment_id: comp.clone(),
408                    },
409                    context,
410                )?;
411
412                context.writer.write("</")?;
413                context.writer.write(comp)?;
414                context.writer.write(">")?;
415                context.writer.write("</webui-route>")?;
416
417                context.route_base = saved_route_base;
418                context.route_children = saved_route_children;
419            }
420        }
421
422        // Render non-matched siblings as hidden
423        for (idx, child) in children.iter().enumerate() {
424            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
425            if !is_matched && !child.fragment_id.is_empty() {
426                context.writer.write("<webui-route")?;
427                if !child.path.is_empty() {
428                    context.writer.write(" path=\"")?;
429                    context.writer.write(&child.path)?;
430                    context.writer.write("\"")?;
431                }
432                context.writer.write(" component=\"")?;
433                context.writer.write(&child.fragment_id)?;
434                context
435                    .writer
436                    .write("\" style=\"display:none\"></webui-route>")?;
437            }
438        }
439
440        Ok(())
441    }
442
443    /// Emit a `<style type="module">` tag for a component if this is its first
444    /// rendering and it has CSS in `protocol.components` (Module strategy only).
445    fn emit_css_module(
446        &self,
447        component: &webui_protocol::WebUIFragmentComponent,
448        context: &mut WebUIProcessContext,
449    ) -> Result<()> {
450        if !context.rendered_components.contains(&component.fragment_id) {
451            if let Some(css) = context
452                .protocol
453                .components
454                .get(&component.fragment_id)
455                .map(|c| c.css.as_str())
456                .filter(|s| !s.is_empty())
457            {
458                context
459                    .writer
460                    .write("<style type=\"module\" specifier=\"")?;
461                context.writer.write(&component.fragment_id)?;
462                context.writer.write("\">")?;
463                context.writer.write(css)?;
464                context.writer.write("</style>")?;
465            }
466        }
467        Ok(())
468    }
469
470    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
471    fn process_route(
472        &self,
473        route_frag: &webui_protocol::WebUiFragmentRoute,
474        best_route: &Option<(String, route_matcher::RouteMatch)>,
475        context: &mut WebUIProcessContext,
476    ) -> Result<()> {
477        let is_matched = best_route
478            .as_ref()
479            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
480
481        context.writer.write("<webui-route")?;
482        if !route_frag.path.is_empty() {
483            context.writer.write(" path=\"")?;
484            context.writer.write(&route_frag.path)?;
485            context.writer.write("\"")?;
486        }
487        if !route_frag.fragment_id.is_empty() {
488            context.writer.write(" component=\"")?;
489            context.writer.write(&route_frag.fragment_id)?;
490            context.writer.write("\"")?;
491        }
492        if route_frag.exact {
493            context.writer.write(" exact")?;
494        }
495
496        if is_matched {
497            context.writer.write(" active>")?;
498
499            if !route_frag.fragment_id.is_empty() {
500                let saved_route_base = context.route_base.clone();
501                let saved_route_children = std::mem::take(&mut context.route_children);
502                if let Some((_, ref rm)) = best_route {
503                    context.route_base = route_matcher::compute_route_base(
504                        &context.request_path,
505                        rm.consumed_segments,
506                    );
507                }
508
509                context.route_children = route_frag.children.clone();
510
511                let comp = webui_protocol::WebUIFragmentComponent {
512                    fragment_id: route_frag.fragment_id.clone(),
513                };
514
515                context.writer.write("<")?;
516                context.writer.write(&route_frag.fragment_id)?;
517                route_renderer::emit_state_attributes(context.state, context.writer)?;
518                context.writer.write(">")?;
519
520                self.process_component(&comp, context)?;
521
522                context.writer.write("</")?;
523                context.writer.write(&route_frag.fragment_id)?;
524                context.writer.write(">")?;
525
526                context.route_base = saved_route_base;
527                context.route_children = saved_route_children;
528            }
529        } else {
530            context.writer.write(" style=\"display:none\">")?;
531        }
532
533        context.writer.write("</webui-route>")?;
534        Ok(())
535    }
536
537    /// Process a component fragment.
538    fn process_component(
539        &self,
540        component: &webui_protocol::WebUIFragmentComponent,
541        context: &mut WebUIProcessContext,
542    ) -> Result<()> {
543        // Emit CSS module into the component's light DOM on first encounter.
544        // Subsequent instances skip — the browser's module map deduplicates.
545        let first_render = !context.rendered_components.contains(&component.fragment_id);
546        if first_render {
547            self.emit_css_module(component, context)?;
548        }
549
550        // Track this component as rendered (for selective f-template emission)
551        context
552            .rendered_components
553            .insert(component.fragment_id.clone());
554
555        // Save parent scope
556        let saved_local_vars = std::mem::take(&mut context.local_vars);
557        let saved_component_attrs = std::mem::take(&mut context.component_attrs);
558
559        // Component gets accumulated attrs as its local vars
560        context.local_vars = saved_component_attrs;
561
562        if let Some(p) = &mut context.plugin {
563            p.push_scope();
564        }
565
566        self.process_fragment_id(&component.fragment_id, context)?;
567
568        if let Some(p) = &mut context.plugin {
569            p.pop_scope();
570        }
571
572        // Restore parent scope
573        context.local_vars = saved_local_vars;
574        context.component_attrs = HashMap::new();
575
576        Ok(())
577    }
578
579    /// Resolve a dotted path value, checking local variables first, then global state.
580    fn resolve_value(&self, path: &str, context: &WebUIProcessContext) -> Option<Value> {
581        // Check local vars first
582        if let Some(first_part) = path.split('.').next() {
583            if let Some(local_value) = context.local_vars.get(first_part) {
584                if first_part.len() == path.len() {
585                    return Some(local_value.clone());
586                }
587                let remaining = &path[first_part.len() + 1..];
588                if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
589                    return Some(v);
590                }
591            }
592        }
593        // Fall back to global state
594        find_value_by_dotted_path(path, context.state)
595    }
596
597    /// Evaluate a condition expression against the current context.
598    ///
599    /// Uses a resolver closure that checks local variables first, then falls
600    /// back to global state — avoiding a full clone of the state tree.
601    /// Returns false if the condition references a missing value.
602    fn evaluate_condition(
603        &self,
604        condition: &webui_protocol::ConditionExpr,
605        context: &WebUIProcessContext,
606    ) -> Result<bool> {
607        let local_vars = &context.local_vars;
608        let state = context.state;
609        match evaluate_with_resolver(condition, |path| {
610            if let Some(first_part) = path.split('.').next() {
611                if let Some(local_value) = local_vars.get(first_part) {
612                    if first_part.len() == path.len() {
613                        return Some(local_value.clone());
614                    }
615                    let remaining = &path[first_part.len() + 1..];
616                    if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
617                        return Some(v);
618                    }
619                }
620            }
621            find_value_by_dotted_path(path, state)
622        }) {
623            Ok(result) => Ok(result),
624            Err(ExpressionError::MissingValue(_)) => Ok(false),
625            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
626        }
627    }
628
629    /// Process a for loop fragment.
630    ///
631    /// Creates a new context for each iteration that includes the current loop item.
632    /// This allows nested templates to access both the loop variable and any parent context.
633    /// Example: `for item in items` makes "item" available in the loop body.
634    fn process_for_loop(
635        &self,
636        for_loop: &webui_protocol::WebUIFragmentFor,
637        context: &mut WebUIProcessContext,
638    ) -> Result<()> {
639        let collection_name = &for_loop.collection;
640
641        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
642        // Hydration comments are always emitted regardless of collection presence.
643        let items = match self.resolve_value(collection_name, context) {
644            Some(Value::Array(arr)) => arr,
645            Some(_) => {
646                return Err(HandlerError::TypeError(format!(
647                    "Collection '{}' is not an array",
648                    collection_name
649                )))
650            }
651            None => Vec::new(),
652        };
653
654        if let Some(p) = &mut context.plugin {
655            p.on_binding_start(&for_loop.fragment_id, context.writer)?;
656        }
657
658        let item_name = &for_loop.item;
659        for (i, item) in items.into_iter().enumerate() {
660            if let Some(p) = &mut context.plugin {
661                p.on_repeat_item_start(i, context.writer)?;
662                p.push_scope();
663            }
664
665            // Save only the overwritten key instead of cloning the entire HashMap.
666            let saved_value = context.local_vars.insert(item_name.clone(), item);
667            self.process_fragment_id(&for_loop.fragment_id, context)?;
668            match saved_value {
669                Some(v) => {
670                    context.local_vars.insert(item_name.clone(), v);
671                }
672                None => {
673                    context.local_vars.remove(item_name.as_str());
674                }
675            }
676
677            if let Some(p) = &mut context.plugin {
678                p.pop_scope();
679                p.on_repeat_item_end(i, context.writer)?;
680            }
681        }
682
683        if let Some(p) = &mut context.plugin {
684            p.on_binding_end(&for_loop.fragment_id, context.writer)?;
685        }
686
687        Ok(())
688    }
689
690    /// Process a signal fragment.
691    ///
692    /// Looks up the value in the context first (for local variables), then in the global state.
693    /// This prioritization allows local variables (like loop items) to override global state.
694    /// If the value is not found in either scope, an empty string is returned.
695    fn process_signal(
696        &self,
697        signal: &webui_protocol::WebUIFragmentSignal,
698        context: &mut WebUIProcessContext,
699    ) -> Result<()> {
700        // Hook: emit inventory meta tag before </head>
701        if signal.raw && signal.value == "head_end" {
702            let (_, inventory_hex) = crate::route_handler::get_needed_components_for_request(
703                context.protocol,
704                &context.entry_id,
705                &context.request_path,
706                "",
707            );
708            context
709                .writer
710                .write("<meta name=\"webui-inventory\" content=\"")?;
711            context.writer.write(&inventory_hex)?;
712            context.writer.write("\">")?;
713        }
714
715        // Hook: emit plugin content (e.g., f-templates) before body_end
716        if signal.raw && signal.value == "body_end" {
717            if let Some(p) = &mut context.plugin {
718                p.on_render_complete(
719                    context.protocol,
720                    &context.rendered_components,
721                    context.writer,
722                )?;
723            }
724        }
725
726        if let Some(p) = &mut context.plugin {
727            p.on_binding_start(&signal.value, context.writer)?;
728        }
729
730        if let Some(value) = self.resolve_value(&signal.value, context) {
731            self.write_signal_value(&value, signal.raw, context.writer)?;
732        }
733
734        if let Some(p) = &mut context.plugin {
735            p.on_binding_end(&signal.value, context.writer)?;
736        }
737        Ok(())
738    }
739
740    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
741    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
742    fn write_signal_value(
743        &self,
744        value: &Value,
745        raw: bool,
746        writer: &mut dyn ResponseWriter,
747    ) -> Result<()> {
748        if raw {
749            match value {
750                Value::String(s) => writer.write(s),
751                _ => writer.write(&value.to_string()),
752            }
753        } else {
754            match value {
755                Value::String(s) => writer.write(&html_escape::encode_safe(s)),
756                _ => {
757                    let s = value.to_string();
758                    writer.write(&html_escape::encode_safe(&s))
759                }
760            }
761        }
762    }
763
764    /// Process an if condition fragment.
765    fn process_if(
766        &self,
767        if_cond: &webui_protocol::WebUIFragmentIf,
768        context: &mut WebUIProcessContext,
769    ) -> Result<()> {
770        let condition = if_cond
771            .condition
772            .as_ref()
773            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
774        let condition_met = self.evaluate_condition(condition, context)?;
775
776        if let Some(p) = &mut context.plugin {
777            p.on_binding_start(&if_cond.fragment_id, context.writer)?;
778        }
779
780        if condition_met {
781            if let Some(p) = &mut context.plugin {
782                p.push_scope();
783            }
784
785            self.process_fragment_id(&if_cond.fragment_id, context)?;
786
787            if let Some(p) = &mut context.plugin {
788                p.pop_scope();
789            }
790        }
791
792        if let Some(p) = &mut context.plugin {
793            p.on_binding_end(&if_cond.fragment_id, context.writer)?;
794        }
795
796        Ok(())
797    }
798
799    /// Process an attribute fragment by rendering the attribute name/value pair.
800    fn process_attribute(
801        &self,
802        attr: &webui_protocol::WebUIFragmentAttribute,
803        context: &mut WebUIProcessContext,
804    ) -> Result<()> {
805        // Initialize component attribute accumulator on attrStart
806        if attr.attr_start {
807            context.component_attrs = HashMap::new();
808        }
809
810        // Boolean attribute with condition tree
811        if let Some(condition) = &attr.condition_tree {
812            let condition_met = self.evaluate_condition(condition, context)?;
813
814            if !attr.attr_skip {
815                let name = component_attr_name(&attr.name);
816                context
817                    .component_attrs
818                    .insert(name, Value::Bool(condition_met));
819            }
820
821            if condition_met {
822                context.writer.write(" ")?;
823                context.writer.write(&attr.name)?;
824            }
825            return Ok(());
826        }
827
828        // Template attribute (mixed static + dynamic)
829        if !attr.template.is_empty() {
830            let raw_value = self.render_template_attr_value(&attr.template, context)?;
831            let escaped = html_escape::encode_safe(&raw_value);
832            write_attr(context.writer, &attr.name, &escaped)?;
833
834            if !attr.attr_skip {
835                let name = component_attr_name(&attr.name);
836                context
837                    .component_attrs
838                    .insert(name, Value::String(raw_value));
839            }
840            return Ok(());
841        }
842
843        // Simple attribute
844        if !attr.value.is_empty() {
845            if attr.raw_value {
846                // Static attribute — value is the literal string
847                write_attr(context.writer, &attr.name, &attr.value)?;
848                if !attr.attr_skip {
849                    let name = component_attr_name(&attr.name);
850                    context
851                        .component_attrs
852                        .insert(name, Value::String(attr.value.clone()));
853                }
854            } else if attr.complex {
855                // Complex attribute — resolve value, don't render to HTML, store as state
856                if let Some(value) = self.resolve_value(&attr.value, context) {
857                    if !attr.attr_skip {
858                        let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
859                        let name = component_attr_name(stripped);
860                        context.component_attrs.insert(name, value);
861                    }
862                }
863            } else {
864                // Dynamic attribute — resolve and render
865                let value = self.resolve_value(&attr.value, context);
866                // Always emit the attribute so FAST hydration binding
867                // markers (data-fe-b-N) match the DOM node structure.
868                match &value {
869                    Some(Value::String(s)) => {
870                        write_attr(context.writer, &attr.name, &html_escape::encode_safe(s))?;
871                    }
872                    Some(Value::Null) | None => {
873                        write_attr(context.writer, &attr.name, "")?;
874                    }
875                    Some(other) => {
876                        let s = other.to_string();
877                        write_attr(context.writer, &attr.name, &s)?;
878                    }
879                }
880
881                if !attr.attr_skip {
882                    let name = component_attr_name(&attr.name);
883                    context
884                        .component_attrs
885                        .insert(name, value.unwrap_or(Value::String(String::new())));
886                }
887            }
888        }
889
890        Ok(())
891    }
892
893    /// Render a template attribute's fragments into a raw (unescaped) string.
894    fn render_template_attr_value(
895        &self,
896        template_id: &str,
897        context: &WebUIProcessContext,
898    ) -> Result<String> {
899        let fragments = context
900            .protocol
901            .fragments
902            .get(template_id)
903            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
904        let mut raw_value = String::new();
905        for frag in &fragments.fragments {
906            match frag.fragment.as_ref() {
907                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
908                Some(Fragment::Signal(signal)) => {
909                    if let Some(value) = self.resolve_value(&signal.value, context) {
910                        match &value {
911                            Value::String(s) => raw_value.push_str(s),
912                            _ => raw_value.push_str(&value.to_string()),
913                        }
914                    }
915                }
916                _ => {}
917            }
918        }
919        Ok(raw_value)
920    }
921
922    /// Render the UI based on the protocol and state.
923    ///
924    /// Like `handle()` but does not call `writer.end()`.
925    pub fn render(
926        &self,
927        protocol: &WebUIProtocol,
928        state: &Value,
929        options: &RenderOptions<'_>,
930        writer: &mut dyn ResponseWriter,
931    ) -> Result<()> {
932        let mut context = WebUIProcessContext {
933            protocol,
934            state,
935            depth: 0,
936            writer,
937            local_vars: HashMap::new(),
938            component_attrs: HashMap::new(),
939            request_path: options.request_path.to_string(),
940            route_base: "/".to_string(),
941            rendered_components: HashSet::new(),
942            plugin: self.plugin_factory.map(|f| f()),
943            route_children: Vec::new(),
944            entry_id: options.entry_id.to_string(),
945        };
946
947        self.process_fragment_id(options.entry_id, &mut context)?;
948
949        Ok(())
950    }
951}
952
953impl Default for WebUIHandler {
954    fn default() -> Self {
955        Self::new()
956    }
957}
958
959/// Write ` name="value"` to the writer without allocating a format string.
960fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
961    writer.write(" ")?;
962    writer.write(name)?;
963    writer.write("=\"")?;
964    writer.write(value)?;
965    writer.write("\"")
966}
967
968/// Process a WebUI protocol with the provided state and write the output to the given writer.
969/// This is the main entry point for the WebUI handler.
970pub fn handle(
971    protocol: &WebUIProtocol,
972    state: &Value,
973    options: &RenderOptions<'_>,
974    writer: &mut dyn ResponseWriter,
975) -> Result<()> {
976    let handler = WebUIHandler::new();
977    handler.handle(protocol, state, options, writer)
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use std::cell::RefCell;
984    use webui_protocol::{
985        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
986        WebUIFragmentAttribute,
987    };
988    use webui_test_utils::test_json;
989
990    // A simple test writer implementation
991    struct TestWriter {
992        content: RefCell<String>,
993        ended: RefCell<bool>,
994    }
995
996    impl TestWriter {
997        fn new() -> Self {
998            Self {
999                content: RefCell::new(String::new()),
1000                ended: RefCell::new(false),
1001            }
1002        }
1003
1004        fn get_content(&self) -> String {
1005            self.content.borrow().clone()
1006        }
1007
1008        fn is_ended(&self) -> bool {
1009            *self.ended.borrow()
1010        }
1011    }
1012
1013    impl ResponseWriter for TestWriter {
1014        fn write(&mut self, content: &str) -> Result<()> {
1015            self.content.borrow_mut().push_str(content);
1016            Ok(())
1017        }
1018
1019        fn end(&mut self) -> Result<()> {
1020            *self.ended.borrow_mut() = true;
1021            Ok(())
1022        }
1023    }
1024
1025    #[test]
1026    fn test_handle_raw() {
1027        // Create a simple protocol
1028        let mut fragments = HashMap::new();
1029        fragments.insert(
1030            "index.html".to_string(),
1031            FragmentList {
1032                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
1033            },
1034        );
1035
1036        let protocol = WebUIProtocol::new(fragments);
1037        let state = test_json!({});
1038
1039        // Create a test writer
1040        let mut writer = TestWriter::new();
1041
1042        // Handle the protocol
1043        assert!(
1044            handle(
1045                &protocol,
1046                &state,
1047                &RenderOptions::new("index.html", "/"),
1048                &mut writer
1049            )
1050            .is_ok(),
1051            "Failed to handle raw protocol"
1052        );
1053
1054        // Check the output
1055        assert_eq!(writer.get_content(), "Hello, WebUI!");
1056        assert!(writer.is_ended());
1057    }
1058
1059    #[test]
1060    fn test_handle_signal() {
1061        // Create a protocol with a signal
1062        let mut fragments = HashMap::new();
1063        fragments.insert(
1064            "index.html".to_string(),
1065            FragmentList {
1066                fragments: vec![
1067                    WebUIFragment::raw("Hello, "),
1068                    WebUIFragment::signal("name", false),
1069                    WebUIFragment::raw("!"),
1070                ],
1071            },
1072        );
1073
1074        let protocol = WebUIProtocol::new(fragments);
1075        let state = test_json!({"name": "WebUI"});
1076
1077        // Create a test writer
1078        let mut writer = TestWriter::new();
1079
1080        // Handle the protocol
1081        assert!(
1082            handle(
1083                &protocol,
1084                &state,
1085                &RenderOptions::new("index.html", "/"),
1086                &mut writer
1087            )
1088            .is_ok(),
1089            "Failed to handle signal protocol"
1090        );
1091
1092        // Check the output
1093        assert_eq!(writer.get_content(), "Hello, WebUI!");
1094        assert!(writer.is_ended());
1095    }
1096
1097    #[test]
1098    fn test_handle_for_loop() {
1099        // Create a protocol with a for loop
1100        let mut fragments = HashMap::new();
1101        fragments.insert(
1102            "index.html".to_string(),
1103            FragmentList {
1104                fragments: vec![
1105                    WebUIFragment::raw("People: "),
1106                    WebUIFragment::for_loop("person", "people", "person-item"),
1107                ],
1108            },
1109        );
1110
1111        fragments.insert(
1112            "person-item".to_string(),
1113            FragmentList {
1114                fragments: vec![
1115                    WebUIFragment::signal("person.name", false),
1116                    WebUIFragment::raw(", "),
1117                ],
1118            },
1119        );
1120
1121        let protocol = WebUIProtocol::new(fragments);
1122        let state = test_json!({
1123            "people": [
1124                {"name": "Alice"},
1125                {"name": "Bob"},
1126                {"name": "Charlie"}
1127            ]
1128        });
1129
1130        // Create a test writer
1131        let mut writer = TestWriter::new();
1132
1133        // Handle the protocol
1134        assert!(
1135            handle(
1136                &protocol,
1137                &state,
1138                &RenderOptions::new("index.html", "/"),
1139                &mut writer
1140            )
1141            .is_ok(),
1142            "Failed to handle for loop protocol"
1143        );
1144
1145        // Check the output
1146        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
1147        assert!(writer.is_ended());
1148    }
1149
1150    #[test]
1151    fn test_handle_if_condition() {
1152        // Create a protocol with an if condition
1153        let mut fragments = HashMap::new();
1154        fragments.insert(
1155            "index.html".to_string(),
1156            FragmentList {
1157                fragments: vec![
1158                    WebUIFragment::raw("Status: "),
1159                    WebUIFragment::if_cond(
1160                        webui_protocol::ConditionExpr::identifier("isActive"),
1161                        "active-content",
1162                    ),
1163                    WebUIFragment::raw("End"),
1164                ],
1165            },
1166        );
1167
1168        fragments.insert(
1169            "active-content".to_string(),
1170            FragmentList {
1171                fragments: vec![WebUIFragment::raw("Active")],
1172            },
1173        );
1174
1175        let protocol = WebUIProtocol::new(fragments);
1176
1177        // Test with isActive = true
1178        let state_true = test_json!({"isActive": true});
1179        let mut writer_true = TestWriter::new();
1180        assert!(
1181            handle(
1182                &protocol,
1183                &state_true,
1184                &RenderOptions::new("index.html", "/"),
1185                &mut writer_true
1186            )
1187            .is_ok(),
1188            "Failed to handle if condition (true case)"
1189        );
1190        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
1191        assert!(writer_true.is_ended());
1192
1193        // Test with isActive = false
1194        let state_false = test_json!({"isActive": false});
1195        let mut writer_false = TestWriter::new();
1196        assert!(
1197            handle(
1198                &protocol,
1199                &state_false,
1200                &RenderOptions::new("index.html", "/"),
1201                &mut writer_false
1202            )
1203            .is_ok(),
1204            "Failed to handle if condition (false case)"
1205        );
1206        assert_eq!(writer_false.get_content(), "Status: End");
1207        assert!(writer_false.is_ended());
1208    }
1209
1210    #[test]
1211    fn test_handle_component() {
1212        // Create a protocol with a component
1213        let mut fragments = HashMap::new();
1214        fragments.insert(
1215            "index.html".to_string(),
1216            FragmentList {
1217                fragments: vec![
1218                    WebUIFragment::raw("Component: "),
1219                    WebUIFragment::component("my-component"),
1220                ],
1221            },
1222        );
1223
1224        fragments.insert(
1225            "my-component".to_string(),
1226            FragmentList {
1227                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
1228            },
1229        );
1230
1231        let protocol = WebUIProtocol::new(fragments);
1232        let state = test_json!({});
1233
1234        // Create a test writer
1235        let mut writer = TestWriter::new();
1236
1237        // Handle the protocol
1238        assert!(
1239            handle(
1240                &protocol,
1241                &state,
1242                &RenderOptions::new("index.html", "/"),
1243                &mut writer
1244            )
1245            .is_ok(),
1246            "Failed to handle component protocol"
1247        );
1248
1249        // Check the output
1250        assert_eq!(
1251            writer.get_content(),
1252            "Component: <div>Component Content</div>"
1253        );
1254        assert!(writer.is_ended());
1255    }
1256
1257    #[test]
1258    fn test_missing_fragment() {
1259        // Create a protocol with a missing fragment reference
1260        let mut fragments = HashMap::new();
1261        fragments.insert(
1262            "index.html".to_string(),
1263            FragmentList {
1264                fragments: vec![WebUIFragment::component("missing-component")],
1265            },
1266        );
1267
1268        let protocol = WebUIProtocol::new(fragments);
1269        let state = test_json!({});
1270
1271        // Create a test writer
1272        let mut writer = TestWriter::new();
1273
1274        // Handle the protocol
1275        let result = handle(
1276            &protocol,
1277            &state,
1278            &RenderOptions::new("index.html", "/"),
1279            &mut writer,
1280        );
1281
1282        // Expect an error
1283        assert!(result.is_err());
1284        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
1285            assert_eq!(fragment_id, "missing-component");
1286        } else {
1287            panic!("Expected MissingFragment error");
1288        }
1289    }
1290
1291    #[test]
1292    fn test_missing_signal_renders_empty() {
1293        // A signal referencing a field absent from state should render as empty
1294        let mut fragments = HashMap::new();
1295        fragments.insert(
1296            "index.html".to_string(),
1297            FragmentList {
1298                fragments: vec![
1299                    WebUIFragment::raw("Hello, "),
1300                    WebUIFragment::signal("missing_field", false),
1301                    WebUIFragment::raw("!"),
1302                ],
1303            },
1304        );
1305
1306        let protocol = WebUIProtocol::new(fragments);
1307        let state = test_json!({});
1308
1309        let mut writer = TestWriter::new();
1310
1311        assert!(
1312            handle(
1313                &protocol,
1314                &state,
1315                &RenderOptions::new("index.html", "/"),
1316                &mut writer
1317            )
1318            .is_ok(),
1319            "Missing signal should not produce an error"
1320        );
1321
1322        assert_eq!(writer.get_content(), "Hello, !");
1323        assert!(writer.is_ended());
1324    }
1325
1326    // ── Boolean attribute rendering tests ─────────────────────────────
1327
1328    #[test]
1329    fn test_boolean_attr_true() {
1330        let mut fragments = HashMap::new();
1331        fragments.insert(
1332            "index.html".to_string(),
1333            FragmentList {
1334                fragments: vec![
1335                    WebUIFragment::raw("<button"),
1336                    WebUIFragment::attribute_boolean(
1337                        "disabled",
1338                        ConditionExpr::identifier("isDisabled"),
1339                    ),
1340                    WebUIFragment::raw(">Click</button>"),
1341                ],
1342            },
1343        );
1344        let protocol = WebUIProtocol::new(fragments);
1345        let state = test_json!({"isDisabled": true});
1346        let mut writer = TestWriter::new();
1347        handle(
1348            &protocol,
1349            &state,
1350            &RenderOptions::new("index.html", "/"),
1351            &mut writer,
1352        )
1353        .unwrap();
1354        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
1355    }
1356
1357    #[test]
1358    fn test_boolean_attr_false() {
1359        let mut fragments = HashMap::new();
1360        fragments.insert(
1361            "index.html".to_string(),
1362            FragmentList {
1363                fragments: vec![
1364                    WebUIFragment::raw("<button"),
1365                    WebUIFragment::attribute_boolean(
1366                        "disabled",
1367                        ConditionExpr::identifier("isDisabled"),
1368                    ),
1369                    WebUIFragment::raw(">Click</button>"),
1370                ],
1371            },
1372        );
1373        let protocol = WebUIProtocol::new(fragments);
1374        let state = test_json!({"isDisabled": false});
1375        let mut writer = TestWriter::new();
1376        handle(
1377            &protocol,
1378            &state,
1379            &RenderOptions::new("index.html", "/"),
1380            &mut writer,
1381        )
1382        .unwrap();
1383        assert_eq!(writer.get_content(), "<button>Click</button>");
1384    }
1385
1386    #[test]
1387    fn test_boolean_attr_missing() {
1388        let mut fragments = HashMap::new();
1389        fragments.insert(
1390            "index.html".to_string(),
1391            FragmentList {
1392                fragments: vec![
1393                    WebUIFragment::raw("<input type=\"checkbox\""),
1394                    WebUIFragment::attribute_boolean(
1395                        "checked",
1396                        ConditionExpr::identifier("checked"),
1397                    ),
1398                    WebUIFragment::raw(">"),
1399                ],
1400            },
1401        );
1402        let protocol = WebUIProtocol::new(fragments);
1403        let state = test_json!({});
1404        let mut writer = TestWriter::new();
1405        handle(
1406            &protocol,
1407            &state,
1408            &RenderOptions::new("index.html", "/"),
1409            &mut writer,
1410        )
1411        .unwrap();
1412        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
1413    }
1414
1415    #[test]
1416    fn test_boolean_attr_multiple() {
1417        let mut fragments = HashMap::new();
1418        fragments.insert(
1419            "index.html".to_string(),
1420            FragmentList {
1421                fragments: vec![
1422                    WebUIFragment::raw("<input type=\"checkbox\""),
1423                    WebUIFragment::attribute_boolean(
1424                        "checked",
1425                        ConditionExpr::identifier("checked"),
1426                    ),
1427                    WebUIFragment::attribute_boolean(
1428                        "disabled",
1429                        ConditionExpr::identifier("disabled"),
1430                    ),
1431                    WebUIFragment::raw(">"),
1432                ],
1433            },
1434        );
1435        let protocol = WebUIProtocol::new(fragments);
1436        let state = test_json!({"checked": true, "disabled": false});
1437        let mut writer = TestWriter::new();
1438        handle(
1439            &protocol,
1440            &state,
1441            &RenderOptions::new("index.html", "/"),
1442            &mut writer,
1443        )
1444        .unwrap();
1445        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
1446    }
1447
1448    // ── Simple attribute rendering tests ──────────────────────────────
1449
1450    #[test]
1451    fn test_attribute_with_value() {
1452        let mut fragments = HashMap::new();
1453        fragments.insert(
1454            "index.html".to_string(),
1455            FragmentList {
1456                fragments: vec![
1457                    WebUIFragment::raw("<input"),
1458                    WebUIFragment::attribute("value", "inputValue"),
1459                    WebUIFragment::raw(">"),
1460                ],
1461            },
1462        );
1463        let protocol = WebUIProtocol::new(fragments);
1464        let state = test_json!({"inputValue": "Hello"});
1465        let mut writer = TestWriter::new();
1466        handle(
1467            &protocol,
1468            &state,
1469            &RenderOptions::new("index.html", "/"),
1470            &mut writer,
1471        )
1472        .unwrap();
1473        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
1474    }
1475
1476    #[test]
1477    fn test_attribute_with_falsy_numeric() {
1478        let mut fragments = HashMap::new();
1479        fragments.insert(
1480            "index.html".to_string(),
1481            FragmentList {
1482                fragments: vec![
1483                    WebUIFragment::raw("<div name=\"test\""),
1484                    WebUIFragment::attribute("handle", "number"),
1485                    WebUIFragment::raw("></div>"),
1486                ],
1487            },
1488        );
1489        let protocol = WebUIProtocol::new(fragments);
1490        let state = test_json!({"number": 0});
1491        let mut writer = TestWriter::new();
1492        handle(
1493            &protocol,
1494            &state,
1495            &RenderOptions::new("index.html", "/"),
1496            &mut writer,
1497        )
1498        .unwrap();
1499        assert_eq!(
1500            writer.get_content(),
1501            "<div name=\"test\" handle=\"0\"></div>"
1502        );
1503    }
1504
1505    // ── Template attribute rendering tests ────────────────────────────
1506
1507    #[test]
1508    fn test_mixed_attribute_template() {
1509        let mut fragments = HashMap::new();
1510        fragments.insert(
1511            "index.html".to_string(),
1512            FragmentList {
1513                fragments: vec![
1514                    WebUIFragment::raw("<input"),
1515                    WebUIFragment::attribute_template("value", "attr-1"),
1516                    WebUIFragment::raw(">"),
1517                ],
1518            },
1519        );
1520        fragments.insert(
1521            "attr-1".to_string(),
1522            FragmentList {
1523                fragments: vec![
1524                    WebUIFragment::raw("hello "),
1525                    WebUIFragment::signal("item", false),
1526                ],
1527            },
1528        );
1529        let protocol = WebUIProtocol::new(fragments);
1530        let state = test_json!({"item": "world"});
1531        let mut writer = TestWriter::new();
1532        handle(
1533            &protocol,
1534            &state,
1535            &RenderOptions::new("index.html", "/"),
1536            &mut writer,
1537        )
1538        .unwrap();
1539        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
1540    }
1541
1542    // ── Raw signal rendering test ─────────────────────────────────────
1543
1544    #[test]
1545    fn test_raw_signal_not_escaped() {
1546        let mut fragments = HashMap::new();
1547        fragments.insert(
1548            "index.html".to_string(),
1549            FragmentList {
1550                fragments: vec![
1551                    WebUIFragment::signal("html", false),
1552                    WebUIFragment::signal("html", true),
1553                ],
1554            },
1555        );
1556        let protocol = WebUIProtocol::new(fragments);
1557        let state = test_json!({"html": "<strong>hi</strong>"});
1558        let mut writer = TestWriter::new();
1559        handle(
1560            &protocol,
1561            &state,
1562            &RenderOptions::new("index.html", "/"),
1563            &mut writer,
1564        )
1565        .unwrap();
1566        assert_eq!(
1567            writer.get_content(),
1568            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
1569        );
1570    }
1571
1572    // ── Nested for loop tests ─────────────────────────────────────────
1573
1574    #[test]
1575    fn test_nested_for_loop() {
1576        let mut fragments = HashMap::new();
1577        fragments.insert(
1578            "index.html".to_string(),
1579            FragmentList {
1580                fragments: vec![
1581                    WebUIFragment::raw("<div>"),
1582                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
1583                    WebUIFragment::raw("</div>"),
1584                ],
1585            },
1586        );
1587        fragments.insert(
1588            "outer".to_string(),
1589            FragmentList {
1590                fragments: vec![
1591                    WebUIFragment::raw("<div>"),
1592                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
1593                    WebUIFragment::raw("</div>"),
1594                ],
1595            },
1596        );
1597        fragments.insert(
1598            "inner".to_string(),
1599            FragmentList {
1600                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
1601            },
1602        );
1603        let protocol = WebUIProtocol::new(fragments);
1604        let state = test_json!({
1605            "outerItems": [
1606                {"innerItems": [{"name": "A"}, {"name": "B"}]},
1607                {"innerItems": [{"name": "C"}]}
1608            ]
1609        });
1610        let mut writer = TestWriter::new();
1611        handle(
1612            &protocol,
1613            &state,
1614            &RenderOptions::new("index.html", "/"),
1615            &mut writer,
1616        )
1617        .unwrap();
1618        assert_eq!(
1619            writer.get_content(),
1620            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
1621        );
1622    }
1623
1624    #[test]
1625    fn test_nested_for_with_signals() {
1626        let mut fragments = HashMap::new();
1627        fragments.insert(
1628            "index.html".to_string(),
1629            FragmentList {
1630                fragments: vec![
1631                    WebUIFragment::raw("<div>"),
1632                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
1633                    WebUIFragment::raw("</div>"),
1634                ],
1635            },
1636        );
1637        fragments.insert(
1638            "outerTemplate".to_string(),
1639            FragmentList {
1640                fragments: vec![
1641                    WebUIFragment::raw("<div>"),
1642                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
1643                    WebUIFragment::raw("</div>"),
1644                ],
1645            },
1646        );
1647        fragments.insert(
1648            "innerTemplate".to_string(),
1649            FragmentList {
1650                fragments: vec![
1651                    WebUIFragment::raw("<span>"),
1652                    WebUIFragment::signal("innerItem.name", false),
1653                    WebUIFragment::raw("</span>"),
1654                ],
1655            },
1656        );
1657        let protocol = WebUIProtocol::new(fragments);
1658        let state = test_json!({
1659            "outerItems": [
1660                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
1661                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
1662            ]
1663        });
1664        let mut writer = TestWriter::new();
1665        handle(
1666            &protocol,
1667            &state,
1668            &RenderOptions::new("index.html", "/"),
1669            &mut writer,
1670        )
1671        .unwrap();
1672        assert_eq!(
1673            writer.get_content(),
1674            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_nested_for_with_global_state() {
1680        let mut fragments = HashMap::new();
1681        fragments.insert(
1682            "index.html".to_string(),
1683            FragmentList {
1684                fragments: vec![
1685                    WebUIFragment::raw("<div>"),
1686                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
1687                    WebUIFragment::raw("</div>"),
1688                ],
1689            },
1690        );
1691        fragments.insert(
1692            "outerTemplate".to_string(),
1693            FragmentList {
1694                fragments: vec![
1695                    WebUIFragment::raw("<div>"),
1696                    WebUIFragment::signal("globalOuter", false),
1697                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
1698                    WebUIFragment::raw("</div>"),
1699                ],
1700            },
1701        );
1702        fragments.insert(
1703            "innerTemplate".to_string(),
1704            FragmentList {
1705                fragments: vec![
1706                    WebUIFragment::raw("<span>"),
1707                    WebUIFragment::signal("innerItem.name", false),
1708                    WebUIFragment::signal("globalInner", false),
1709                    WebUIFragment::raw("</span>"),
1710                ],
1711            },
1712        );
1713        let protocol = WebUIProtocol::new(fragments);
1714        let state = test_json!({
1715            "globalOuter": "GO",
1716            "globalInner": "GI",
1717            "outerItems": [
1718                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
1719                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
1720            ]
1721        });
1722        let mut writer = TestWriter::new();
1723        handle(
1724            &protocol,
1725            &state,
1726            &RenderOptions::new("index.html", "/"),
1727            &mut writer,
1728        )
1729        .unwrap();
1730        assert_eq!(
1731            writer.get_content(),
1732            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
1733        );
1734    }
1735
1736    // ── For + If state scoping tests ──────────────────────────────────
1737
1738    #[test]
1739    fn test_if_in_for_uses_local_state() {
1740        let mut fragments = HashMap::new();
1741        fragments.insert(
1742            "index.html".to_string(),
1743            FragmentList {
1744                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
1745            },
1746        );
1747        fragments.insert(
1748            "item-tpl".to_string(),
1749            FragmentList {
1750                fragments: vec![WebUIFragment::if_cond(
1751                    ConditionExpr::identifier("item.visible"),
1752                    "visible-tpl",
1753                )],
1754            },
1755        );
1756        fragments.insert(
1757            "visible-tpl".to_string(),
1758            FragmentList {
1759                fragments: vec![WebUIFragment::signal("item.name", false)],
1760            },
1761        );
1762        let protocol = WebUIProtocol::new(fragments);
1763        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
1764        let mut writer = TestWriter::new();
1765        handle(
1766            &protocol,
1767            &state,
1768            &RenderOptions::new("index.html", "/"),
1769            &mut writer,
1770        )
1771        .unwrap();
1772        assert_eq!(writer.get_content(), "Show");
1773    }
1774
1775    #[test]
1776    fn test_for_if_local_overrides_global() {
1777        let mut fragments = HashMap::new();
1778        fragments.insert(
1779            "index.html".to_string(),
1780            FragmentList {
1781                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
1782            },
1783        );
1784        fragments.insert(
1785            "item-tpl".to_string(),
1786            FragmentList {
1787                fragments: vec![WebUIFragment::if_cond(
1788                    ConditionExpr::identifier("item.flag"),
1789                    "show-tpl",
1790                )],
1791            },
1792        );
1793        fragments.insert(
1794            "show-tpl".to_string(),
1795            FragmentList {
1796                fragments: vec![WebUIFragment::raw("yes")],
1797            },
1798        );
1799        let protocol = WebUIProtocol::new(fragments);
1800        // Global flag is true, but local item.flag is false for second item
1801        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
1802        let mut writer = TestWriter::new();
1803        handle(
1804            &protocol,
1805            &state,
1806            &RenderOptions::new("index.html", "/"),
1807            &mut writer,
1808        )
1809        .unwrap();
1810        assert_eq!(writer.get_content(), "yes");
1811    }
1812
1813    // ── Component attribute state tests ───────────────────────────────
1814
1815    #[test]
1816    fn test_component_attr_state_simple() {
1817        let mut fragments = HashMap::new();
1818        fragments.insert(
1819            "index.html".to_string(),
1820            FragmentList {
1821                fragments: vec![
1822                    WebUIFragment::raw("<my-comp"),
1823                    WebUIFragment {
1824                        fragment: Some(web_ui_fragment::Fragment::Attribute(
1825                            WebUIFragmentAttribute {
1826                                name: "title".into(),
1827                                value: "Attribute Title".into(),
1828                                attr_start: true,
1829                                raw_value: true,
1830                                ..Default::default()
1831                            },
1832                        )),
1833                    },
1834                    WebUIFragment::raw(">"),
1835                    WebUIFragment::component("my-comp"),
1836                    WebUIFragment::raw("</my-comp>"),
1837                ],
1838            },
1839        );
1840        fragments.insert(
1841            "my-comp".to_string(),
1842            FragmentList {
1843                fragments: vec![
1844                    WebUIFragment::raw("<span>"),
1845                    WebUIFragment::signal("title", false),
1846                    WebUIFragment::raw("</span>"),
1847                ],
1848            },
1849        );
1850        let protocol = WebUIProtocol::new(fragments);
1851        let state = test_json!({"title": "Global Title"});
1852        let mut writer = TestWriter::new();
1853        handle(
1854            &protocol,
1855            &state,
1856            &RenderOptions::new("index.html", "/"),
1857            &mut writer,
1858        )
1859        .unwrap();
1860        assert_eq!(
1861            writer.get_content(),
1862            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
1863        );
1864    }
1865
1866    #[test]
1867    fn test_component_attr_state_template() {
1868        let mut fragments = HashMap::new();
1869        fragments.insert(
1870            "index.html".to_string(),
1871            FragmentList {
1872                fragments: vec![
1873                    WebUIFragment::raw("<my-comp"),
1874                    WebUIFragment {
1875                        fragment: Some(web_ui_fragment::Fragment::Attribute(
1876                            WebUIFragmentAttribute {
1877                                name: "title".into(),
1878                                template: "title-attr".into(),
1879                                attr_start: true,
1880                                ..Default::default()
1881                            },
1882                        )),
1883                    },
1884                    WebUIFragment::raw(">"),
1885                    WebUIFragment::component("my-comp"),
1886                    WebUIFragment::raw("</my-comp>"),
1887                ],
1888            },
1889        );
1890        fragments.insert(
1891            "title-attr".to_string(),
1892            FragmentList {
1893                fragments: vec![
1894                    WebUIFragment::raw("hello "),
1895                    WebUIFragment::signal("item", false),
1896                ],
1897            },
1898        );
1899        fragments.insert(
1900            "my-comp".to_string(),
1901            FragmentList {
1902                fragments: vec![
1903                    WebUIFragment::raw("<span>"),
1904                    WebUIFragment::signal("title", false),
1905                    WebUIFragment::raw("</span>"),
1906                ],
1907            },
1908        );
1909        let protocol = WebUIProtocol::new(fragments);
1910        let state = test_json!({"item": "<world>"});
1911        let mut writer = TestWriter::new();
1912        handle(
1913            &protocol,
1914            &state,
1915            &RenderOptions::new("index.html", "/"),
1916            &mut writer,
1917        )
1918        .unwrap();
1919        assert_eq!(
1920            writer.get_content(),
1921            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
1922        );
1923    }
1924
1925    #[test]
1926    fn test_component_attr_camel_case() {
1927        let mut fragments = HashMap::new();
1928        fragments.insert(
1929            "index.html".to_string(),
1930            FragmentList {
1931                fragments: vec![
1932                    WebUIFragment::raw("<my-comp"),
1933                    WebUIFragment {
1934                        fragment: Some(web_ui_fragment::Fragment::Attribute(
1935                            WebUIFragmentAttribute {
1936                                name: "data-title".into(),
1937                                template: "dt-attr".into(),
1938                                attr_start: true,
1939                                ..Default::default()
1940                            },
1941                        )),
1942                    },
1943                    WebUIFragment::raw(">"),
1944                    WebUIFragment::component("my-comp"),
1945                    WebUIFragment::raw("</my-comp>"),
1946                ],
1947            },
1948        );
1949        fragments.insert(
1950            "dt-attr".to_string(),
1951            FragmentList {
1952                fragments: vec![
1953                    WebUIFragment::raw("prefix "),
1954                    WebUIFragment::signal("item", false),
1955                ],
1956            },
1957        );
1958        fragments.insert(
1959            "my-comp".to_string(),
1960            FragmentList {
1961                fragments: vec![
1962                    WebUIFragment::raw("<span>"),
1963                    WebUIFragment::signal("dataTitle", false),
1964                    WebUIFragment::raw("</span>"),
1965                ],
1966            },
1967        );
1968        let protocol = WebUIProtocol::new(fragments);
1969        let state = test_json!({"item": "a&b"});
1970        let mut writer = TestWriter::new();
1971        handle(
1972            &protocol,
1973            &state,
1974            &RenderOptions::new("index.html", "/"),
1975            &mut writer,
1976        )
1977        .unwrap();
1978        assert_eq!(
1979            writer.get_content(),
1980            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
1981        );
1982    }
1983
1984    #[test]
1985    fn test_component_complex_attr() {
1986        let mut fragments = HashMap::new();
1987        fragments.insert(
1988            "index.html".to_string(),
1989            FragmentList {
1990                fragments: vec![
1991                    WebUIFragment::raw("<my-comp"),
1992                    WebUIFragment {
1993                        fragment: Some(web_ui_fragment::Fragment::Attribute(
1994                            WebUIFragmentAttribute {
1995                                name: ":item".into(),
1996                                value: "complexItem".into(),
1997                                attr_start: true,
1998                                complex: true,
1999                                ..Default::default()
2000                            },
2001                        )),
2002                    },
2003                    WebUIFragment::raw(">"),
2004                    WebUIFragment::component("my-comp"),
2005                    WebUIFragment::raw("</my-comp>"),
2006                ],
2007            },
2008        );
2009        fragments.insert(
2010            "my-comp".to_string(),
2011            FragmentList {
2012                fragments: vec![
2013                    WebUIFragment::raw("<span>"),
2014                    WebUIFragment::signal("item.foo", false),
2015                    WebUIFragment::raw("</span><p>"),
2016                    WebUIFragment::signal("item.bar", false),
2017                    WebUIFragment::raw("</p>"),
2018                ],
2019            },
2020        );
2021        let protocol = WebUIProtocol::new(fragments);
2022        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
2023        let mut writer = TestWriter::new();
2024        handle(
2025            &protocol,
2026            &state,
2027            &RenderOptions::new("index.html", "/"),
2028            &mut writer,
2029        )
2030        .unwrap();
2031        assert_eq!(
2032            writer.get_content(),
2033            "<my-comp><span>1</span><p>true</p></my-comp>"
2034        );
2035    }
2036
2037    #[test]
2038    fn test_component_no_parent_pollution() {
2039        let mut fragments = HashMap::new();
2040        fragments.insert(
2041            "index.html".to_string(),
2042            FragmentList {
2043                fragments: vec![
2044                    WebUIFragment::raw("<parent"),
2045                    WebUIFragment {
2046                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2047                            WebUIFragmentAttribute {
2048                                name: "var".into(),
2049                                value: "var".into(),
2050                                attr_start: true,
2051                                ..Default::default()
2052                            },
2053                        )),
2054                    },
2055                    WebUIFragment::raw(">"),
2056                    WebUIFragment::component("parent"),
2057                    WebUIFragment::raw("</parent>"),
2058                ],
2059            },
2060        );
2061        fragments.insert(
2062            "parent".to_string(),
2063            FragmentList {
2064                fragments: vec![
2065                    WebUIFragment::raw("Before: "),
2066                    WebUIFragment::signal("var", false),
2067                    WebUIFragment::raw("<child foo"),
2068                    WebUIFragment {
2069                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2070                            WebUIFragmentAttribute {
2071                                name: "var".into(),
2072                                value: "replaced".into(),
2073                                raw_value: true,
2074                                ..Default::default()
2075                            },
2076                        )),
2077                    },
2078                    WebUIFragment::raw(">"),
2079                    WebUIFragment::component("child"),
2080                    WebUIFragment::raw("Label</child>After: "),
2081                    WebUIFragment::signal("var", false),
2082                ],
2083            },
2084        );
2085        fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
2086        let protocol = WebUIProtocol::new(fragments);
2087        let state = test_json!({"var": "original"});
2088        let mut writer = TestWriter::new();
2089        handle(
2090            &protocol,
2091            &state,
2092            &RenderOptions::new("index.html", "/"),
2093            &mut writer,
2094        )
2095        .unwrap();
2096        assert_eq!(
2097            writer.get_content(),
2098            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
2099        );
2100    }
2101
2102    #[test]
2103    fn test_component_boolean_attr_state() {
2104        let mut fragments = HashMap::new();
2105        fragments.insert(
2106            "index.html".to_string(),
2107            FragmentList {
2108                fragments: vec![
2109                    WebUIFragment::raw("<my-comp"),
2110                    WebUIFragment {
2111                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2112                            WebUIFragmentAttribute {
2113                                name: "disabled".into(),
2114                                attr_start: true,
2115                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
2116                                ..Default::default()
2117                            },
2118                        )),
2119                    },
2120                    WebUIFragment::raw(">"),
2121                    WebUIFragment::component("my-comp"),
2122                    WebUIFragment::raw("</my-comp>"),
2123                ],
2124            },
2125        );
2126        fragments.insert(
2127            "my-comp".to_string(),
2128            FragmentList {
2129                fragments: vec![WebUIFragment::if_cond(
2130                    ConditionExpr::identifier("disabled"),
2131                    "show",
2132                )],
2133            },
2134        );
2135        fragments.insert(
2136            "show".to_string(),
2137            FragmentList {
2138                fragments: vec![WebUIFragment::raw("disabled!")],
2139            },
2140        );
2141        let protocol = WebUIProtocol::new(fragments);
2142        let state = test_json!({"isDisabled": true});
2143        let mut writer = TestWriter::new();
2144        handle(
2145            &protocol,
2146            &state,
2147            &RenderOptions::new("index.html", "/"),
2148            &mut writer,
2149        )
2150        .unwrap();
2151        assert_eq!(
2152            writer.get_content(),
2153            "<my-comp disabled>disabled!</my-comp>"
2154        );
2155    }
2156
2157    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
2158
2159    /// Helper: render a signal value through the handler and return the escaped output.
2160    fn render_signal(value: &str) -> String {
2161        let mut fragments = HashMap::new();
2162        fragments.insert(
2163            "index.html".to_string(),
2164            FragmentList {
2165                fragments: vec![WebUIFragment::signal("v", false)],
2166            },
2167        );
2168        let protocol = WebUIProtocol::new(fragments);
2169        let state = test_json!({"v": value});
2170        let mut writer = TestWriter::new();
2171        handle(
2172            &protocol,
2173            &state,
2174            &RenderOptions::new("index.html", "/"),
2175            &mut writer,
2176        )
2177        .unwrap();
2178        writer.get_content()
2179    }
2180
2181    #[test]
2182    fn test_escape_ampersand() {
2183        assert_eq!(render_signal("&"), "&amp;");
2184    }
2185
2186    #[test]
2187    fn test_escape_less_than() {
2188        assert_eq!(render_signal("<"), "&lt;");
2189    }
2190
2191    #[test]
2192    fn test_escape_greater_than() {
2193        assert_eq!(render_signal(">"), "&gt;");
2194    }
2195
2196    #[test]
2197    fn test_escape_double_quote() {
2198        assert_eq!(render_signal("\""), "&quot;");
2199    }
2200
2201    #[test]
2202    fn test_escape_single_quote() {
2203        // html_escape::encode_safe escapes ' as &#x27;
2204        let result = render_signal("'");
2205        assert!(
2206            result == "&#39;" || result == "&#x27;" || result == "'",
2207            "Expected escaped single quote, got: {}",
2208            result
2209        );
2210    }
2211
2212    #[test]
2213    fn test_escape_multiple_special_chars() {
2214        let result = render_signal("<script>alert('xss');</script>");
2215        assert!(
2216            result.contains("&lt;") && result.contains("&gt;"),
2217            "Expected escaped HTML, got: {}",
2218            result
2219        );
2220        assert!(
2221            !result.contains("<script>"),
2222            "Should not contain raw <script> tag"
2223        );
2224    }
2225
2226    #[test]
2227    fn test_escape_no_special_chars() {
2228        assert_eq!(render_signal("Hello World"), "Hello World");
2229    }
2230
2231    #[test]
2232    fn test_escape_empty_string() {
2233        assert_eq!(render_signal(""), "");
2234    }
2235
2236    #[test]
2237    fn test_escape_special_at_beginning() {
2238        let result = render_signal("<Hello");
2239        assert!(
2240            result.starts_with("&lt;"),
2241            "Expected &lt; at start, got: {}",
2242            result
2243        );
2244    }
2245
2246    #[test]
2247    fn test_escape_special_at_end() {
2248        let result = render_signal("Hello>");
2249        assert!(
2250            result.ends_with("&gt;"),
2251            "Expected &gt; at end, got: {}",
2252            result
2253        );
2254    }
2255
2256    #[test]
2257    fn test_escape_special_in_middle() {
2258        let result = render_signal("Hel&lo");
2259        assert!(
2260            result.contains("&amp;"),
2261            "Expected &amp; in middle, got: {}",
2262            result
2263        );
2264    }
2265
2266    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
2267
2268    #[test]
2269    fn test_boolean_attr_truthy_values() {
2270        // checked: 1
2271        {
2272            let mut fragments = HashMap::new();
2273            fragments.insert(
2274                "index.html".to_string(),
2275                FragmentList {
2276                    fragments: vec![
2277                        WebUIFragment::raw("<input"),
2278                        WebUIFragment::attribute_boolean(
2279                            "checked",
2280                            ConditionExpr::identifier("checked"),
2281                        ),
2282                        WebUIFragment::raw(">"),
2283                    ],
2284                },
2285            );
2286            let protocol = WebUIProtocol::new(fragments);
2287            let state = test_json!({"checked": 1});
2288            let mut writer = TestWriter::new();
2289            handle(
2290                &protocol,
2291                &state,
2292                &RenderOptions::new("index.html", "/"),
2293                &mut writer,
2294            )
2295            .unwrap();
2296            assert_eq!(writer.get_content(), "<input checked>");
2297        }
2298        // checked: "yes"
2299        {
2300            let mut fragments = HashMap::new();
2301            fragments.insert(
2302                "index.html".to_string(),
2303                FragmentList {
2304                    fragments: vec![
2305                        WebUIFragment::raw("<input"),
2306                        WebUIFragment::attribute_boolean(
2307                            "checked",
2308                            ConditionExpr::identifier("checked"),
2309                        ),
2310                        WebUIFragment::raw(">"),
2311                    ],
2312                },
2313            );
2314            let protocol = WebUIProtocol::new(fragments);
2315            let state = test_json!({"checked": "yes"});
2316            let mut writer = TestWriter::new();
2317            handle(
2318                &protocol,
2319                &state,
2320                &RenderOptions::new("index.html", "/"),
2321                &mut writer,
2322            )
2323            .unwrap();
2324            assert_eq!(writer.get_content(), "<input checked>");
2325        }
2326        // checked: {} (empty object is truthy)
2327        {
2328            let mut fragments = HashMap::new();
2329            fragments.insert(
2330                "index.html".to_string(),
2331                FragmentList {
2332                    fragments: vec![
2333                        WebUIFragment::raw("<input"),
2334                        WebUIFragment::attribute_boolean(
2335                            "checked",
2336                            ConditionExpr::identifier("checked"),
2337                        ),
2338                        WebUIFragment::raw(">"),
2339                    ],
2340                },
2341            );
2342            let protocol = WebUIProtocol::new(fragments);
2343            let state = test_json!({"checked": {}});
2344            let mut writer = TestWriter::new();
2345            handle(
2346                &protocol,
2347                &state,
2348                &RenderOptions::new("index.html", "/"),
2349                &mut writer,
2350            )
2351            .unwrap();
2352            // Empty object is falsy in this expression evaluator
2353            assert_eq!(writer.get_content(), "<input>");
2354        }
2355        // checked: "false" (string "false" is truthy)
2356        {
2357            let mut fragments = HashMap::new();
2358            fragments.insert(
2359                "index.html".to_string(),
2360                FragmentList {
2361                    fragments: vec![
2362                        WebUIFragment::raw("<input"),
2363                        WebUIFragment::attribute_boolean(
2364                            "checked",
2365                            ConditionExpr::identifier("checked"),
2366                        ),
2367                        WebUIFragment::raw(">"),
2368                    ],
2369                },
2370            );
2371            let protocol = WebUIProtocol::new(fragments);
2372            let state = test_json!({"checked": "false"});
2373            let mut writer = TestWriter::new();
2374            handle(
2375                &protocol,
2376                &state,
2377                &RenderOptions::new("index.html", "/"),
2378                &mut writer,
2379            )
2380            .unwrap();
2381            assert_eq!(writer.get_content(), "<input checked>");
2382        }
2383    }
2384
2385    #[test]
2386    fn test_boolean_attr_falsy_values() {
2387        // checked: 0
2388        {
2389            let mut fragments = HashMap::new();
2390            fragments.insert(
2391                "index.html".to_string(),
2392                FragmentList {
2393                    fragments: vec![
2394                        WebUIFragment::raw("<input"),
2395                        WebUIFragment::attribute_boolean(
2396                            "checked",
2397                            ConditionExpr::identifier("checked"),
2398                        ),
2399                        WebUIFragment::raw(">"),
2400                    ],
2401                },
2402            );
2403            let protocol = WebUIProtocol::new(fragments);
2404            let state = test_json!({"checked": 0});
2405            let mut writer = TestWriter::new();
2406            handle(
2407                &protocol,
2408                &state,
2409                &RenderOptions::new("index.html", "/"),
2410                &mut writer,
2411            )
2412            .unwrap();
2413            assert_eq!(writer.get_content(), "<input>");
2414        }
2415        // checked: ""
2416        {
2417            let mut fragments = HashMap::new();
2418            fragments.insert(
2419                "index.html".to_string(),
2420                FragmentList {
2421                    fragments: vec![
2422                        WebUIFragment::raw("<input"),
2423                        WebUIFragment::attribute_boolean(
2424                            "checked",
2425                            ConditionExpr::identifier("checked"),
2426                        ),
2427                        WebUIFragment::raw(">"),
2428                    ],
2429                },
2430            );
2431            let protocol = WebUIProtocol::new(fragments);
2432            let state = test_json!({"checked": ""});
2433            let mut writer = TestWriter::new();
2434            handle(
2435                &protocol,
2436                &state,
2437                &RenderOptions::new("index.html", "/"),
2438                &mut writer,
2439            )
2440            .unwrap();
2441            assert_eq!(writer.get_content(), "<input>");
2442        }
2443        // checked: false
2444        {
2445            let mut fragments = HashMap::new();
2446            fragments.insert(
2447                "index.html".to_string(),
2448                FragmentList {
2449                    fragments: vec![
2450                        WebUIFragment::raw("<input"),
2451                        WebUIFragment::attribute_boolean(
2452                            "checked",
2453                            ConditionExpr::identifier("checked"),
2454                        ),
2455                        WebUIFragment::raw(">"),
2456                    ],
2457                },
2458            );
2459            let protocol = WebUIProtocol::new(fragments);
2460            let state = test_json!({"checked": false});
2461            let mut writer = TestWriter::new();
2462            handle(
2463                &protocol,
2464                &state,
2465                &RenderOptions::new("index.html", "/"),
2466                &mut writer,
2467            )
2468            .unwrap();
2469            assert_eq!(writer.get_content(), "<input>");
2470        }
2471        // no checked key at all
2472        {
2473            let mut fragments = HashMap::new();
2474            fragments.insert(
2475                "index.html".to_string(),
2476                FragmentList {
2477                    fragments: vec![
2478                        WebUIFragment::raw("<input"),
2479                        WebUIFragment::attribute_boolean(
2480                            "checked",
2481                            ConditionExpr::identifier("checked"),
2482                        ),
2483                        WebUIFragment::raw(">"),
2484                    ],
2485                },
2486            );
2487            let protocol = WebUIProtocol::new(fragments);
2488            let state = test_json!({});
2489            let mut writer = TestWriter::new();
2490            handle(
2491                &protocol,
2492                &state,
2493                &RenderOptions::new("index.html", "/"),
2494                &mut writer,
2495            )
2496            .unwrap();
2497            assert_eq!(writer.get_content(), "<input>");
2498        }
2499    }
2500
2501    #[test]
2502    fn test_boolean_attr_expression_true() {
2503        let mut fragments = HashMap::new();
2504        fragments.insert(
2505            "index.html".to_string(),
2506            FragmentList {
2507                fragments: vec![
2508                    WebUIFragment::raw("<button"),
2509                    WebUIFragment::attribute_boolean(
2510                        "disabled",
2511                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
2512                    ),
2513                    WebUIFragment::raw(">Click</button>"),
2514                ],
2515            },
2516        );
2517        let protocol = WebUIProtocol::new(fragments);
2518        let state = test_json!({"itemCount": 5});
2519        let mut writer = TestWriter::new();
2520        handle(
2521            &protocol,
2522            &state,
2523            &RenderOptions::new("index.html", "/"),
2524            &mut writer,
2525        )
2526        .unwrap();
2527        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2528    }
2529
2530    #[test]
2531    fn test_boolean_attr_expression_false() {
2532        let mut fragments = HashMap::new();
2533        fragments.insert(
2534            "index.html".to_string(),
2535            FragmentList {
2536                fragments: vec![
2537                    WebUIFragment::raw("<button"),
2538                    WebUIFragment::attribute_boolean(
2539                        "disabled",
2540                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
2541                    ),
2542                    WebUIFragment::raw(">Click</button>"),
2543                ],
2544            },
2545        );
2546        let protocol = WebUIProtocol::new(fragments);
2547        let state = test_json!({"itemCount": 3});
2548        let mut writer = TestWriter::new();
2549        handle(
2550            &protocol,
2551            &state,
2552            &RenderOptions::new("index.html", "/"),
2553            &mut writer,
2554        )
2555        .unwrap();
2556        assert_eq!(writer.get_content(), "<button>Click</button>");
2557    }
2558
2559    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
2560
2561    #[test]
2562    fn test_nested_component_attr_capture() {
2563        let mut fragments = HashMap::new();
2564        fragments.insert(
2565            "index.html".to_string(),
2566            FragmentList {
2567                fragments: vec![
2568                    WebUIFragment::raw("<parent-component"),
2569                    WebUIFragment {
2570                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2571                            WebUIFragmentAttribute {
2572                                name: "title".into(),
2573                                template: "parent-title".into(),
2574                                attr_start: true,
2575                                ..Default::default()
2576                            },
2577                        )),
2578                    },
2579                    WebUIFragment::raw(">"),
2580                    WebUIFragment::component("parent-component"),
2581                    WebUIFragment::raw("</parent-component>"),
2582                ],
2583            },
2584        );
2585        fragments.insert(
2586            "parent-title".to_string(),
2587            FragmentList {
2588                fragments: vec![
2589                    WebUIFragment::raw("Hello "),
2590                    WebUIFragment::signal("who", false),
2591                ],
2592            },
2593        );
2594        fragments.insert(
2595            "parent-component".to_string(),
2596            FragmentList {
2597                fragments: vec![
2598                    WebUIFragment::raw("<child-component"),
2599                    WebUIFragment {
2600                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2601                            WebUIFragmentAttribute {
2602                                name: "title".into(),
2603                                template: "child-title".into(),
2604                                attr_start: true,
2605                                ..Default::default()
2606                            },
2607                        )),
2608                    },
2609                    WebUIFragment::raw(">"),
2610                    WebUIFragment::component("child-component"),
2611                    WebUIFragment::raw("</child-component>"),
2612                ],
2613            },
2614        );
2615        fragments.insert(
2616            "child-title".to_string(),
2617            FragmentList {
2618                fragments: vec![
2619                    WebUIFragment::raw("Child of "),
2620                    WebUIFragment::signal("title", false),
2621                ],
2622            },
2623        );
2624        fragments.insert(
2625            "child-component".to_string(),
2626            FragmentList {
2627                fragments: vec![
2628                    WebUIFragment::raw("<span>"),
2629                    WebUIFragment::signal("title", false),
2630                    WebUIFragment::raw("</span>"),
2631                ],
2632            },
2633        );
2634        let protocol = WebUIProtocol::new(fragments);
2635        let state = test_json!({"who": "<world>"});
2636        let mut writer = TestWriter::new();
2637        handle(
2638            &protocol,
2639            &state,
2640            &RenderOptions::new("index.html", "/"),
2641            &mut writer,
2642        )
2643        .unwrap();
2644        assert_eq!(
2645            writer.get_content(),
2646            "<parent-component title=\"Hello &lt;world&gt;\"><child-component title=\"Child of Hello &lt;world&gt;\"><span>Child of Hello &lt;world&gt;</span></child-component></parent-component>"
2647        );
2648    }
2649
2650    #[test]
2651    fn test_grandchild_attr_propagation() {
2652        let mut fragments = HashMap::new();
2653        fragments.insert(
2654            "index.html".to_string(),
2655            FragmentList {
2656                fragments: vec![
2657                    WebUIFragment::raw("<parent-component"),
2658                    WebUIFragment {
2659                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2660                            WebUIFragmentAttribute {
2661                                name: "title".into(),
2662                                template: "p-title".into(),
2663                                attr_start: true,
2664                                ..Default::default()
2665                            },
2666                        )),
2667                    },
2668                    WebUIFragment::raw(">"),
2669                    WebUIFragment::component("parent-component"),
2670                    WebUIFragment::raw("</parent-component>"),
2671                ],
2672            },
2673        );
2674        fragments.insert(
2675            "p-title".to_string(),
2676            FragmentList {
2677                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
2678            },
2679        );
2680        fragments.insert(
2681            "parent-component".to_string(),
2682            FragmentList {
2683                fragments: vec![
2684                    WebUIFragment::raw("<child-component"),
2685                    WebUIFragment {
2686                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2687                            WebUIFragmentAttribute {
2688                                name: "title".into(),
2689                                template: "c-title".into(),
2690                                attr_start: true,
2691                                ..Default::default()
2692                            },
2693                        )),
2694                    },
2695                    WebUIFragment::raw(">"),
2696                    WebUIFragment::component("child-component"),
2697                    WebUIFragment::raw("</child-component>"),
2698                ],
2699            },
2700        );
2701        fragments.insert(
2702            "c-title".to_string(),
2703            FragmentList {
2704                fragments: vec![
2705                    WebUIFragment::raw("C("),
2706                    WebUIFragment::signal("title", false),
2707                    WebUIFragment::raw(")-"),
2708                    WebUIFragment::signal("cExtra", false),
2709                ],
2710            },
2711        );
2712        fragments.insert(
2713            "child-component".to_string(),
2714            FragmentList {
2715                fragments: vec![
2716                    WebUIFragment::raw("<grandchild-component"),
2717                    WebUIFragment {
2718                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2719                            WebUIFragmentAttribute {
2720                                name: "title".into(),
2721                                value: "title".into(),
2722                                attr_start: true,
2723                                ..Default::default()
2724                            },
2725                        )),
2726                    },
2727                    WebUIFragment::raw(">"),
2728                    WebUIFragment::component("grandchild-component"),
2729                    WebUIFragment::raw("</grandchild-component>"),
2730                ],
2731            },
2732        );
2733        fragments.insert(
2734            "grandchild-component".to_string(),
2735            FragmentList {
2736                fragments: vec![
2737                    WebUIFragment::raw("<span>"),
2738                    WebUIFragment::signal("title", false),
2739                    WebUIFragment::raw("</span>"),
2740                ],
2741            },
2742        );
2743        let protocol = WebUIProtocol::new(fragments);
2744        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
2745        let mut writer = TestWriter::new();
2746        handle(
2747            &protocol,
2748            &state,
2749            &RenderOptions::new("index.html", "/"),
2750            &mut writer,
2751        )
2752        .unwrap();
2753        assert_eq!(
2754            writer.get_content(),
2755            "<parent-component title=\"P:&lt;p&gt;\"><child-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><grandchild-component title=\"C(P:&lt;p&gt;)-x&amp;y\"><span>C(P:&lt;p&gt;)-x&amp;y</span></grandchild-component></child-component></parent-component>"
2756        );
2757    }
2758
2759    #[test]
2760    fn test_for_loop_component_attr() {
2761        let mut fragments = HashMap::new();
2762        fragments.insert(
2763            "index.html".to_string(),
2764            FragmentList {
2765                fragments: vec![
2766                    WebUIFragment::raw("<parent-component"),
2767                    WebUIFragment {
2768                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2769                            WebUIFragmentAttribute {
2770                                name: "title".into(),
2771                                template: "parent-title-loop".into(),
2772                                attr_start: true,
2773                                ..Default::default()
2774                            },
2775                        )),
2776                    },
2777                    WebUIFragment::raw(">"),
2778                    WebUIFragment::component("parent-component"),
2779                    WebUIFragment::raw("</parent-component>"),
2780                ],
2781            },
2782        );
2783        fragments.insert(
2784            "parent-title-loop".to_string(),
2785            FragmentList {
2786                fragments: vec![
2787                    WebUIFragment::raw("Parent:"),
2788                    WebUIFragment::signal("who", false),
2789                ],
2790            },
2791        );
2792        fragments.insert(
2793            "parent-component".to_string(),
2794            FragmentList {
2795                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
2796            },
2797        );
2798        fragments.insert(
2799            "child-loop".to_string(),
2800            FragmentList {
2801                fragments: vec![
2802                    WebUIFragment::raw("<child-component"),
2803                    WebUIFragment {
2804                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2805                            WebUIFragmentAttribute {
2806                                name: "title".into(),
2807                                template: "child-title-loop".into(),
2808                                attr_start: true,
2809                                ..Default::default()
2810                            },
2811                        )),
2812                    },
2813                    WebUIFragment::raw(">"),
2814                    WebUIFragment::component("child-component"),
2815                    WebUIFragment::raw("</child-component>"),
2816                ],
2817            },
2818        );
2819        fragments.insert(
2820            "child-title-loop".to_string(),
2821            FragmentList {
2822                fragments: vec![
2823                    WebUIFragment::raw("Hi "),
2824                    WebUIFragment::signal("item.name", false),
2825                    WebUIFragment::raw(" / "),
2826                    WebUIFragment::signal("title", false),
2827                ],
2828            },
2829        );
2830        fragments.insert(
2831            "child-component".to_string(),
2832            FragmentList {
2833                fragments: vec![
2834                    WebUIFragment::raw("<span>"),
2835                    WebUIFragment::signal("title", false),
2836                    WebUIFragment::raw("</span>"),
2837                ],
2838            },
2839        );
2840        let protocol = WebUIProtocol::new(fragments);
2841        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
2842        let mut writer = TestWriter::new();
2843        handle(
2844            &protocol,
2845            &state,
2846            &RenderOptions::new("index.html", "/"),
2847            &mut writer,
2848        )
2849        .unwrap();
2850        assert_eq!(
2851            writer.get_content(),
2852            "<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A&lt;1&gt; &#x2F; Parent:Bob\"><span>Hi A&lt;1&gt; &#x2F; Parent:Bob</span></child-component><child-component title=\"Hi B&amp;2 &#x2F; Parent:Bob\"><span>Hi B&amp;2 &#x2F; Parent:Bob</span></child-component></parent-component>"
2853        );
2854    }
2855
2856    #[test]
2857    fn test_multiple_template_attrs() {
2858        let mut fragments = HashMap::new();
2859        fragments.insert(
2860            "index.html".to_string(),
2861            FragmentList {
2862                fragments: vec![
2863                    WebUIFragment::raw("<my-component"),
2864                    WebUIFragment {
2865                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2866                            WebUIFragmentAttribute {
2867                                name: "title".into(),
2868                                template: "attr-title".into(),
2869                                attr_start: true,
2870                                ..Default::default()
2871                            },
2872                        )),
2873                    },
2874                    WebUIFragment {
2875                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2876                            WebUIFragmentAttribute {
2877                                name: "data-title".into(),
2878                                template: "attr-data-title".into(),
2879                                attr_start: false,
2880                                ..Default::default()
2881                            },
2882                        )),
2883                    },
2884                    WebUIFragment {
2885                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2886                            WebUIFragmentAttribute {
2887                                name: "aria-label".into(),
2888                                template: "attr-aria-label".into(),
2889                                attr_start: false,
2890                                ..Default::default()
2891                            },
2892                        )),
2893                    },
2894                    WebUIFragment::raw(">"),
2895                    WebUIFragment::component("my-component"),
2896                    WebUIFragment::raw("</my-component>"),
2897                ],
2898            },
2899        );
2900        fragments.insert(
2901            "attr-title".to_string(),
2902            FragmentList {
2903                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
2904            },
2905        );
2906        fragments.insert(
2907            "attr-data-title".to_string(),
2908            FragmentList {
2909                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
2910            },
2911        );
2912        fragments.insert(
2913            "attr-aria-label".to_string(),
2914            FragmentList {
2915                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
2916            },
2917        );
2918        fragments.insert(
2919            "my-component".to_string(),
2920            FragmentList {
2921                fragments: vec![
2922                    WebUIFragment::raw("<span>"),
2923                    WebUIFragment::signal("title", false),
2924                    WebUIFragment::raw("|"),
2925                    WebUIFragment::signal("dataTitle", false),
2926                    WebUIFragment::raw("|"),
2927                    WebUIFragment::signal("ariaLabel", false),
2928                    WebUIFragment::raw("</span>"),
2929                ],
2930            },
2931        );
2932        let protocol = WebUIProtocol::new(fragments);
2933        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
2934        let mut writer = TestWriter::new();
2935        handle(
2936            &protocol,
2937            &state,
2938            &RenderOptions::new("index.html", "/"),
2939            &mut writer,
2940        )
2941        .unwrap();
2942        assert_eq!(
2943            writer.get_content(),
2944            "<my-component title=\"T:&lt;t&amp;1&gt;\" data-title=\"D:d&lt;2&gt;\" aria-label=\"A:a&amp;3\"><span>T:&lt;t&amp;1&gt;|D:d&lt;2&gt;|A:a&amp;3</span></my-component>"
2945        );
2946    }
2947
2948    #[test]
2949    fn test_attr_priority_over_global() {
2950        let mut fragments = HashMap::new();
2951        fragments.insert(
2952            "index.html".to_string(),
2953            FragmentList {
2954                fragments: vec![
2955                    WebUIFragment::raw("<my-component"),
2956                    WebUIFragment {
2957                        fragment: Some(web_ui_fragment::Fragment::Attribute(
2958                            WebUIFragmentAttribute {
2959                                name: "title".into(),
2960                                value: "Attribute Title".into(),
2961                                raw_value: true,
2962                                attr_start: true,
2963                                ..Default::default()
2964                            },
2965                        )),
2966                    },
2967                    WebUIFragment::raw(">"),
2968                    WebUIFragment::component("my-component"),
2969                    WebUIFragment::raw("</my-component>"),
2970                ],
2971            },
2972        );
2973        fragments.insert(
2974            "my-component".to_string(),
2975            FragmentList {
2976                fragments: vec![
2977                    WebUIFragment::raw("<span>"),
2978                    WebUIFragment::signal("title", false),
2979                    WebUIFragment::raw("</span>"),
2980                ],
2981            },
2982        );
2983        let protocol = WebUIProtocol::new(fragments);
2984        let state = test_json!({"title": "Global Title"});
2985        let mut writer = TestWriter::new();
2986        handle(
2987            &protocol,
2988            &state,
2989            &RenderOptions::new("index.html", "/"),
2990            &mut writer,
2991        )
2992        .unwrap();
2993        assert_eq!(
2994            writer.get_content(),
2995            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
2996        );
2997    }
2998
2999    #[test]
3000    fn test_attr_priority_over_local_and_global() {
3001        let mut fragments = HashMap::new();
3002        fragments.insert(
3003            "index.html".to_string(),
3004            FragmentList {
3005                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
3006            },
3007        );
3008        fragments.insert(
3009            "loop".to_string(),
3010            FragmentList {
3011                fragments: vec![
3012                    WebUIFragment::raw("<my-component"),
3013                    WebUIFragment {
3014                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3015                            WebUIFragmentAttribute {
3016                                name: "title".into(),
3017                                value: "Attribute Title".into(),
3018                                raw_value: true,
3019                                attr_start: true,
3020                                ..Default::default()
3021                            },
3022                        )),
3023                    },
3024                    WebUIFragment::raw(">"),
3025                    WebUIFragment::component("my-component"),
3026                    WebUIFragment::raw("</my-component>"),
3027                ],
3028            },
3029        );
3030        fragments.insert(
3031            "my-component".to_string(),
3032            FragmentList {
3033                fragments: vec![
3034                    WebUIFragment::raw("<span>"),
3035                    WebUIFragment::signal("title", false),
3036                    WebUIFragment::raw("</span>"),
3037                ],
3038            },
3039        );
3040        let protocol = WebUIProtocol::new(fragments);
3041        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
3042        let mut writer = TestWriter::new();
3043        handle(
3044            &protocol,
3045            &state,
3046            &RenderOptions::new("index.html", "/"),
3047            &mut writer,
3048        )
3049        .unwrap();
3050        assert_eq!(
3051            writer.get_content(),
3052            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
3053        );
3054    }
3055
3056    #[test]
3057    fn test_boolean_attr_first_component_attr() {
3058        let mut fragments = HashMap::new();
3059        fragments.insert(
3060            "index.html".to_string(),
3061            FragmentList {
3062                fragments: vec![
3063                    WebUIFragment::raw("<my-component"),
3064                    WebUIFragment {
3065                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3066                            WebUIFragmentAttribute {
3067                                name: "disabled".into(),
3068                                attr_start: true,
3069                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3070                                ..Default::default()
3071                            },
3072                        )),
3073                    },
3074                    WebUIFragment {
3075                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3076                            WebUIFragmentAttribute {
3077                                name: "label".into(),
3078                                value: "Component Label".into(),
3079                                raw_value: true,
3080                                attr_start: false,
3081                                ..Default::default()
3082                            },
3083                        )),
3084                    },
3085                    WebUIFragment::raw(">"),
3086                    WebUIFragment::component("my-component"),
3087                    WebUIFragment::raw("</my-component>"),
3088                ],
3089            },
3090        );
3091        fragments.insert(
3092            "my-component".to_string(),
3093            FragmentList {
3094                fragments: vec![
3095                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
3096                    WebUIFragment::if_cond(
3097                        ConditionExpr::identifier("disabled"),
3098                        "disabledTemplate",
3099                    ),
3100                    WebUIFragment::raw("<span>"),
3101                    WebUIFragment::signal("label", false),
3102                    WebUIFragment::raw("</span></template>"),
3103                ],
3104            },
3105        );
3106        fragments.insert(
3107            "disabledTemplate".to_string(),
3108            FragmentList {
3109                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
3110            },
3111        );
3112        let protocol = WebUIProtocol::new(fragments);
3113        let state = test_json!({"isDisabled": true});
3114        let mut writer = TestWriter::new();
3115        handle(
3116            &protocol,
3117            &state,
3118            &RenderOptions::new("index.html", "/"),
3119            &mut writer,
3120        )
3121        .unwrap();
3122        assert_eq!(
3123            writer.get_content(),
3124            "<my-component disabled label=\"Component Label\"><template shadowrootmode=\"open\"><div>Disabled</div><span>Component Label</span></template></my-component>"
3125        );
3126    }
3127
3128    #[test]
3129    fn test_hyphenated_attr_camelcase() {
3130        let mut fragments = HashMap::new();
3131        fragments.insert(
3132            "index.html".to_string(),
3133            FragmentList {
3134                fragments: vec![
3135                    WebUIFragment::raw("<my-component"),
3136                    WebUIFragment {
3137                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3138                            WebUIFragmentAttribute {
3139                                name: "key-hyphen".into(),
3140                                value: "Local Value".into(),
3141                                raw_value: true,
3142                                attr_start: true,
3143                                ..Default::default()
3144                            },
3145                        )),
3146                    },
3147                    WebUIFragment::raw(">"),
3148                    WebUIFragment::component("my-component"),
3149                    WebUIFragment::raw("</my-component>"),
3150                ],
3151            },
3152        );
3153        fragments.insert(
3154            "my-component".to_string(),
3155            FragmentList {
3156                fragments: vec![
3157                    WebUIFragment::raw("<span>"),
3158                    WebUIFragment::signal("keyHyphen", false),
3159                    WebUIFragment::raw("</span>"),
3160                ],
3161            },
3162        );
3163        let protocol = WebUIProtocol::new(fragments);
3164        let state = test_json!({"keyHyphen": "Global Value"});
3165        let mut writer = TestWriter::new();
3166        handle(
3167            &protocol,
3168            &state,
3169            &RenderOptions::new("index.html", "/"),
3170            &mut writer,
3171        )
3172        .unwrap();
3173        assert_eq!(
3174            writer.get_content(),
3175            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
3176        );
3177    }
3178
3179    #[test]
3180    fn test_skipped_component_attrs() {
3181        // Skipped attributes: class, style, role, data-*, aria-*
3182        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
3183        // These render on the HTML element but are NOT passed into component attribute state.
3184        let mut fragments = HashMap::new();
3185        fragments.insert(
3186            "index.html".to_string(),
3187            FragmentList {
3188                fragments: vec![
3189                    WebUIFragment::raw("<test-component"),
3190                    // Skipped: class
3191                    WebUIFragment {
3192                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3193                            WebUIFragmentAttribute {
3194                                name: "class".into(),
3195                                value: "skippedClass".into(),
3196                                attr_start: true,
3197                                attr_skip: true,
3198                                ..Default::default()
3199                            },
3200                        )),
3201                    },
3202                    // Skipped: style
3203                    WebUIFragment {
3204                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3205                            WebUIFragmentAttribute {
3206                                name: "style".into(),
3207                                value: "skippedStyle".into(),
3208                                attr_skip: true,
3209                                ..Default::default()
3210                            },
3211                        )),
3212                    },
3213                    // Skipped: role
3214                    WebUIFragment {
3215                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3216                            WebUIFragmentAttribute {
3217                                name: "role".into(),
3218                                value: "skippedRole".into(),
3219                                attr_skip: true,
3220                                ..Default::default()
3221                            },
3222                        )),
3223                    },
3224                    // Skipped: data-testid (data-* prefix)
3225                    WebUIFragment {
3226                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3227                            WebUIFragmentAttribute {
3228                                name: "data-testid".into(),
3229                                value: "skippedDataTestid".into(),
3230                                attr_skip: true,
3231                                ..Default::default()
3232                            },
3233                        )),
3234                    },
3235                    // Skipped: aria-label (aria-* prefix)
3236                    WebUIFragment {
3237                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3238                            WebUIFragmentAttribute {
3239                                name: "aria-label".into(),
3240                                value: "skippedAriaLabel".into(),
3241                                attr_skip: true,
3242                                ..Default::default()
3243                            },
3244                        )),
3245                    },
3246                    // NOT skipped: title
3247                    WebUIFragment {
3248                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3249                            WebUIFragmentAttribute {
3250                                name: "title".into(),
3251                                value: "title".into(),
3252                                ..Default::default()
3253                            },
3254                        )),
3255                    },
3256                    WebUIFragment::raw(">"),
3257                    WebUIFragment::component("test-component"),
3258                    WebUIFragment::raw("</test-component>"),
3259                ],
3260            },
3261        );
3262        fragments.insert(
3263            "test-component".to_string(),
3264            FragmentList {
3265                fragments: vec![
3266                    WebUIFragment::raw("<template shadowrootmode=\"open\"><span>"),
3267                    WebUIFragment::signal("title", false),
3268                    WebUIFragment::raw("-"),
3269                    WebUIFragment::signal("class", false),
3270                    WebUIFragment::raw("-"),
3271                    WebUIFragment::signal("style", false),
3272                    WebUIFragment::raw("-"),
3273                    WebUIFragment::signal("role", false),
3274                    WebUIFragment::raw("-"),
3275                    WebUIFragment::signal("dataTestid", false),
3276                    WebUIFragment::raw("-"),
3277                    WebUIFragment::signal("ariaLabel", false),
3278                    WebUIFragment::raw("</span></template>"),
3279                ],
3280            },
3281        );
3282        let protocol = WebUIProtocol::new(fragments);
3283        let state = test_json!({
3284            "title": "Hello",
3285            "skippedClass": "my-class",
3286            "skippedStyle": "color:red",
3287            "skippedRole": "button",
3288            "skippedDataTestid": "test-id",
3289            "skippedAriaLabel": "label-text"
3290        });
3291        let mut writer = TestWriter::new();
3292        handle(
3293            &protocol,
3294            &state,
3295            &RenderOptions::new("index.html", "/"),
3296            &mut writer,
3297        )
3298        .unwrap();
3299        // Skipped attrs render on the element but their values are NOT accessible inside the component.
3300        // The component's signals for skipped attrs resolve to empty strings.
3301        // Only "title" (non-skipped) is accessible.
3302        assert_eq!(
3303            writer.get_content(),
3304            "<test-component class=\"my-class\" style=\"color:red\" role=\"button\" data-testid=\"test-id\" aria-label=\"label-text\" title=\"Hello\"><template shadowrootmode=\"open\"><span>Hello-----</span></template></test-component>"
3305        );
3306    }
3307
3308    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
3309
3310    #[test]
3311    fn test_attr_inherit_parent_to_child() {
3312        let mut fragments = HashMap::new();
3313        fragments.insert(
3314            "index.html".to_string(),
3315            FragmentList {
3316                fragments: vec![
3317                    WebUIFragment::raw("<parent-component"),
3318                    WebUIFragment {
3319                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3320                            WebUIFragmentAttribute {
3321                                name: "title".into(),
3322                                value: "Parent Title".into(),
3323                                raw_value: true,
3324                                attr_start: true,
3325                                ..Default::default()
3326                            },
3327                        )),
3328                    },
3329                    WebUIFragment::raw(">"),
3330                    WebUIFragment::component("parent-component"),
3331                    WebUIFragment::raw("</parent-component>"),
3332                ],
3333            },
3334        );
3335        fragments.insert(
3336            "parent-component".to_string(),
3337            FragmentList {
3338                fragments: vec![
3339                    WebUIFragment::raw("<h1>"),
3340                    WebUIFragment::signal("title", false),
3341                    WebUIFragment::raw("</h1><child-component"),
3342                    WebUIFragment {
3343                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3344                            WebUIFragmentAttribute {
3345                                name: "title".into(),
3346                                value: "title".into(),
3347                                attr_start: true,
3348                                ..Default::default()
3349                            },
3350                        )),
3351                    },
3352                    WebUIFragment::raw(">"),
3353                    WebUIFragment::component("child-component"),
3354                    WebUIFragment::raw("</child-component>"),
3355                ],
3356            },
3357        );
3358        fragments.insert(
3359            "child-component".to_string(),
3360            FragmentList {
3361                fragments: vec![
3362                    WebUIFragment::raw("<h2>"),
3363                    WebUIFragment::signal("title", false),
3364                    WebUIFragment::raw("</h2>"),
3365                ],
3366            },
3367        );
3368        let protocol = WebUIProtocol::new(fragments);
3369        let state = test_json!({});
3370        let mut writer = TestWriter::new();
3371        handle(
3372            &protocol,
3373            &state,
3374            &RenderOptions::new("index.html", "/"),
3375            &mut writer,
3376        )
3377        .unwrap();
3378        assert_eq!(
3379            writer.get_content(),
3380            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
3381        );
3382    }
3383
3384    #[test]
3385    fn test_attr_inherit_deep() {
3386        let mut fragments = HashMap::new();
3387        fragments.insert(
3388            "index.html".to_string(),
3389            FragmentList {
3390                fragments: vec![
3391                    WebUIFragment::raw("<parent-component"),
3392                    WebUIFragment {
3393                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3394                            WebUIFragmentAttribute {
3395                                name: "title".into(),
3396                                value: "Parent Title".into(),
3397                                raw_value: true,
3398                                attr_start: true,
3399                                ..Default::default()
3400                            },
3401                        )),
3402                    },
3403                    WebUIFragment::raw(">"),
3404                    WebUIFragment::component("parent-component"),
3405                    WebUIFragment::raw("</parent-component>"),
3406                ],
3407            },
3408        );
3409        fragments.insert(
3410            "parent-component".to_string(),
3411            FragmentList {
3412                fragments: vec![
3413                    WebUIFragment::raw("<child-component"),
3414                    WebUIFragment {
3415                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3416                            WebUIFragmentAttribute {
3417                                name: "title".into(),
3418                                value: "Child Title".into(),
3419                                raw_value: true,
3420                                attr_start: true,
3421                                ..Default::default()
3422                            },
3423                        )),
3424                    },
3425                    WebUIFragment::raw(">"),
3426                    WebUIFragment::component("child-component"),
3427                    WebUIFragment::raw("</child-component>"),
3428                ],
3429            },
3430        );
3431        fragments.insert(
3432            "child-component".to_string(),
3433            FragmentList {
3434                fragments: vec![
3435                    WebUIFragment::raw("<grandchild-component"),
3436                    WebUIFragment {
3437                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3438                            WebUIFragmentAttribute {
3439                                name: "title".into(),
3440                                value: "title".into(),
3441                                attr_start: true,
3442                                ..Default::default()
3443                            },
3444                        )),
3445                    },
3446                    WebUIFragment::raw(">"),
3447                    WebUIFragment::component("grandchild-component"),
3448                    WebUIFragment::raw("</grandchild-component>"),
3449                ],
3450            },
3451        );
3452        fragments.insert(
3453            "grandchild-component".to_string(),
3454            FragmentList {
3455                fragments: vec![
3456                    WebUIFragment::raw("<h3>"),
3457                    WebUIFragment::signal("title", false),
3458                    WebUIFragment::raw("</h3>"),
3459                ],
3460            },
3461        );
3462        let protocol = WebUIProtocol::new(fragments);
3463        let state = test_json!({});
3464        let mut writer = TestWriter::new();
3465        handle(
3466            &protocol,
3467            &state,
3468            &RenderOptions::new("index.html", "/"),
3469            &mut writer,
3470        )
3471        .unwrap();
3472        assert_eq!(
3473            writer.get_content(),
3474            "<parent-component title=\"Parent Title\"><child-component title=\"Child Title\"><grandchild-component title=\"Child Title\"><h3>Child Title</h3></grandchild-component></child-component></parent-component>"
3475        );
3476    }
3477
3478    #[test]
3479    fn test_complex_attr_access() {
3480        let mut fragments = HashMap::new();
3481        fragments.insert(
3482            "index.html".to_string(),
3483            FragmentList {
3484                fragments: vec![
3485                    WebUIFragment::raw("<my-component"),
3486                    WebUIFragment {
3487                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3488                            WebUIFragmentAttribute {
3489                                name: ":item".into(),
3490                                value: "complexItem".into(),
3491                                attr_start: true,
3492                                complex: true,
3493                                ..Default::default()
3494                            },
3495                        )),
3496                    },
3497                    WebUIFragment::raw(">"),
3498                    WebUIFragment::component("my-component"),
3499                    WebUIFragment::raw("</my-component>"),
3500                ],
3501            },
3502        );
3503        fragments.insert(
3504            "my-component".to_string(),
3505            FragmentList {
3506                fragments: vec![
3507                    WebUIFragment::raw("<span>"),
3508                    WebUIFragment::signal("item.foo", false),
3509                    WebUIFragment::raw("</span><p>"),
3510                    WebUIFragment::signal("item.bar", false),
3511                    WebUIFragment::raw("</p>"),
3512                ],
3513            },
3514        );
3515        let protocol = WebUIProtocol::new(fragments);
3516        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
3517        let mut writer = TestWriter::new();
3518        handle(
3519            &protocol,
3520            &state,
3521            &RenderOptions::new("index.html", "/"),
3522            &mut writer,
3523        )
3524        .unwrap();
3525        assert_eq!(
3526            writer.get_content(),
3527            "<my-component><span>1</span><p>true</p></my-component>"
3528        );
3529    }
3530
3531    #[test]
3532    fn test_complex_attr_for_loop() {
3533        let mut fragments = HashMap::new();
3534        fragments.insert(
3535            "index.html".to_string(),
3536            FragmentList {
3537                fragments: vec![WebUIFragment::for_loop(
3538                    "item",
3539                    "list.items",
3540                    "listTemplate",
3541                )],
3542            },
3543        );
3544        fragments.insert(
3545            "listTemplate".to_string(),
3546            FragmentList {
3547                fragments: vec![
3548                    WebUIFragment {
3549                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3550                            WebUIFragmentAttribute {
3551                                name: ":item".into(),
3552                                value: "item".into(),
3553                                attr_start: true,
3554                                complex: true,
3555                                ..Default::default()
3556                            },
3557                        )),
3558                    },
3559                    WebUIFragment::component("item_component"),
3560                ],
3561            },
3562        );
3563        fragments.insert(
3564            "item_component".to_string(),
3565            FragmentList {
3566                fragments: vec![
3567                    WebUIFragment::raw("<span>"),
3568                    WebUIFragment::signal("item.name", false),
3569                    WebUIFragment::raw("</span>"),
3570                ],
3571            },
3572        );
3573        let protocol = WebUIProtocol::new(fragments);
3574        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
3575        let mut writer = TestWriter::new();
3576        handle(
3577            &protocol,
3578            &state,
3579            &RenderOptions::new("index.html", "/"),
3580            &mut writer,
3581        )
3582        .unwrap();
3583        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
3584    }
3585
3586    #[test]
3587    fn test_complex_attr_nested_for() {
3588        let mut fragments = HashMap::new();
3589        fragments.insert(
3590            "index.html".to_string(),
3591            FragmentList {
3592                fragments: vec![WebUIFragment::for_loop(
3593                    "outer",
3594                    "data.outer",
3595                    "outerTemplate",
3596                )],
3597            },
3598        );
3599        fragments.insert(
3600            "outerTemplate".to_string(),
3601            FragmentList {
3602                fragments: vec![WebUIFragment::for_loop(
3603                    "middle",
3604                    "outer.middle",
3605                    "middleTemplate",
3606                )],
3607            },
3608        );
3609        fragments.insert(
3610            "middleTemplate".to_string(),
3611            FragmentList {
3612                fragments: vec![WebUIFragment::for_loop(
3613                    "inner",
3614                    "middle.inner",
3615                    "innerTemplate",
3616                )],
3617            },
3618        );
3619        fragments.insert(
3620            "innerTemplate".to_string(),
3621            FragmentList {
3622                fragments: vec![
3623                    WebUIFragment::raw("<card"),
3624                    WebUIFragment {
3625                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3626                            WebUIFragmentAttribute {
3627                                name: ":outer".into(),
3628                                value: "outer".into(),
3629                                attr_start: true,
3630                                complex: true,
3631                                ..Default::default()
3632                            },
3633                        )),
3634                    },
3635                    WebUIFragment {
3636                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3637                            WebUIFragmentAttribute {
3638                                name: ":middle".into(),
3639                                value: "middle".into(),
3640                                attr_start: false,
3641                                complex: true,
3642                                ..Default::default()
3643                            },
3644                        )),
3645                    },
3646                    WebUIFragment {
3647                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3648                            WebUIFragmentAttribute {
3649                                name: ":inner".into(),
3650                                value: "inner".into(),
3651                                attr_start: false,
3652                                complex: true,
3653                                ..Default::default()
3654                            },
3655                        )),
3656                    },
3657                    WebUIFragment::raw(">"),
3658                    WebUIFragment::component("card_component"),
3659                    WebUIFragment::raw("</card>"),
3660                ],
3661            },
3662        );
3663        fragments.insert(
3664            "card_component".to_string(),
3665            FragmentList {
3666                fragments: vec![
3667                    WebUIFragment::raw("<p>"),
3668                    WebUIFragment::signal("outer.label", false),
3669                    WebUIFragment::raw(" / "),
3670                    WebUIFragment::signal("middle.label", false),
3671                    WebUIFragment::raw(" / "),
3672                    WebUIFragment::signal("inner.label", false),
3673                    WebUIFragment::raw("</p>"),
3674                ],
3675            },
3676        );
3677        let protocol = WebUIProtocol::new(fragments);
3678        let state = test_json!({"data": {"outer": [
3679            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
3680            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
3681        ]}});
3682        let mut writer = TestWriter::new();
3683        handle(
3684            &protocol,
3685            &state,
3686            &RenderOptions::new("index.html", "/"),
3687            &mut writer,
3688        )
3689        .unwrap();
3690        assert_eq!(
3691            writer.get_content(),
3692            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
3693        );
3694    }
3695
3696    // ── GROUP 8: Boolean Component State ──────────────────────────────
3697
3698    #[test]
3699    fn test_bool_component_state_true() {
3700        let mut fragments = HashMap::new();
3701        fragments.insert(
3702            "index.html".to_string(),
3703            FragmentList {
3704                fragments: vec![
3705                    WebUIFragment::raw("<my-component"),
3706                    WebUIFragment {
3707                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3708                            WebUIFragmentAttribute {
3709                                name: "disabled".into(),
3710                                attr_start: true,
3711                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3712                                ..Default::default()
3713                            },
3714                        )),
3715                    },
3716                    WebUIFragment::raw(">"),
3717                    WebUIFragment::component("my-component"),
3718                    WebUIFragment::raw("</my-component>"),
3719                ],
3720            },
3721        );
3722        fragments.insert(
3723            "my-component".to_string(),
3724            FragmentList {
3725                fragments: vec![
3726                    WebUIFragment::if_cond(
3727                        ConditionExpr::identifier("disabled"),
3728                        "disabledTemplate",
3729                    ),
3730                    WebUIFragment::if_cond(
3731                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
3732                        "enabledTemplate",
3733                    ),
3734                ],
3735            },
3736        );
3737        fragments.insert(
3738            "disabledTemplate".to_string(),
3739            FragmentList {
3740                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
3741            },
3742        );
3743        fragments.insert(
3744            "enabledTemplate".to_string(),
3745            FragmentList {
3746                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
3747            },
3748        );
3749        let protocol = WebUIProtocol::new(fragments);
3750        let state = test_json!({"isDisabled": true});
3751        let mut writer = TestWriter::new();
3752        handle(
3753            &protocol,
3754            &state,
3755            &RenderOptions::new("index.html", "/"),
3756            &mut writer,
3757        )
3758        .unwrap();
3759        assert_eq!(
3760            writer.get_content(),
3761            "<my-component disabled><span>Disabled</span></my-component>"
3762        );
3763    }
3764
3765    #[test]
3766    fn test_bool_component_state_false() {
3767        let mut fragments = HashMap::new();
3768        fragments.insert(
3769            "index.html".to_string(),
3770            FragmentList {
3771                fragments: vec![
3772                    WebUIFragment::raw("<my-component"),
3773                    WebUIFragment {
3774                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3775                            WebUIFragmentAttribute {
3776                                name: "disabled".into(),
3777                                attr_start: true,
3778                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3779                                ..Default::default()
3780                            },
3781                        )),
3782                    },
3783                    WebUIFragment::raw(">"),
3784                    WebUIFragment::component("my-component"),
3785                    WebUIFragment::raw("</my-component>"),
3786                ],
3787            },
3788        );
3789        fragments.insert(
3790            "my-component".to_string(),
3791            FragmentList {
3792                fragments: vec![
3793                    WebUIFragment::if_cond(
3794                        ConditionExpr::identifier("disabled"),
3795                        "disabledTemplate",
3796                    ),
3797                    WebUIFragment::if_cond(
3798                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
3799                        "enabledTemplate",
3800                    ),
3801                ],
3802            },
3803        );
3804        fragments.insert(
3805            "disabledTemplate".to_string(),
3806            FragmentList {
3807                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
3808            },
3809        );
3810        fragments.insert(
3811            "enabledTemplate".to_string(),
3812            FragmentList {
3813                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
3814            },
3815        );
3816        let protocol = WebUIProtocol::new(fragments);
3817        let state = test_json!({"isDisabled": false});
3818        let mut writer = TestWriter::new();
3819        handle(
3820            &protocol,
3821            &state,
3822            &RenderOptions::new("index.html", "/"),
3823            &mut writer,
3824        )
3825        .unwrap();
3826        assert_eq!(
3827            writer.get_content(),
3828            "<my-component><span>Enabled</span></my-component>"
3829        );
3830    }
3831
3832    #[test]
3833    fn test_bool_component_state_forward() {
3834        let mut fragments = HashMap::new();
3835        fragments.insert(
3836            "index.html".to_string(),
3837            FragmentList {
3838                fragments: vec![
3839                    WebUIFragment::raw("<parent-component"),
3840                    WebUIFragment {
3841                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3842                            WebUIFragmentAttribute {
3843                                name: "disabled".into(),
3844                                attr_start: true,
3845                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3846                                ..Default::default()
3847                            },
3848                        )),
3849                    },
3850                    WebUIFragment::raw(">"),
3851                    WebUIFragment::component("parent-component"),
3852                    WebUIFragment::raw("</parent-component>"),
3853                ],
3854            },
3855        );
3856        fragments.insert(
3857            "parent-component".to_string(),
3858            FragmentList {
3859                fragments: vec![
3860                    WebUIFragment::if_cond(
3861                        ConditionExpr::identifier("disabled"),
3862                        "parentDisabledTemplate",
3863                    ),
3864                    WebUIFragment::raw("<child-component"),
3865                    WebUIFragment {
3866                        fragment: Some(web_ui_fragment::Fragment::Attribute(
3867                            WebUIFragmentAttribute {
3868                                name: "disabled".into(),
3869                                attr_start: true,
3870                                condition_tree: Some(ConditionExpr::identifier("disabled")),
3871                                ..Default::default()
3872                            },
3873                        )),
3874                    },
3875                    WebUIFragment::raw(">"),
3876                    WebUIFragment::component("child-component"),
3877                    WebUIFragment::raw("</child-component>"),
3878                ],
3879            },
3880        );
3881        fragments.insert(
3882            "parentDisabledTemplate".to_string(),
3883            FragmentList {
3884                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
3885            },
3886        );
3887        fragments.insert(
3888            "child-component".to_string(),
3889            FragmentList {
3890                fragments: vec![
3891                    WebUIFragment::if_cond(
3892                        ConditionExpr::identifier("disabled"),
3893                        "childDisabledTemplate",
3894                    ),
3895                    WebUIFragment::if_cond(
3896                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
3897                        "childEnabledTemplate",
3898                    ),
3899                ],
3900            },
3901        );
3902        fragments.insert(
3903            "childDisabledTemplate".to_string(),
3904            FragmentList {
3905                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
3906            },
3907        );
3908        fragments.insert(
3909            "childEnabledTemplate".to_string(),
3910            FragmentList {
3911                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
3912            },
3913        );
3914
3915        // Test case 1: isDisabled = true
3916        {
3917            let protocol = WebUIProtocol::new(fragments.clone());
3918            let state = test_json!({"isDisabled": true});
3919            let mut writer = TestWriter::new();
3920            handle(
3921                &protocol,
3922                &state,
3923                &RenderOptions::new("index.html", "/"),
3924                &mut writer,
3925            )
3926            .unwrap();
3927            assert_eq!(
3928                writer.get_content(),
3929                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
3930            );
3931        }
3932
3933        // Test case 2: isDisabled = false
3934        {
3935            let protocol = WebUIProtocol::new(fragments.clone());
3936            let state = test_json!({"isDisabled": false});
3937            let mut writer = TestWriter::new();
3938            handle(
3939                &protocol,
3940                &state,
3941                &RenderOptions::new("index.html", "/"),
3942                &mut writer,
3943            )
3944            .unwrap();
3945            assert_eq!(
3946                writer.get_content(),
3947                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
3948            );
3949        }
3950    }
3951
3952    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
3953
3954    // TODO: test_hydration – requires FastHydrationPlugin integration; see plugin/fast.rs
3955
3956    // ── Component tests ──────────────────────────────────────────────
3957
3958    #[test]
3959    fn test_component_with_template() {
3960        let mut fragments = HashMap::new();
3961        fragments.insert(
3962            "index.html".to_string(),
3963            FragmentList {
3964                fragments: vec![
3965                    WebUIFragment::raw("<custom-element>"),
3966                    WebUIFragment::component("custom-element"),
3967                    WebUIFragment::raw("</custom-element>"),
3968                ],
3969            },
3970        );
3971        fragments.insert(
3972            "custom-element".to_string(),
3973            FragmentList {
3974                fragments: vec![WebUIFragment::raw(
3975                    "<template shadowrootmode=\"open\"><div>Custom Element</div></template>",
3976                )],
3977            },
3978        );
3979        let protocol = WebUIProtocol::new(fragments);
3980        let state = test_json!({});
3981        let mut writer = TestWriter::new();
3982        handle(
3983            &protocol,
3984            &state,
3985            &RenderOptions::new("index.html", "/"),
3986            &mut writer,
3987        )
3988        .unwrap();
3989        assert_eq!(
3990            writer.get_content(),
3991            "<custom-element><template shadowrootmode=\"open\"><div>Custom Element</div></template></custom-element>"
3992        );
3993        assert!(writer.is_ended());
3994    }
3995
3996    #[test]
3997    fn test_component_with_slots() {
3998        let mut fragments = HashMap::new();
3999        fragments.insert(
4000            "index.html".to_string(),
4001            FragmentList {
4002                fragments: vec![
4003                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
4004                    WebUIFragment::component("custom-element"),
4005                    WebUIFragment::raw("Hello World</custom-element>"),
4006                ],
4007            },
4008        );
4009        fragments.insert(
4010            "custom-element".to_string(),
4011            FragmentList {
4012                fragments: vec![WebUIFragment::raw(
4013                    "<template shadowrootmode=\"open\"><slot></slot></template>",
4014                )],
4015            },
4016        );
4017        let protocol = WebUIProtocol::new(fragments);
4018        let state = test_json!({});
4019        let mut writer = TestWriter::new();
4020        handle(
4021            &protocol,
4022            &state,
4023            &RenderOptions::new("index.html", "/"),
4024            &mut writer,
4025        )
4026        .unwrap();
4027        assert_eq!(
4028            writer.get_content(),
4029            "<custom-element appearance=\"subtle\"><template shadowrootmode=\"open\"><slot></slot></template>Hello World</custom-element>"
4030        );
4031        assert!(writer.is_ended());
4032    }
4033
4034    #[test]
4035    fn test_multiple_nested_components() {
4036        let mut fragments = HashMap::new();
4037        fragments.insert(
4038            "index.html".to_string(),
4039            FragmentList {
4040                fragments: vec![
4041                    WebUIFragment::raw("<div>"),
4042                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
4043                    WebUIFragment::raw("</div>"),
4044                ],
4045            },
4046        );
4047        fragments.insert(
4048            "custom-button".to_string(),
4049            FragmentList {
4050                fragments: vec![WebUIFragment::raw(
4051                    "<template shadowrootmode=\"open\"><slot></slot></template>",
4052                )],
4053            },
4054        );
4055        fragments.insert(
4056            "custom-element".to_string(),
4057            FragmentList {
4058                fragments: vec![
4059                    WebUIFragment::raw("<template shadowrootmode=\"open\"><custom-child>"),
4060                    WebUIFragment::component("custom-child"),
4061                    WebUIFragment::raw("</custom-child><slot></slot></template>"),
4062                ],
4063            },
4064        );
4065        fragments.insert(
4066            "custom-child".to_string(),
4067            FragmentList {
4068                fragments: vec![WebUIFragment::raw(
4069                    "<template shadowrootmode=\"open\"><h1>Hello World!</h1></template>",
4070                )],
4071            },
4072        );
4073        fragments.insert(
4074            "templateRepeat".to_string(),
4075            FragmentList {
4076                fragments: vec![
4077                    WebUIFragment::raw("<custom-element>"),
4078                    WebUIFragment::component("custom-element"),
4079                    WebUIFragment::raw("<custom-button>"),
4080                    WebUIFragment::component("custom-button"),
4081                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
4082                ],
4083            },
4084        );
4085        let protocol = WebUIProtocol::new(fragments);
4086        let state = test_json!({"items": [{"name": "Item1"}]});
4087        let mut writer = TestWriter::new();
4088        handle(
4089            &protocol,
4090            &state,
4091            &RenderOptions::new("index.html", "/"),
4092            &mut writer,
4093        )
4094        .unwrap();
4095        assert_eq!(
4096            writer.get_content(),
4097            "<div><custom-element><template shadowrootmode=\"open\"><custom-child><template shadowrootmode=\"open\"><h1>Hello World!</h1></template></custom-child><slot></slot></template><custom-button><template shadowrootmode=\"open\"><slot></slot></template>Ok</custom-button></custom-element></div>"
4098        );
4099        assert!(writer.is_ended());
4100    }
4101
4102    // ── Conditional tests ────────────────────────────────────────────
4103
4104    #[test]
4105    fn test_if_with_binary_expression() {
4106        let mut fragments = HashMap::new();
4107        fragments.insert(
4108            "index.html".to_string(),
4109            FragmentList {
4110                fragments: vec![
4111                    WebUIFragment::raw("<div>"),
4112                    WebUIFragment::if_cond(
4113                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
4114                        "if-1",
4115                    ),
4116                    WebUIFragment::raw("</div>"),
4117                ],
4118            },
4119        );
4120        fragments.insert(
4121            "if-1".to_string(),
4122            FragmentList {
4123                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
4124            },
4125        );
4126        let protocol = WebUIProtocol::new(fragments);
4127
4128        // True case: x = 10 > 5
4129        let state_true = test_json!({"x": 10});
4130        let mut writer_true = TestWriter::new();
4131        handle(
4132            &protocol,
4133            &state_true,
4134            &RenderOptions::new("index.html", "/"),
4135            &mut writer_true,
4136        )
4137        .unwrap();
4138        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
4139
4140        // False case: x = 1 <= 5
4141        let state_false = test_json!({"x": 1});
4142        let mut writer_false = TestWriter::new();
4143        handle(
4144            &protocol,
4145            &state_false,
4146            &RenderOptions::new("index.html", "/"),
4147            &mut writer_false,
4148        )
4149        .unwrap();
4150        assert_eq!(writer_false.get_content(), "<div></div>");
4151    }
4152
4153    #[test]
4154    fn test_for_if_overlapping_local_state() {
4155        let mut fragments = HashMap::new();
4156        fragments.insert(
4157            "index.html".to_string(),
4158            FragmentList {
4159                fragments: vec![
4160                    WebUIFragment::raw("<div>"),
4161                    WebUIFragment::for_loop("item", "items", "template1"),
4162                    WebUIFragment::raw("</div>"),
4163                ],
4164            },
4165        );
4166        fragments.insert(
4167            "template1".to_string(),
4168            FragmentList {
4169                fragments: vec![
4170                    WebUIFragment::raw("<div>"),
4171                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4172                    WebUIFragment::raw("</div>"),
4173                ],
4174            },
4175        );
4176        fragments.insert(
4177            "ifBlock".to_string(),
4178            FragmentList {
4179                fragments: vec![
4180                    WebUIFragment::raw("<span>"),
4181                    WebUIFragment::signal("item.label", false),
4182                    WebUIFragment::raw("</span>"),
4183                ],
4184            },
4185        );
4186        let protocol = WebUIProtocol::new(fragments);
4187        let state = test_json!({
4188            "flag": false,
4189            "items": [
4190                {"label": "A", "flag": true},
4191                {"label": "B", "flag": false},
4192                {"label": "C", "flag": true}
4193            ]
4194        });
4195        let mut writer = TestWriter::new();
4196        handle(
4197            &protocol,
4198            &state,
4199            &RenderOptions::new("index.html", "/"),
4200            &mut writer,
4201        )
4202        .unwrap();
4203        assert_eq!(
4204            writer.get_content(),
4205            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
4206        );
4207    }
4208
4209    #[test]
4210    fn test_for_if_global_flag_no_effect() {
4211        let mut fragments = HashMap::new();
4212        fragments.insert(
4213            "index.html".to_string(),
4214            FragmentList {
4215                fragments: vec![
4216                    WebUIFragment::raw("<div>"),
4217                    WebUIFragment::for_loop("item", "items", "template1"),
4218                    WebUIFragment::raw("</div>"),
4219                ],
4220            },
4221        );
4222        fragments.insert(
4223            "template1".to_string(),
4224            FragmentList {
4225                fragments: vec![
4226                    WebUIFragment::raw("<div>"),
4227                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4228                    WebUIFragment::raw("</div>"),
4229                ],
4230            },
4231        );
4232        fragments.insert(
4233            "ifBlock".to_string(),
4234            FragmentList {
4235                fragments: vec![
4236                    WebUIFragment::raw("<span>"),
4237                    WebUIFragment::signal("item.label", false),
4238                    WebUIFragment::raw("</span>"),
4239                ],
4240            },
4241        );
4242        let protocol = WebUIProtocol::new(fragments);
4243        let state = test_json!({
4244            "item": {"flag": true},
4245            "items": [
4246                {"label": "A", "flag": false},
4247                {"label": "B", "flag": true},
4248                {"label": "C", "flag": false}
4249            ]
4250        });
4251        let mut writer = TestWriter::new();
4252        handle(
4253            &protocol,
4254            &state,
4255            &RenderOptions::new("index.html", "/"),
4256            &mut writer,
4257        )
4258        .unwrap();
4259        assert_eq!(
4260            writer.get_content(),
4261            "<div><div></div><div><span>B</span></div><div></div></div>"
4262        );
4263    }
4264
4265    // ── Recursive template test ──────────────────────────────────────
4266
4267    #[test]
4268    fn test_recursive_template_refs() {
4269        let mut fragments = HashMap::new();
4270        fragments.insert(
4271            "index.html".to_string(),
4272            FragmentList {
4273                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
4274            },
4275        );
4276        fragments.insert(
4277            "static".to_string(),
4278            FragmentList {
4279                fragments: vec![
4280                    WebUIFragment::raw("<div expanded=\""),
4281                    WebUIFragment::signal("item.expanded", false),
4282                    WebUIFragment::raw("\" class=\""),
4283                    WebUIFragment::signal("testScenario", false),
4284                    WebUIFragment::raw("\"><span>"),
4285                    WebUIFragment::signal("item.name", false),
4286                    WebUIFragment::raw("</span>"),
4287                    WebUIFragment::for_loop("item", "item.children", "static"),
4288                    WebUIFragment::raw("</div>"),
4289                ],
4290            },
4291        );
4292        let protocol = WebUIProtocol::new(fragments);
4293        let state = test_json!({
4294            "testScenario": "RecursiveTemplatesWithGlobalState",
4295            "items": [
4296                {"name": "A", "expanded": "false", "children": []},
4297                {"name": "B", "expanded": "true", "children": [
4298                    {"name": "C", "expanded": "false"},
4299                    {"name": "D", "expanded": "false"}
4300                ]},
4301                {"name": "E", "expanded": "false"}
4302            ]
4303        });
4304        let mut writer = TestWriter::new();
4305        handle(
4306            &protocol,
4307            &state,
4308            &RenderOptions::new("index.html", "/"),
4309            &mut writer,
4310        )
4311        .unwrap();
4312        assert_eq!(
4313            writer.get_content(),
4314            "<div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>A</span></div><div expanded=\"true\" class=\"RecursiveTemplatesWithGlobalState\"><span>B</span><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>C</span></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>D</span></div></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>E</span></div>"
4315        );
4316    }
4317
4318    // ── Advanced state management tests ──────────────────────────────
4319
4320    #[test]
4321    fn test_component_in_for_no_local_access() {
4322        let mut fragments = HashMap::new();
4323        fragments.insert(
4324            "index.html".to_string(),
4325            FragmentList {
4326                fragments: vec![
4327                    WebUIFragment::raw("<div>"),
4328                    WebUIFragment::for_loop("item", "items", "templateComponent"),
4329                    WebUIFragment::raw("</div>"),
4330                ],
4331            },
4332        );
4333        fragments.insert(
4334            "templateComponent".to_string(),
4335            FragmentList {
4336                fragments: vec![
4337                    WebUIFragment::raw("<component-tag>"),
4338                    WebUIFragment::component("my-component"),
4339                    WebUIFragment::raw("</component-tag>"),
4340                ],
4341            },
4342        );
4343        fragments.insert(
4344            "my-component".to_string(),
4345            FragmentList {
4346                fragments: vec![
4347                    WebUIFragment::raw("<span>"),
4348                    WebUIFragment::signal("name", false),
4349                    WebUIFragment::raw("</span>"),
4350                ],
4351            },
4352        );
4353        let protocol = WebUIProtocol::new(fragments);
4354        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
4355        let mut writer = TestWriter::new();
4356        handle(
4357            &protocol,
4358            &state,
4359            &RenderOptions::new("index.html", "/"),
4360            &mut writer,
4361        )
4362        .unwrap();
4363        assert_eq!(
4364            writer.get_content(),
4365            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
4366        );
4367    }
4368
4369    #[test]
4370    fn test_nested_for_hierarchical_state() {
4371        let mut fragments = HashMap::new();
4372        fragments.insert(
4373            "index.html".to_string(),
4374            FragmentList {
4375                fragments: vec![
4376                    WebUIFragment::raw("<div>"),
4377                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4378                    WebUIFragment::raw("</div>"),
4379                ],
4380            },
4381        );
4382        fragments.insert(
4383            "outerTemplate".to_string(),
4384            FragmentList {
4385                fragments: vec![
4386                    WebUIFragment::raw("<section>"),
4387                    WebUIFragment::signal("globalPrefix", false),
4388                    WebUIFragment::signal("outerItem.outerLabel", false),
4389                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
4390                    WebUIFragment::raw("</section>"),
4391                ],
4392            },
4393        );
4394        fragments.insert(
4395            "innerTemplate".to_string(),
4396            FragmentList {
4397                fragments: vec![
4398                    WebUIFragment::raw("<p>"),
4399                    WebUIFragment::signal("globalPrefix", false),
4400                    WebUIFragment::signal("outerItem.outerLabel", false),
4401                    WebUIFragment::raw(": "),
4402                    WebUIFragment::signal("innerItem.innerLabel", false),
4403                    WebUIFragment::raw("</p>"),
4404                ],
4405            },
4406        );
4407        let protocol = WebUIProtocol::new(fragments);
4408        let state = test_json!({
4409            "globalPrefix": "Prefix: ",
4410            "outerItems": [
4411                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
4412                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
4413            ]
4414        });
4415        let mut writer = TestWriter::new();
4416        handle(
4417            &protocol,
4418            &state,
4419            &RenderOptions::new("index.html", "/"),
4420            &mut writer,
4421        )
4422        .unwrap();
4423        assert_eq!(
4424            writer.get_content(),
4425            "<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
4426        );
4427    }
4428
4429    #[test]
4430    fn test_component_in_for_global_only() {
4431        let mut fragments = HashMap::new();
4432        fragments.insert(
4433            "index.html".to_string(),
4434            FragmentList {
4435                fragments: vec![
4436                    WebUIFragment::raw("<div>"),
4437                    WebUIFragment::for_loop("item", "items", "templateComponent"),
4438                    WebUIFragment::raw("</div>"),
4439                ],
4440            },
4441        );
4442        fragments.insert(
4443            "templateComponent".to_string(),
4444            FragmentList {
4445                fragments: vec![
4446                    WebUIFragment::raw("<component-tag>"),
4447                    WebUIFragment::component("my-component"),
4448                    WebUIFragment::raw("</component-tag>"),
4449                ],
4450            },
4451        );
4452        fragments.insert(
4453            "my-component".to_string(),
4454            FragmentList {
4455                fragments: vec![
4456                    WebUIFragment::raw("<span>"),
4457                    WebUIFragment::signal("name", false),
4458                    WebUIFragment::raw("-"),
4459                    WebUIFragment::signal("globalSuffix", false),
4460                    WebUIFragment::raw("</span>"),
4461                ],
4462            },
4463        );
4464        let protocol = WebUIProtocol::new(fragments);
4465        let state =
4466            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
4467        let mut writer = TestWriter::new();
4468        handle(
4469            &protocol,
4470            &state,
4471            &RenderOptions::new("index.html", "/"),
4472            &mut writer,
4473        )
4474        .unwrap();
4475        assert_eq!(
4476            writer.get_content(),
4477            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
4478        );
4479    }
4480
4481    #[test]
4482    fn test_component_no_item_moniker() {
4483        let mut fragments = HashMap::new();
4484        fragments.insert(
4485            "index.html".to_string(),
4486            FragmentList {
4487                fragments: vec![
4488                    WebUIFragment::raw("<div>"),
4489                    WebUIFragment::for_loop("item", "items", "templateComponent"),
4490                    WebUIFragment::raw("</div>"),
4491                ],
4492            },
4493        );
4494        fragments.insert(
4495            "templateComponent".to_string(),
4496            FragmentList {
4497                fragments: vec![
4498                    WebUIFragment::raw("<component-tag>"),
4499                    WebUIFragment::component("my-component"),
4500                    WebUIFragment::raw("</component-tag>"),
4501                ],
4502            },
4503        );
4504        fragments.insert(
4505            "my-component".to_string(),
4506            FragmentList {
4507                fragments: vec![
4508                    WebUIFragment::raw("<span>"),
4509                    WebUIFragment::signal("item.name", false),
4510                    WebUIFragment::raw("-"),
4511                    WebUIFragment::signal("globalSuffix", false),
4512                    WebUIFragment::raw("</span>"),
4513                ],
4514            },
4515        );
4516        let protocol = WebUIProtocol::new(fragments);
4517        let state =
4518            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
4519        let mut writer = TestWriter::new();
4520        handle(
4521            &protocol,
4522            &state,
4523            &RenderOptions::new("index.html", "/"),
4524            &mut writer,
4525        )
4526        .unwrap();
4527        assert_eq!(
4528            writer.get_content(),
4529            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
4530        );
4531    }
4532
4533    #[test]
4534    fn test_for_nonqualified_uses_global() {
4535        let mut fragments = HashMap::new();
4536        fragments.insert(
4537            "index.html".to_string(),
4538            FragmentList {
4539                fragments: vec![
4540                    WebUIFragment::raw("<div>"),
4541                    WebUIFragment::for_loop("item", "items", "template1"),
4542                    WebUIFragment::raw("</div>"),
4543                ],
4544            },
4545        );
4546        fragments.insert(
4547            "template1".to_string(),
4548            FragmentList {
4549                fragments: vec![
4550                    WebUIFragment::raw("<span>"),
4551                    WebUIFragment::signal("name", false),
4552                    WebUIFragment::raw("</span>"),
4553                ],
4554            },
4555        );
4556        let protocol = WebUIProtocol::new(fragments);
4557        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
4558        let mut writer = TestWriter::new();
4559        handle(
4560            &protocol,
4561            &state,
4562            &RenderOptions::new("index.html", "/"),
4563            &mut writer,
4564        )
4565        .unwrap();
4566        assert_eq!(
4567            writer.get_content(),
4568            "<div><span>GlobalName</span><span>GlobalName</span></div>"
4569        );
4570    }
4571
4572    #[test]
4573    fn test_nested_for_if_interleaved() {
4574        let mut fragments = HashMap::new();
4575        fragments.insert(
4576            "index.html".to_string(),
4577            FragmentList {
4578                fragments: vec![
4579                    WebUIFragment::raw("<div>"),
4580                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4581                    WebUIFragment::raw("</div>"),
4582                ],
4583            },
4584        );
4585        fragments.insert(
4586            "outerTemplate".to_string(),
4587            FragmentList {
4588                fragments: vec![
4589                    WebUIFragment::raw("<section>"),
4590                    WebUIFragment::signal("globalPrefix", false),
4591                    WebUIFragment::signal("outerItem.outerLabel", false),
4592                    WebUIFragment::if_cond(
4593                        ConditionExpr::identifier("outerItem.include"),
4594                        "ifTemplate",
4595                    ),
4596                    WebUIFragment::raw("</section>"),
4597                ],
4598            },
4599        );
4600        fragments.insert(
4601            "ifTemplate".to_string(),
4602            FragmentList {
4603                fragments: vec![
4604                    WebUIFragment::raw("<div>"),
4605                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
4606                    WebUIFragment::raw("</div>"),
4607                ],
4608            },
4609        );
4610        fragments.insert(
4611            "innerTemplate".to_string(),
4612            FragmentList {
4613                fragments: vec![
4614                    WebUIFragment::raw("<p>"),
4615                    WebUIFragment::signal("globalSuffix", false),
4616                    WebUIFragment::raw(": "),
4617                    WebUIFragment::signal("innerItem.innerLabel", false),
4618                    WebUIFragment::raw("</p>"),
4619                ],
4620            },
4621        );
4622        let protocol = WebUIProtocol::new(fragments);
4623        let state = test_json!({
4624            "globalPrefix": "Prefix: ",
4625            "globalSuffix": "Suffix",
4626            "outerItems": [
4627                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
4628                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
4629                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
4630            ]
4631        });
4632        let mut writer = TestWriter::new();
4633        handle(
4634            &protocol,
4635            &state,
4636            &RenderOptions::new("index.html", "/"),
4637            &mut writer,
4638        )
4639        .unwrap();
4640        assert_eq!(
4641            writer.get_content(),
4642            "<div><section>Prefix: O1<div><p>Suffix: I1</p><p>Suffix: I2</p></div></section><section>Prefix: O2</section><section>Prefix: O3<div><p>Suffix: I3</p></div></section></div>"
4643        );
4644    }
4645
4646    #[test]
4647    fn test_nested_for_if_outer_state() {
4648        let mut fragments = HashMap::new();
4649        fragments.insert(
4650            "index.html".to_string(),
4651            FragmentList {
4652                fragments: vec![
4653                    WebUIFragment::raw("<div>"),
4654                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4655                    WebUIFragment::raw("</div>"),
4656                ],
4657            },
4658        );
4659        fragments.insert(
4660            "outerTemplate".to_string(),
4661            FragmentList {
4662                fragments: vec![
4663                    WebUIFragment::raw("<section>"),
4664                    WebUIFragment::signal("globalPrefix", false),
4665                    WebUIFragment::signal("outerItem.label", false),
4666                    WebUIFragment::for_loop(
4667                        "middleItem",
4668                        "outerItem.middleItems",
4669                        "middleTemplate",
4670                    ),
4671                    WebUIFragment::raw("</section>"),
4672                ],
4673            },
4674        );
4675        fragments.insert(
4676            "middleTemplate".to_string(),
4677            FragmentList {
4678                fragments: vec![
4679                    WebUIFragment::raw("<div>"),
4680                    WebUIFragment::if_cond(
4681                        ConditionExpr::identifier("outerItem.active"),
4682                        "ifTemplate",
4683                    ),
4684                    WebUIFragment::raw("</div>"),
4685                ],
4686            },
4687        );
4688        fragments.insert(
4689            "ifTemplate".to_string(),
4690            FragmentList {
4691                fragments: vec![
4692                    WebUIFragment::raw("<p>"),
4693                    WebUIFragment::signal("middleItem.value", false),
4694                    WebUIFragment::raw("</p>"),
4695                ],
4696            },
4697        );
4698        let protocol = WebUIProtocol::new(fragments);
4699        let state = test_json!({
4700            "globalPrefix": "GP-",
4701            "outerItems": [
4702                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
4703                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
4704                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
4705            ]
4706        });
4707        let mut writer = TestWriter::new();
4708        handle(
4709            &protocol,
4710            &state,
4711            &RenderOptions::new("index.html", "/"),
4712            &mut writer,
4713        )
4714        .unwrap();
4715        assert_eq!(
4716            writer.get_content(),
4717            "<div><section>GP-O1<div><p>M1</p></div><div><p>M2</p></div></section><section>GP-O2<div></div></section><section>GP-O3<div><p>M4</p></div></section></div>"
4718        );
4719    }
4720
4721    #[test]
4722    fn test_nested_for_if_inner_state() {
4723        let mut fragments = HashMap::new();
4724        fragments.insert(
4725            "index.html".to_string(),
4726            FragmentList {
4727                fragments: vec![
4728                    WebUIFragment::raw("<div>"),
4729                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4730                    WebUIFragment::raw("</div>"),
4731                ],
4732            },
4733        );
4734        fragments.insert(
4735            "outerTemplate".to_string(),
4736            FragmentList {
4737                fragments: vec![
4738                    WebUIFragment::raw("<section>"),
4739                    WebUIFragment::signal("outerItem.label", false),
4740                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
4741                    WebUIFragment::raw("</section>"),
4742                ],
4743            },
4744        );
4745        fragments.insert(
4746            "innerTemplate".to_string(),
4747            FragmentList {
4748                fragments: vec![
4749                    WebUIFragment::raw("<article>"),
4750                    WebUIFragment::if_cond(
4751                        ConditionExpr::identifier("innerItem.show"),
4752                        "ifTemplate",
4753                    ),
4754                    WebUIFragment::raw("</article>"),
4755                ],
4756            },
4757        );
4758        fragments.insert(
4759            "ifTemplate".to_string(),
4760            FragmentList {
4761                fragments: vec![
4762                    WebUIFragment::raw("<p>"),
4763                    WebUIFragment::signal("innerItem.detail", false),
4764                    WebUIFragment::raw("</p>"),
4765                ],
4766            },
4767        );
4768        let protocol = WebUIProtocol::new(fragments);
4769        let state = test_json!({
4770            "outerItems": [
4771                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
4772                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
4773            ]
4774        });
4775        let mut writer = TestWriter::new();
4776        handle(
4777            &protocol,
4778            &state,
4779            &RenderOptions::new("index.html", "/"),
4780            &mut writer,
4781        )
4782        .unwrap();
4783        assert_eq!(
4784            writer.get_content(),
4785            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
4786        );
4787    }
4788
4789    #[test]
4790    fn test_for_merge_local_global_monikers() {
4791        let mut fragments = HashMap::new();
4792        fragments.insert(
4793            "index.html".to_string(),
4794            FragmentList {
4795                fragments: vec![
4796                    WebUIFragment::raw("<div>"),
4797                    WebUIFragment::for_loop("item", "items", "template1"),
4798                    WebUIFragment::raw("</div>"),
4799                ],
4800            },
4801        );
4802        fragments.insert(
4803            "template1".to_string(),
4804            FragmentList {
4805                fragments: vec![
4806                    WebUIFragment::raw("<span>"),
4807                    WebUIFragment::signal("item.name", false),
4808                    WebUIFragment::raw("-"),
4809                    WebUIFragment::signal("item.globalValue", false),
4810                    WebUIFragment::raw("-"),
4811                    WebUIFragment::signal("item.localOnly", false),
4812                    WebUIFragment::raw("-"),
4813                    WebUIFragment::signal("item.otherVal", false),
4814                    WebUIFragment::raw("</span>"),
4815                ],
4816            },
4817        );
4818        let protocol = WebUIProtocol::new(fragments);
4819        let state = test_json!({
4820            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
4821            "items": [
4822                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
4823                {"name": "Local2", "localOnly": "Only2"}
4824            ]
4825        });
4826        let mut writer = TestWriter::new();
4827        handle(
4828            &protocol,
4829            &state,
4830            &RenderOptions::new("index.html", "/"),
4831            &mut writer,
4832        )
4833        .unwrap();
4834        assert_eq!(
4835            writer.get_content(),
4836            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
4837        );
4838    }
4839
4840    #[test]
4841    fn test_component_in_for_global_moniker_shadow() {
4842        let mut fragments = HashMap::new();
4843        fragments.insert(
4844            "index.html".to_string(),
4845            FragmentList {
4846                fragments: vec![
4847                    WebUIFragment::raw("<div>"),
4848                    WebUIFragment::for_loop("item", "items", "templateComponent"),
4849                    WebUIFragment::raw("</div>"),
4850                ],
4851            },
4852        );
4853        fragments.insert(
4854            "templateComponent".to_string(),
4855            FragmentList {
4856                fragments: vec![
4857                    WebUIFragment::raw("<component-tag>"),
4858                    WebUIFragment::component("my-component"),
4859                    WebUIFragment::raw("</component-tag>"),
4860                ],
4861            },
4862        );
4863        fragments.insert(
4864            "my-component".to_string(),
4865            FragmentList {
4866                fragments: vec![
4867                    WebUIFragment::raw("<span>"),
4868                    WebUIFragment::signal("name", false),
4869                    WebUIFragment::raw("-"),
4870                    WebUIFragment::signal("item.globalValue", false),
4871                    WebUIFragment::raw("-"),
4872                    WebUIFragment::signal("localOnly", false),
4873                    WebUIFragment::raw("-"),
4874                    WebUIFragment::signal("item.otherVal", false),
4875                    WebUIFragment::raw("</span>"),
4876                ],
4877            },
4878        );
4879        let protocol = WebUIProtocol::new(fragments);
4880        let state = test_json!({
4881            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
4882            "items": [
4883                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
4884                {"name": "Local2", "localOnly": "Only2"}
4885            ]
4886        });
4887        let mut writer = TestWriter::new();
4888        handle(
4889            &protocol,
4890            &state,
4891            &RenderOptions::new("index.html", "/"),
4892            &mut writer,
4893        )
4894        .unwrap();
4895        assert_eq!(
4896            writer.get_content(),
4897            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
4898        );
4899    }
4900
4901    #[test]
4902    fn test_if_in_nested_for_local_flag() {
4903        let mut fragments = HashMap::new();
4904        fragments.insert(
4905            "index.html".to_string(),
4906            FragmentList {
4907                fragments: vec![
4908                    WebUIFragment::raw("<div>"),
4909                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
4910                    WebUIFragment::raw("</div>"),
4911                ],
4912            },
4913        );
4914        fragments.insert(
4915            "outerTemplate".to_string(),
4916            FragmentList {
4917                fragments: vec![
4918                    WebUIFragment::raw("<section>"),
4919                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
4920                    WebUIFragment::raw("</section>"),
4921                ],
4922            },
4923        );
4924        fragments.insert(
4925            "innerTemplate".to_string(),
4926            FragmentList {
4927                fragments: vec![WebUIFragment::if_cond(
4928                    ConditionExpr::identifier("inner_item.flag"),
4929                    "ifInner",
4930                )],
4931            },
4932        );
4933        fragments.insert(
4934            "ifInner".to_string(),
4935            FragmentList {
4936                fragments: vec![
4937                    WebUIFragment::raw("<p>"),
4938                    WebUIFragment::signal("inner_item.value", false),
4939                    WebUIFragment::raw("</p>"),
4940                ],
4941            },
4942        );
4943        let protocol = WebUIProtocol::new(fragments);
4944        let state = test_json!({
4945            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
4946            "inner_item": {"flag": false}
4947        });
4948        let mut writer = TestWriter::new();
4949        handle(
4950            &protocol,
4951            &state,
4952            &RenderOptions::new("index.html", "/"),
4953            &mut writer,
4954        )
4955        .unwrap();
4956        assert_eq!(
4957            writer.get_content(),
4958            "<div><section><p>X</p></section></div>"
4959        );
4960    }
4961
4962    #[test]
4963    fn test_if_in_nested_for_global_fallback() {
4964        let mut fragments = HashMap::new();
4965        fragments.insert(
4966            "index.html".to_string(),
4967            FragmentList {
4968                fragments: vec![
4969                    WebUIFragment::raw("<div>"),
4970                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
4971                    WebUIFragment::raw("</div>"),
4972                ],
4973            },
4974        );
4975        fragments.insert(
4976            "outerTemplate".to_string(),
4977            FragmentList {
4978                fragments: vec![
4979                    WebUIFragment::raw("<section>"),
4980                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
4981                    WebUIFragment::raw("</section>"),
4982                ],
4983            },
4984        );
4985        fragments.insert(
4986            "innerTemplate".to_string(),
4987            FragmentList {
4988                fragments: vec![WebUIFragment::if_cond(
4989                    ConditionExpr::identifier("inner_item.flag"),
4990                    "ifInner",
4991                )],
4992            },
4993        );
4994        fragments.insert(
4995            "ifInner".to_string(),
4996            FragmentList {
4997                fragments: vec![
4998                    WebUIFragment::raw("<p>"),
4999                    WebUIFragment::signal("inner_item.value", false),
5000                    WebUIFragment::raw("</p>"),
5001                ],
5002            },
5003        );
5004        let protocol = WebUIProtocol::new(fragments);
5005        let state = test_json!({
5006            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
5007            "inner_item": {"flag": true}
5008        });
5009        let mut writer = TestWriter::new();
5010        handle(
5011            &protocol,
5012            &state,
5013            &RenderOptions::new("index.html", "/"),
5014            &mut writer,
5015        )
5016        .unwrap();
5017        assert_eq!(
5018            writer.get_content(),
5019            "<div><section><p>X</p><p>Y</p></section></div>"
5020        );
5021    }
5022
5023    #[test]
5024    fn test_if_mixed_for_monikers() {
5025        let mut fragments = HashMap::new();
5026        fragments.insert(
5027            "index.html".to_string(),
5028            FragmentList {
5029                fragments: vec![
5030                    WebUIFragment::raw("<div>"),
5031                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
5032                    WebUIFragment::raw("</div>"),
5033                ],
5034            },
5035        );
5036        fragments.insert(
5037            "outerTemplate".to_string(),
5038            FragmentList {
5039                fragments: vec![
5040                    WebUIFragment::raw("<section>"),
5041                    WebUIFragment::signal("outer.outerLabel", false),
5042                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
5043                    WebUIFragment::raw("</section>"),
5044                ],
5045            },
5046        );
5047        fragments.insert(
5048            "innerTemplate".to_string(),
5049            FragmentList {
5050                fragments: vec![WebUIFragment::if_cond(
5051                    ConditionExpr::compound(
5052                        ConditionExpr::identifier("outer.active"),
5053                        LogicalOperator::And,
5054                        ConditionExpr::predicate(
5055                            "inner.value",
5056                            ComparisonOperator::GreaterThan,
5057                            "globalLimit",
5058                        ),
5059                    ),
5060                    "ifInner",
5061                )],
5062            },
5063        );
5064        fragments.insert(
5065            "ifInner".to_string(),
5066            FragmentList {
5067                fragments: vec![
5068                    WebUIFragment::raw("<p>"),
5069                    WebUIFragment::signal("inner.value", false),
5070                    WebUIFragment::raw("</p>"),
5071                ],
5072            },
5073        );
5074        let protocol = WebUIProtocol::new(fragments);
5075        let state = test_json!({
5076            "globalLimit": 10,
5077            "list": {"outerItems": [
5078                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
5079                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
5080                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
5081            ]}
5082        });
5083        let mut writer = TestWriter::new();
5084        handle(
5085            &protocol,
5086            &state,
5087            &RenderOptions::new("index.html", "/"),
5088            &mut writer,
5089        )
5090        .unwrap();
5091        assert_eq!(
5092            writer.get_content(),
5093            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
5094        );
5095    }
5096
5097    // ── Route-aware rendering tests ─────────────────────────────────────
5098
5099    fn make_route_protocol() -> WebUIProtocol {
5100        use webui_protocol::WebUiFragmentRoute;
5101
5102        let mut fragments = HashMap::new();
5103
5104        // Entry page with two routes
5105        fragments.insert(
5106            "index.html".to_string(),
5107            FragmentList {
5108                fragments: vec![
5109                    WebUIFragment::raw("<h1>Shell</h1>"),
5110                    WebUIFragment::route_from(WebUiFragmentRoute {
5111                        path: "/".into(),
5112                        fragment_id: "dash-page".into(),
5113                        exact: true,
5114                        ..Default::default()
5115                    }),
5116                    WebUIFragment::route_from(WebUiFragmentRoute {
5117                        path: "/contacts/:id".into(),
5118                        fragment_id: "detail-page".into(),
5119                        exact: true,
5120                        ..Default::default()
5121                    }),
5122                ],
5123            },
5124        );
5125
5126        // Dashboard page component
5127        fragments.insert(
5128            "dash-page".to_string(),
5129            FragmentList {
5130                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
5131            },
5132        );
5133
5134        // Detail page component
5135        fragments.insert(
5136            "detail-page".to_string(),
5137            FragmentList {
5138                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
5139            },
5140        );
5141
5142        WebUIProtocol::new(fragments)
5143    }
5144
5145    #[test]
5146    fn test_route_renders_shell_always() {
5147        let protocol = make_route_protocol();
5148        let state = test_json!({});
5149        let mut writer = TestWriter::new();
5150        handle(
5151            &protocol,
5152            &state,
5153            &RenderOptions::new("index.html", "/"),
5154            &mut writer,
5155        )
5156        .unwrap();
5157        let html = writer.get_content();
5158
5159        // Shell content always renders regardless of route matching
5160        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
5161        // Dashboard matches "/" so it should be active
5162        assert!(html.contains(" active>"), "matched route should be active");
5163        // Detail should be hidden and empty
5164        assert!(
5165            html.contains("style=\"display:none\""),
5166            "non-matched routes should be hidden"
5167        );
5168    }
5169
5170    #[test]
5171    fn test_route_matched_renders_visible() {
5172        let protocol = make_route_protocol();
5173        let state = test_json!({});
5174        let mut writer = TestWriter::new();
5175        handle(
5176            &protocol,
5177            &state,
5178            &RenderOptions::new("index.html", "/"),
5179            &mut writer,
5180        )
5181        .unwrap();
5182        let html = writer.get_content();
5183
5184        // Dashboard route should be visible (active, no display:none)
5185        assert!(
5186            html.contains("<webui-route path=\"/\""),
5187            "dashboard route should exist"
5188        );
5189        assert!(
5190            html.contains("active>") && html.contains("<dash-page>"),
5191            "matched route should be active with component tag: {html}"
5192        );
5193        assert!(
5194            html.contains("<p>Dashboard</p>"),
5195            "matched route should have content"
5196        );
5197    }
5198
5199    #[test]
5200    fn test_route_non_matched_renders_hidden_empty() {
5201        let protocol = make_route_protocol();
5202        let state = test_json!({});
5203        let mut writer = TestWriter::new();
5204        handle(
5205            &protocol,
5206            &state,
5207            &RenderOptions::new("index.html", "/"),
5208            &mut writer,
5209        )
5210        .unwrap();
5211        let html = writer.get_content();
5212
5213        // Detail route should be hidden and empty (no content rendered)
5214        assert!(
5215            html.contains("<webui-route path=\"/contacts/:id\""),
5216            "detail route element should exist"
5217        );
5218        // The non-matched route should have display:none and no inner content
5219        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
5220        let after_detail = &html[detail_start..];
5221        assert!(
5222            after_detail.contains("style=\"display:none\">")
5223                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
5224            "non-matched route should be hidden: {after_detail}"
5225        );
5226        // Should NOT contain the component's rendered content
5227        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
5228        let detail_body = &after_detail[..detail_end];
5229        assert!(
5230            !detail_body.contains("<detail-page>"),
5231            "non-matched route should not render component content: {detail_body}"
5232        );
5233    }
5234
5235    #[test]
5236    fn test_route_parameterized_match() {
5237        let protocol = make_route_protocol();
5238        let state = test_json!({});
5239        let mut writer = TestWriter::new();
5240        handle(
5241            &protocol,
5242            &state,
5243            &RenderOptions::new("index.html", "/contacts/42"),
5244            &mut writer,
5245        )
5246        .unwrap();
5247        let html = writer.get_content();
5248
5249        // Detail route matches /contacts/42
5250        assert!(
5251            html.contains("active>") && html.contains("<detail-page>"),
5252            "detail route should be active: {html}"
5253        );
5254        assert!(html.contains("<p>Detail</p>"), "detail should have content");
5255        // Dashboard should be hidden + empty
5256        let dash_start = html
5257            .find("component=\"dash-page\"")
5258            .expect("dashboard route");
5259        let after_dash = &html[dash_start..];
5260        assert!(
5261            after_dash.contains("style=\"display:none\">"),
5262            "dashboard should be hidden when detail matches: {after_dash}"
5263        );
5264        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
5265        let dash_body = &after_dash[..dash_end];
5266        assert!(
5267            !dash_body.contains("<dash-page>"),
5268            "dashboard should not render component content: {dash_body}"
5269        );
5270    }
5271
5272    #[test]
5273    fn test_route_no_match_all_hidden_empty() {
5274        let protocol = make_route_protocol();
5275        let state = test_json!({});
5276        let mut writer = TestWriter::new();
5277        handle(
5278            &protocol,
5279            &state,
5280            &RenderOptions::new("index.html", "/nonexistent"),
5281            &mut writer,
5282        )
5283        .unwrap();
5284        let html = writer.get_content();
5285
5286        // Shell content should still render
5287        assert!(html.contains("<h1>Shell</h1>"));
5288        // All routes should be hidden + empty (nothing matched)
5289        assert!(
5290            !html.contains("<p>Dashboard</p>"),
5291            "no route content when nothing matches"
5292        );
5293        assert!(
5294            !html.contains("<p>Detail</p>"),
5295            "no route content when nothing matches"
5296        );
5297    }
5298
5299    #[test]
5300    fn test_route_component_attr_emitted() {
5301        let protocol = make_route_protocol();
5302        let state = test_json!({});
5303        let mut writer = TestWriter::new();
5304        handle(
5305            &protocol,
5306            &state,
5307            &RenderOptions::new("index.html", "/"),
5308            &mut writer,
5309        )
5310        .unwrap();
5311        let html = writer.get_content();
5312        // component attribute should be emitted on webui-route
5313        assert!(
5314            html.contains("component=\"dash-page\""),
5315            "component attr should be on webui-route: {html}"
5316        );
5317        assert!(
5318            html.contains("component=\"detail-page\""),
5319            "component attr should be on webui-route: {html}"
5320        );
5321    }
5322
5323    #[test]
5324    fn test_route_state_attributes_escape_scalars_and_data_state() {
5325        let protocol = make_route_protocol();
5326        let state = test_json!({
5327            "title": "Fish & Chips <\"special\">",
5328            "cartOpen": true,
5329            "items": [{"name": "A&B"}]
5330        });
5331        let mut writer = TestWriter::new();
5332
5333        handle(
5334            &protocol,
5335            &state,
5336            &RenderOptions::new("index.html", "/"),
5337            &mut writer,
5338        )
5339        .unwrap();
5340
5341        let html = writer.get_content();
5342        assert!(
5343            html.contains(r#"title="Fish &amp; Chips &lt;&quot;special&quot;&gt;""#),
5344            "escaped title should be emitted: {html}"
5345        );
5346        assert!(
5347            html.contains(r#"cart-open="true""#),
5348            "bool attrs should render: {html}"
5349        );
5350        assert!(
5351            html.contains(r#"data-state=""#),
5352            "complex state should be emitted as data-state: {html}"
5353        );
5354        assert!(
5355            html.contains(r#"&quot;name&quot;:&quot;A&amp;B&quot;"#),
5356            "complex state values should be escaped inside data-state: {html}"
5357        );
5358    }
5359
5360    // ── Nested route + outlet rendering tests ─────────────────────────
5361
5362    /// Build a protocol with nested routes and outlet-based components.
5363    fn make_nested_route_protocol() -> WebUIProtocol {
5364        use webui_protocol::WebUiFragmentRoute;
5365
5366        let mut fragments = HashMap::new();
5367
5368        fragments.insert(
5369            "index.html".to_string(),
5370            FragmentList {
5371                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5372                    path: "/".into(),
5373                    fragment_id: "app-shell".into(),
5374                    exact: false,
5375                    children: vec![WebUiFragmentRoute {
5376                        path: "sections/:id".into(),
5377                        fragment_id: "section-comp".into(),
5378                        exact: false,
5379                        children: vec![WebUiFragmentRoute {
5380                            path: "topics/:topicId".into(),
5381                            fragment_id: "topic-comp".into(),
5382                            exact: true,
5383                            children: vec![],
5384                        }],
5385                    }],
5386                })],
5387            },
5388        );
5389
5390        fragments.insert(
5391            "app-shell".to_string(),
5392            FragmentList {
5393                fragments: vec![
5394                    WebUIFragment::raw("<h1>Shell</h1>"),
5395                    WebUIFragment::outlet(),
5396                ],
5397            },
5398        );
5399
5400        fragments.insert(
5401            "section-comp".to_string(),
5402            FragmentList {
5403                fragments: vec![
5404                    WebUIFragment::raw("<h2>Section</h2>"),
5405                    WebUIFragment::outlet(),
5406                ],
5407            },
5408        );
5409
5410        fragments.insert(
5411            "topic-comp".to_string(),
5412            FragmentList {
5413                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
5414            },
5415        );
5416
5417        WebUIProtocol::new(fragments)
5418    }
5419
5420    #[test]
5421    fn test_nested_routes_render_webui_route_as_light_dom() {
5422        let protocol = make_nested_route_protocol();
5423        let state = test_json!({"title": "Test"});
5424        let handler = WebUIHandler::new();
5425        let mut writer = TestWriter::new();
5426
5427        handler
5428            .handle(
5429                &protocol,
5430                &state,
5431                &RenderOptions::new("index.html", "/sections/frontend"),
5432                &mut writer,
5433            )
5434            .expect("render failed");
5435
5436        let html = writer.get_content();
5437
5438        assert!(
5439            html.contains("component=\"app-shell\"") && html.contains("active>"),
5440            "root route should be active: {html}"
5441        );
5442        // webui-route should NOT have shadow DOM — it's a light DOM structural element
5443        assert!(
5444            !html.contains("<webui-route path=\"/\"")
5445                || !html
5446                    .contains("<webui-route path=\"/\" component=\"app-shell\" active><template shadowrootmode"),
5447            "webui-route should be light DOM (no shadow template): {html}"
5448        );
5449    }
5450
5451    #[test]
5452    fn test_nested_routes_render_outlet_as_light_dom() {
5453        let protocol = make_nested_route_protocol();
5454        let state = test_json!({"title": "Test"});
5455        let handler = WebUIHandler::new();
5456        let mut writer = TestWriter::new();
5457
5458        handler
5459            .handle(
5460                &protocol,
5461                &state,
5462                &RenderOptions::new("index.html", "/sections/frontend"),
5463                &mut writer,
5464            )
5465            .expect("render failed");
5466
5467        let html = writer.get_content();
5468
5469        // No <webui-outlet> wrapper — routes render directly at outlet position
5470        assert!(
5471            !html.contains("<webui-outlet>"),
5472            "should not contain webui-outlet wrapper: {html}"
5473        );
5474        // Route elements should be in the output directly
5475        assert!(
5476            html.contains("<webui-route"),
5477            "should contain webui-route elements: {html}"
5478        );
5479    }
5480
5481    #[test]
5482    fn test_nested_routes_match_child_at_outlet() {
5483        let protocol = make_nested_route_protocol();
5484        let state = test_json!({"title": "Test"});
5485        let handler = WebUIHandler::new();
5486        let mut writer = TestWriter::new();
5487
5488        handler
5489            .handle(
5490                &protocol,
5491                &state,
5492                &RenderOptions::new("index.html", "/sections/frontend"),
5493                &mut writer,
5494            )
5495            .expect("render failed");
5496
5497        let html = writer.get_content();
5498
5499        assert!(
5500            html.contains("component=\"section-comp\"") && html.contains("active>"),
5501            "section route should be active: {html}"
5502        );
5503        assert!(
5504            html.contains("<h2>Section</h2>"),
5505            "section content should be present: {html}"
5506        );
5507    }
5508
5509    #[test]
5510    fn test_nested_routes_three_levels_deep() {
5511        let protocol = make_nested_route_protocol();
5512        let state = test_json!({"title": "Test"});
5513        let handler = WebUIHandler::new();
5514        let mut writer = TestWriter::new();
5515
5516        handler
5517            .handle(
5518                &protocol,
5519                &state,
5520                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
5521                &mut writer,
5522            )
5523            .expect("render failed");
5524
5525        let html = writer.get_content();
5526
5527        assert!(
5528            html.contains("component=\"app-shell\"") && html.contains("active>"),
5529            "root active: {html}"
5530        );
5531        assert!(
5532            html.contains("component=\"section-comp\"") && html.contains("active>"),
5533            "section active: {html}"
5534        );
5535        assert!(
5536            html.contains("component=\"topic-comp\"") && html.contains("exact active>"),
5537            "topic active: {html}"
5538        );
5539        assert!(
5540            html.contains("<p>Topic content</p>"),
5541            "leaf content present: {html}"
5542        );
5543    }
5544
5545    #[test]
5546    fn test_nested_routes_nonmatched_siblings_hidden() {
5547        let protocol = make_nested_route_protocol();
5548        let state = test_json!({"title": "Test"});
5549        let handler = WebUIHandler::new();
5550        let mut writer = TestWriter::new();
5551
5552        handler
5553            .handle(
5554                &protocol,
5555                &state,
5556                &RenderOptions::new("index.html", "/sections/frontend"),
5557                &mut writer,
5558            )
5559            .expect("render failed");
5560
5561        let html = writer.get_content();
5562
5563        assert!(
5564            html.contains(r#"component="topic-comp" style="display:none">"#),
5565            "topic should be hidden: {html}"
5566        );
5567    }
5568
5569    #[test]
5570    fn test_nested_routes_root_only() {
5571        let protocol = make_nested_route_protocol();
5572        let state = test_json!({"title": "Test"});
5573        let handler = WebUIHandler::new();
5574        let mut writer = TestWriter::new();
5575
5576        handler
5577            .handle(
5578                &protocol,
5579                &state,
5580                &RenderOptions::new("index.html", "/"),
5581                &mut writer,
5582            )
5583            .expect("render failed");
5584
5585        let html = writer.get_content();
5586
5587        assert!(
5588            html.contains("component=\"app-shell\"") && html.contains("active>"),
5589            "root active at /: {html}"
5590        );
5591        assert!(
5592            html.contains("<h1>Shell</h1>"),
5593            "shell renders at /: {html}"
5594        );
5595        assert!(
5596            html.contains(r#"component="section-comp" style="display:none">"#),
5597            "section hidden at /: {html}"
5598        );
5599    }
5600
5601    // ── CSS Module dedup tests ───────────────────────────────────────
5602
5603    #[test]
5604    fn test_css_module_emitted_once_before_component() {
5605        // CSS module definition emitted once, right before the first
5606        // component usage — not in <head>.
5607        let template = r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-card"><p><slot></slot></p></template>"#;
5608
5609        let mut fragments = HashMap::new();
5610        fragments.insert(
5611            "index.html".to_string(),
5612            FragmentList {
5613                fragments: vec![
5614                    WebUIFragment::raw("<div>".to_string()),
5615                    WebUIFragment::component("my-card"),
5616                    WebUIFragment::raw("A".to_string()),
5617                    WebUIFragment::component("my-card"),
5618                    WebUIFragment::raw("B</div>".to_string()),
5619                ],
5620            },
5621        );
5622        fragments.insert(
5623            "my-card".to_string(),
5624            FragmentList {
5625                fragments: vec![WebUIFragment::raw(template.to_string())],
5626            },
5627        );
5628
5629        let mut protocol = WebUIProtocol::new(fragments);
5630        protocol
5631            .components
5632            .entry("my-card".to_string())
5633            .or_default()
5634            .css = "p{color:red}".to_string();
5635        let state = test_json!({});
5636        let mut writer = TestWriter::new();
5637
5638        handle(
5639            &protocol,
5640            &state,
5641            &RenderOptions::new("index.html", "/"),
5642            &mut writer,
5643        )
5644        .unwrap();
5645
5646        let html = writer.get_content();
5647
5648        // CSS module should appear exactly once
5649        let count = html
5650            .matches(r#"<style type="module" specifier="my-card">"#)
5651            .count();
5652        assert_eq!(
5653            count, 1,
5654            "CSS module should be emitted once, got {count} in: {html}"
5655        );
5656
5657        // Template should appear twice (once per component instance)
5658        let tmpl_count = html
5659            .matches(r#"shadowrootadoptedstylesheets="my-card""#)
5660            .count();
5661        assert_eq!(
5662            tmpl_count, 2,
5663            "Template should render twice, got {tmpl_count} in: {html}"
5664        );
5665
5666        // CSS module should appear before the first template
5667        let css_pos = html
5668            .find(r#"<style type="module""#)
5669            .expect("CSS module missing");
5670        let tmpl_pos = html
5671            .find(r#"shadowrootadoptedstylesheets"#)
5672            .expect("template missing");
5673        assert!(
5674            css_pos < tmpl_pos,
5675            "CSS module should precede first component: {html}"
5676        );
5677    }
5678
5679    #[test]
5680    fn test_component_without_css_renders_normally() {
5681        // Components without CSS module prefix pass through unchanged
5682        let mut fragments = HashMap::new();
5683        fragments.insert(
5684            "index.html".to_string(),
5685            FragmentList {
5686                fragments: vec![WebUIFragment::component("my-card")],
5687            },
5688        );
5689        fragments.insert(
5690            "my-card".to_string(),
5691            FragmentList {
5692                fragments: vec![WebUIFragment::raw(
5693                    r#"<template shadowrootmode="open"><p>hello</p></template>"#.to_string(),
5694                )],
5695            },
5696        );
5697
5698        let protocol = WebUIProtocol::new(fragments);
5699        let state = test_json!({});
5700        let mut writer = TestWriter::new();
5701
5702        handle(
5703            &protocol,
5704            &state,
5705            &RenderOptions::new("index.html", "/"),
5706            &mut writer,
5707        )
5708        .unwrap();
5709
5710        let html = writer.get_content();
5711        assert!(
5712            html.contains("<p>hello</p>"),
5713            "Non-module component should render normally: {html}"
5714        );
5715    }
5716
5717    #[test]
5718    fn test_non_module_strategy_no_css_in_head() {
5719        // When component_css is empty (Link/Style strategies), no
5720        // <style type="module"> tags should appear in <head>.
5721        let template = r#"<template shadowrootmode="open"><p>hello</p></template>"#;
5722
5723        let mut fragments = HashMap::new();
5724        fragments.insert(
5725            "index.html".to_string(),
5726            FragmentList {
5727                fragments: vec![
5728                    WebUIFragment::raw("<html><head>".to_string()),
5729                    WebUIFragment::signal("head_end", true),
5730                    WebUIFragment::raw("</head><body>".to_string()),
5731                    WebUIFragment::component("my-card"),
5732                    WebUIFragment::raw("</body></html>".to_string()),
5733                ],
5734            },
5735        );
5736        fragments.insert(
5737            "my-card".to_string(),
5738            FragmentList {
5739                fragments: vec![WebUIFragment::raw(template.to_string())],
5740            },
5741        );
5742
5743        // No component css populated — simulates Link/Style strategy
5744        let protocol = WebUIProtocol::new(fragments);
5745        let state = test_json!({});
5746        let mut writer = TestWriter::new();
5747
5748        handle(
5749            &protocol,
5750            &state,
5751            &RenderOptions::new("index.html", "/"),
5752            &mut writer,
5753        )
5754        .unwrap();
5755
5756        let html = writer.get_content();
5757
5758        assert!(
5759            !html.contains(r#"<style type="module""#),
5760            "Non-module strategy should not emit CSS modules in <head>: {html}"
5761        );
5762        assert!(
5763            html.contains("<p>hello</p>"),
5764            "Component should still render: {html}"
5765        );
5766    }
5767
5768    #[test]
5769    fn test_css_module_emitted_in_component_light_dom() {
5770        // The CSS module <style> must be inside the component tag (light DOM),
5771        // not outside it — e.g. <mp-card><style type="module" ...>CSS</style>
5772        // <template shadowrootadoptedstylesheets="mp-card">...</template></mp-card>
5773        let mut fragments = HashMap::new();
5774        fragments.insert(
5775            "index.html".to_string(),
5776            FragmentList {
5777                fragments: vec![
5778                    WebUIFragment::raw("<my-card>".to_string()),
5779                    WebUIFragment::component("my-card"),
5780                    WebUIFragment::raw("</my-card>".to_string()),
5781                ],
5782            },
5783        );
5784        fragments.insert(
5785            "my-card".to_string(),
5786            FragmentList {
5787                fragments: vec![WebUIFragment::raw(
5788                    r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-card"><p>hi</p></template>"#.to_string(),
5789                )],
5790            },
5791        );
5792
5793        let mut protocol = WebUIProtocol::new(fragments);
5794        protocol
5795            .components
5796            .entry("my-card".to_string())
5797            .or_default()
5798            .css = "p{color:red}".to_string();
5799        let state = test_json!({});
5800        let mut writer = TestWriter::new();
5801
5802        handle(
5803            &protocol,
5804            &state,
5805            &RenderOptions::new("index.html", "/"),
5806            &mut writer,
5807        )
5808        .unwrap();
5809
5810        let html = writer.get_content();
5811
5812        // CSS module must be INSIDE the component tag (light DOM)
5813        let tag_open = html.find("<my-card>").expect("<my-card> missing");
5814        let css_pos = html
5815            .find(r#"<style type="module""#)
5816            .expect("CSS module missing");
5817        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
5818        assert!(
5819            css_pos > tag_open && css_pos < tag_close,
5820            "CSS module should be inside component light DOM: {html}"
5821        );
5822    }
5823
5824    #[test]
5825    fn test_css_module_emitted_for_route_components() {
5826        // Route components also get CSS modules via process_route → process_component.
5827        let template = r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="dash-page"><h1>Dashboard</h1></template>"#;
5828
5829        let mut fragments = HashMap::new();
5830        fragments.insert(
5831            "index.html".to_string(),
5832            FragmentList {
5833                fragments: vec![WebUIFragment::route("/", "dash-page")],
5834            },
5835        );
5836        fragments.insert(
5837            "dash-page".to_string(),
5838            FragmentList {
5839                fragments: vec![WebUIFragment::raw(template.to_string())],
5840            },
5841        );
5842
5843        let mut protocol = WebUIProtocol::new(fragments);
5844        protocol
5845            .components
5846            .entry("dash-page".to_string())
5847            .or_default()
5848            .css = "h1{font-size:2rem}".to_string();
5849        let state = test_json!({});
5850        let mut writer = TestWriter::new();
5851
5852        handle(
5853            &protocol,
5854            &state,
5855            &RenderOptions::new("index.html", "/"),
5856            &mut writer,
5857        )
5858        .unwrap();
5859
5860        let html = writer.get_content();
5861
5862        assert!(
5863            html.contains(
5864                r#"<style type="module" specifier="dash-page">h1{font-size:2rem}</style>"#
5865            ),
5866            "Route component should have CSS module: {html}"
5867        );
5868        assert!(
5869            html.contains("<h1>Dashboard</h1>"),
5870            "Route component should render content: {html}"
5871        );
5872    }
5873}