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