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