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