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