Skip to main content

webui_handler/
lib.rs

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