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