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