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