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