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