pub mod plugin;
pub mod route_handler;
pub mod route_matcher;
pub(crate) mod route_renderer;
use plugin::HandlerPlugin;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use webui_expressions::{evaluate_with_resolver, ExpressionError};
use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol};
use webui_state::find_value_by_dotted_path;
#[derive(Debug, Error)]
pub enum HandlerError {
#[error("Rendering error: {0}")]
Rendering(String),
#[error("Rendering invariant error: {0}")]
Invariant(String),
#[error("Missing fragment: {0}")]
MissingFragment(String),
#[error("Missing data field: {0}")]
MissingData(String),
#[error("Type error: {0}")]
TypeError(String),
#[error("Protocol error: {0}")]
Protocol(#[from] webui_protocol::ProtocolError),
#[error("Evaluation error: {0}")]
Evaluation(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Writer error: {0}")]
Writer(String),
#[error("Plugin data error: {0}")]
PluginData(String),
}
pub type Result<T> = std::result::Result<T, HandlerError>;
pub trait ResponseWriter {
fn write(&mut self, content: &str) -> Result<()>;
fn end(&mut self) -> Result<()>;
}
pub struct RenderOptions<'a> {
pub entry_id: &'a str,
pub request_path: &'a str,
pub nonce: Option<&'a str>,
}
impl<'a> RenderOptions<'a> {
#[must_use]
pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
Self {
entry_id,
request_path,
nonce: None,
}
}
#[must_use]
pub fn with_nonce(mut self, nonce: &'a str) -> Self {
self.nonce = Some(nonce);
self
}
}
pub struct WebUIHandler {
plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
}
struct WebUIProcessContext<'a> {
protocol: &'a WebUIProtocol,
state: &'a Value,
#[allow(dead_code)]
depth: usize,
writer: &'a mut dyn ResponseWriter,
local_vars: HashMap<String, Value>,
component_attrs: HashMap<String, Value>,
request_path: String,
route_base: String,
rendered_components: HashSet<String>,
plugin: Option<Box<dyn HandlerPlugin>>,
route_children: Vec<webui_protocol::WebUiFragmentRoute>,
entry_id: String,
nonce: Option<String>,
}
fn convert_hyphen_to_camel_case(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut capitalize_next = false;
for ch in name.chars() {
if ch == '-' {
capitalize_next = true;
} else if capitalize_next {
result.extend(ch.to_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
fn camel_to_kebab(name: &str) -> String {
let mut result = String::with_capacity(name.len() + 4);
for ch in name.chars() {
if ch.is_uppercase() && !result.is_empty() {
result.push('-');
for lc in ch.to_lowercase() {
result.push(lc);
}
} else {
result.push(ch);
}
}
result
}
fn component_attr_name(name: &str) -> String {
let stripped = name.strip_prefix(':').unwrap_or(name);
if stripped.contains('-') {
convert_hyphen_to_camel_case(stripped)
} else {
stripped.to_string()
}
}
impl WebUIHandler {
pub fn new() -> Self {
Self {
plugin_factory: None,
}
}
pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
Self {
plugin_factory: Some(factory),
}
}
pub fn handle(
&self,
protocol: &WebUIProtocol,
state: &Value,
options: &RenderOptions<'_>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if !protocol.fragments.contains_key(options.entry_id) {
return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
}
let mut context = WebUIProcessContext {
protocol,
state,
depth: 0,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: options.request_path.to_string(),
route_base: "/".to_string(),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id: options.entry_id.to_string(),
nonce: options.nonce.map(String::from),
};
self.process_fragment_id(options.entry_id, &mut context)?;
writer.end()?;
Ok(())
}
pub fn handle_as_component(
&self,
protocol: &WebUIProtocol,
state: &Value,
entry_id: &str,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if !protocol.fragments.contains_key(entry_id) {
return Err(HandlerError::MissingFragment(entry_id.to_string()));
}
let mut context = WebUIProcessContext {
protocol,
state,
depth: 0,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: String::new(),
route_base: "/".to_string(),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id: entry_id.to_string(),
nonce: None,
};
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(entry_id, &mut context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
writer.end()?;
Ok(())
}
fn process_fragment_id(
&self,
fragment_id: &str,
context: &mut WebUIProcessContext,
) -> Result<()> {
if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
self.process_fragment(&fragment_list.fragments, context)
} else {
Err(HandlerError::MissingFragment(fragment_id.to_string()))
}
}
fn process_fragment(
&self,
fragments: &[WebUIFragment],
context: &mut WebUIProcessContext,
) -> Result<()> {
let best_route = route_renderer::find_best_route_match(
fragments,
&context.request_path,
&context.route_base,
);
for item in fragments {
match item.fragment.as_ref() {
Some(Fragment::Raw(raw)) => {
context.writer.write(&raw.value)?;
}
Some(Fragment::Component(component)) => {
self.process_component(component, context)?;
}
Some(Fragment::ForLoop(for_loop)) => {
self.process_for_loop(for_loop, context)?;
}
Some(Fragment::Signal(signal)) => {
self.process_signal(signal, context)?;
}
Some(Fragment::IfCond(if_cond)) => {
self.process_if(if_cond, context)?;
}
Some(Fragment::Attribute(attr)) => {
self.process_attribute(attr, context)?;
}
Some(Fragment::Plugin(plugin_frag)) => {
if let Some(p) = &mut context.plugin {
p.on_element_data(&plugin_frag.data, context.writer)?;
}
}
Some(Fragment::Route(route_frag)) => {
self.process_route(route_frag, &best_route, context)?;
}
Some(Fragment::Outlet(_)) => {
self.process_outlet(context)?;
}
None => {}
}
}
Ok(())
}
fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
let mut children = std::mem::take(&mut context.route_children);
if children.is_empty() {
return Ok(());
}
let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
for (idx, child) in children.iter().enumerate() {
let resolved = route_matcher::resolve_route_path(&child.path, &context.route_base);
if let Some(m) =
route_matcher::match_single_route(&resolved, &context.request_path, child.exact)
{
let is_better = best
.as_ref()
.is_none_or(|(_, prev)| m.specificity > prev.specificity);
if is_better {
best = Some((idx, m));
}
}
}
let grandchildren = if let Some((idx, _)) = &best {
std::mem::take(&mut children[*idx].children)
} else {
Vec::new()
};
if let Some((idx, ref rm)) = best {
let matched_child = &children[idx];
let comp = &matched_child.fragment_id;
if !comp.is_empty() {
let saved_route_base = context.route_base.clone();
let saved_route_children = std::mem::take(&mut context.route_children);
if rm.consumed_segments > 0 {
context.route_base = route_matcher::compute_route_base(
&context.request_path,
rm.consumed_segments,
);
}
context.route_children = grandchildren;
context.writer.write("<webui-route")?;
if !matched_child.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&matched_child.path)?;
context.writer.write("\"")?;
}
context.writer.write(" component=\"")?;
context.writer.write(comp)?;
context.writer.write("\"")?;
if matched_child.exact {
context.writer.write(" exact")?;
}
context.writer.write(" active>")?;
context.writer.write("<")?;
context.writer.write(comp)?;
if let Some(p) = &context.plugin {
p.write_route_component_state(context.state, context.writer)?;
}
context.writer.write(">")?;
self.process_component(
&webui_protocol::WebUIFragmentComponent {
fragment_id: comp.clone(),
},
context,
)?;
context.writer.write("</")?;
context.writer.write(comp)?;
context.writer.write(">")?;
context.writer.write("</webui-route>")?;
context.route_base = saved_route_base;
context.route_children = saved_route_children;
}
}
for (idx, child) in children.iter().enumerate() {
let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
if !is_matched && !child.fragment_id.is_empty() {
context.writer.write("<webui-route")?;
if !child.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&child.path)?;
context.writer.write("\"")?;
}
context.writer.write(" component=\"")?;
context.writer.write(&child.fragment_id)?;
context
.writer
.write("\" style=\"display:none\"></webui-route>")?;
}
}
Ok(())
}
fn emit_css_module(
&self,
component: &webui_protocol::WebUIFragmentComponent,
context: &mut WebUIProcessContext,
) -> Result<()> {
if !context.rendered_components.contains(&component.fragment_id) {
if let Some(css) = context
.protocol
.components
.get(&component.fragment_id)
.map(|c| c.css.as_str())
.filter(|s| !s.is_empty())
{
context
.writer
.write("<style type=\"module\" specifier=\"")?;
context.writer.write(&component.fragment_id)?;
context.writer.write("\">")?;
context.writer.write(css)?;
context.writer.write("</style>")?;
}
}
Ok(())
}
fn process_route(
&self,
route_frag: &webui_protocol::WebUiFragmentRoute,
best_route: &Option<(String, route_matcher::RouteMatch)>,
context: &mut WebUIProcessContext,
) -> Result<()> {
let is_matched = best_route
.as_ref()
.is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
context.writer.write("<webui-route")?;
if !route_frag.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&route_frag.path)?;
context.writer.write("\"")?;
}
if !route_frag.fragment_id.is_empty() {
context.writer.write(" component=\"")?;
context.writer.write(&route_frag.fragment_id)?;
context.writer.write("\"")?;
}
if route_frag.exact {
context.writer.write(" exact")?;
}
if is_matched {
context.writer.write(" active>")?;
if !route_frag.fragment_id.is_empty() {
let saved_route_base = context.route_base.clone();
let saved_route_children = std::mem::take(&mut context.route_children);
if let Some((_, ref rm)) = best_route {
context.route_base = route_matcher::compute_route_base(
&context.request_path,
rm.consumed_segments,
);
}
context.route_children = route_frag.children.clone();
let comp = webui_protocol::WebUIFragmentComponent {
fragment_id: route_frag.fragment_id.clone(),
};
context.writer.write("<")?;
context.writer.write(&route_frag.fragment_id)?;
if let Some(p) = &context.plugin {
p.write_route_component_state(context.state, context.writer)?;
}
context.writer.write(">")?;
self.process_component(&comp, context)?;
context.writer.write("</")?;
context.writer.write(&route_frag.fragment_id)?;
context.writer.write(">")?;
context.route_base = saved_route_base;
context.route_children = saved_route_children;
}
} else {
context.writer.write(" style=\"display:none\">")?;
}
context.writer.write("</webui-route>")?;
Ok(())
}
fn process_component(
&self,
component: &webui_protocol::WebUIFragmentComponent,
context: &mut WebUIProcessContext,
) -> Result<()> {
let first_render = !context.rendered_components.contains(&component.fragment_id);
if first_render {
self.emit_css_module(component, context)?;
}
context
.rendered_components
.insert(component.fragment_id.clone());
let saved_local_vars = std::mem::take(&mut context.local_vars);
let saved_component_attrs = std::mem::take(&mut context.component_attrs);
context.local_vars = saved_component_attrs;
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(&component.fragment_id, context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
context.local_vars = saved_local_vars;
context.component_attrs = HashMap::new();
Ok(())
}
fn resolve_value(&self, path: &str, context: &WebUIProcessContext) -> Option<Value> {
if let Some(first_part) = path.split('.').next() {
if let Some(local_value) = context.local_vars.get(first_part) {
if first_part.len() == path.len() {
return Some(local_value.clone());
}
let remaining = &path[first_part.len() + 1..];
if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
return Some(v);
}
}
}
find_value_by_dotted_path(path, context.state)
}
fn evaluate_condition(
&self,
condition: &webui_protocol::ConditionExpr,
context: &WebUIProcessContext,
) -> Result<bool> {
let local_vars = &context.local_vars;
let state = context.state;
match evaluate_with_resolver(condition, |path| {
if let Some(first_part) = path.split('.').next() {
if let Some(local_value) = local_vars.get(first_part) {
if first_part.len() == path.len() {
return Some(local_value.clone());
}
let remaining = &path[first_part.len() + 1..];
if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
return Some(v);
}
}
}
find_value_by_dotted_path(path, state)
}) {
Ok(result) => Ok(result),
Err(ExpressionError::MissingValue(_)) => Ok(false),
Err(e) => Err(HandlerError::Evaluation(e.to_string())),
}
}
fn process_for_loop(
&self,
for_loop: &webui_protocol::WebUIFragmentFor,
context: &mut WebUIProcessContext,
) -> Result<()> {
let collection_name = &for_loop.collection;
let items = match self.resolve_value(collection_name, context) {
Some(Value::Array(arr)) => arr,
Some(_) => {
return Err(HandlerError::TypeError(format!(
"Collection '{}' is not an array",
collection_name
)))
}
None => Vec::new(),
};
if let Some(p) = &mut context.plugin {
p.on_for_start(&for_loop.fragment_id, context.writer)?;
}
let item_name = &for_loop.item;
for (i, item) in items.into_iter().enumerate() {
if let Some(p) = &mut context.plugin {
p.on_repeat_item_start(i, context.writer)?;
p.push_scope();
}
let saved_value = context.local_vars.insert(item_name.clone(), item);
self.process_fragment_id(&for_loop.fragment_id, context)?;
match saved_value {
Some(v) => {
context.local_vars.insert(item_name.clone(), v);
}
None => {
context.local_vars.remove(item_name.as_str());
}
}
if let Some(p) = &mut context.plugin {
p.pop_scope();
p.on_repeat_item_end(i, context.writer)?;
}
}
if let Some(p) = &mut context.plugin {
p.on_for_end(&for_loop.fragment_id, context.writer)?;
}
Ok(())
}
fn process_signal(
&self,
signal: &webui_protocol::WebUIFragmentSignal,
context: &mut WebUIProcessContext,
) -> Result<()> {
if signal.raw && signal.value == "head_end" {
let (needed_components, inventory_hex) =
crate::route_handler::get_needed_components_for_request(
context.protocol,
&context.entry_id,
&context.request_path,
"",
);
context
.writer
.write("<meta name=\"webui-inventory\" content=\"")?;
context.writer.write(&inventory_hex)?;
context.writer.write("\">")?;
if let Some(ref nonce) = context.nonce {
context
.writer
.write("<meta name=\"webui-nonce\" content=\"")?;
context.writer.write(&html_escape::encode_safe(nonce))?;
context.writer.write("\">")?;
}
for name in &needed_components {
if let Some(href) = context
.protocol
.components
.get(name)
.map(|c| &c.css_href)
.filter(|h| !h.is_empty())
{
context.writer.write("<link rel=\"stylesheet\" href=\"")?;
context.writer.write(href)?;
context.writer.write("\">")?;
}
}
}
if signal.raw && signal.value == "body_end" && context.plugin.is_some() {
let mut reachable = context.rendered_components.clone();
let mut stack: Vec<&str> = context
.rendered_components
.iter()
.map(|s| s.as_str())
.collect();
while let Some(fid) = stack.pop() {
if let Some(fragment_list) = context.protocol.fragments.get(fid) {
collect_reachable_components(
&fragment_list.fragments,
&mut reachable,
&mut stack,
);
}
}
crate::plugin::emit_rendered_component_templates(
context.protocol,
&reachable,
context.nonce.as_deref(),
context.writer,
)?;
if let Some(ref nonce) = context.nonce {
context.writer.write("<script nonce=\"")?;
context.writer.write(nonce)?;
context.writer.write("\">window.__webui_state=")?;
} else {
context.writer.write("<script>window.__webui_state=")?;
}
let state_json = serde_json::to_string(context.state).unwrap_or_default();
let safe_json = state_json.replace("</", "<\\/");
context.writer.write(&safe_json)?;
context.writer.write("</script>\n")?;
}
if let Some(p) = &mut context.plugin {
p.on_binding_start(&signal.value, context.writer)?;
}
if let Some(value) = self.resolve_value(&signal.value, context) {
self.write_signal_value(&value, signal.raw, context.writer)?;
}
if let Some(p) = &mut context.plugin {
p.on_binding_end(&signal.value, context.writer)?;
}
Ok(())
}
fn write_signal_value(
&self,
value: &Value,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if raw {
match value {
Value::String(s) => writer.write(s),
_ => writer.write(&value.to_string()),
}
} else {
match value {
Value::String(s) => writer.write(&html_escape::encode_safe(s)),
_ => {
let s = value.to_string();
writer.write(&html_escape::encode_safe(&s))
}
}
}
}
fn process_if(
&self,
if_cond: &webui_protocol::WebUIFragmentIf,
context: &mut WebUIProcessContext,
) -> Result<()> {
let condition = if_cond
.condition
.as_ref()
.ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
let condition_met = self.evaluate_condition(condition, context)?;
if let Some(p) = &mut context.plugin {
p.on_if_start(&if_cond.fragment_id, context.writer)?;
}
if condition_met {
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(&if_cond.fragment_id, context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
}
if let Some(p) = &mut context.plugin {
p.on_if_end(&if_cond.fragment_id, context.writer)?;
}
Ok(())
}
fn process_attribute(
&self,
attr: &webui_protocol::WebUIFragmentAttribute,
context: &mut WebUIProcessContext,
) -> Result<()> {
if attr.attr_start {
context.component_attrs = HashMap::new();
}
if let Some(condition) = &attr.condition_tree {
let condition_met = self.evaluate_condition(condition, context)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::Bool(condition_met));
}
if condition_met {
context.writer.write(" ")?;
context.writer.write(&attr.name)?;
}
return Ok(());
}
if !attr.template.is_empty() {
let raw_value = self.render_template_attr_value(&attr.template, context)?;
let escaped = html_escape::encode_safe(&raw_value);
write_attr(context.writer, &attr.name, &escaped)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::String(raw_value));
}
return Ok(());
}
if !attr.value.is_empty() {
if attr.raw_value {
write_attr(context.writer, &attr.name, &attr.value)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::String(attr.value.clone()));
}
} else if attr.complex {
if let Some(value) = self.resolve_value(&attr.value, context) {
if !attr.attr_skip {
let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
let name = component_attr_name(stripped);
context.component_attrs.insert(name, value);
}
}
} else {
let value = self.resolve_value(&attr.value, context);
match &value {
Some(Value::String(s)) => {
write_attr(context.writer, &attr.name, &html_escape::encode_safe(s))?;
}
Some(Value::Null) | None => {
write_attr(context.writer, &attr.name, "")?;
}
Some(other) => {
let s = other.to_string();
write_attr(context.writer, &attr.name, &s)?;
}
}
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, value.unwrap_or(Value::String(String::new())));
}
}
}
Ok(())
}
fn render_template_attr_value(
&self,
template_id: &str,
context: &WebUIProcessContext,
) -> Result<String> {
let fragments = context
.protocol
.fragments
.get(template_id)
.ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
let mut raw_value = String::new();
for frag in &fragments.fragments {
match frag.fragment.as_ref() {
Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
Some(Fragment::Signal(signal)) => {
if let Some(value) = self.resolve_value(&signal.value, context) {
match &value {
Value::String(s) => raw_value.push_str(s),
_ => raw_value.push_str(&value.to_string()),
}
}
}
_ => {}
}
}
Ok(raw_value)
}
pub fn render(
&self,
protocol: &WebUIProtocol,
state: &Value,
options: &RenderOptions<'_>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let mut context = WebUIProcessContext {
protocol,
state,
depth: 0,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: options.request_path.to_string(),
route_base: "/".to_string(),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id: options.entry_id.to_string(),
nonce: options.nonce.map(String::from),
};
self.process_fragment_id(options.entry_id, &mut context)?;
Ok(())
}
}
impl Default for WebUIHandler {
fn default() -> Self {
Self::new()
}
}
fn collect_reachable_components<'a>(
fragments: &'a [WebUIFragment],
reachable: &mut HashSet<String>,
stack: &mut Vec<&'a str>,
) {
for node in fragments {
match &node.fragment {
Some(Fragment::Component(comp)) => {
if reachable.insert(comp.fragment_id.clone()) {
stack.push(&comp.fragment_id);
}
}
Some(Fragment::IfCond(ic)) => {
if reachable.insert(ic.fragment_id.clone()) {
stack.push(&ic.fragment_id);
}
}
Some(Fragment::ForLoop(fl)) => {
if reachable.insert(fl.fragment_id.clone()) {
stack.push(&fl.fragment_id);
}
}
Some(Fragment::Route(_)) => {
}
_ => {}
}
}
}
fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
writer.write(" ")?;
writer.write(name)?;
writer.write("=\"")?;
writer.write(value)?;
writer.write("\"")
}
pub fn handle(
protocol: &WebUIProtocol,
state: &Value,
options: &RenderOptions<'_>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let handler = WebUIHandler::new();
handler.handle(protocol, state, options, writer)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use webui_protocol::{
web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
WebUIFragmentAttribute,
};
use webui_test_utils::test_json;
struct TestWriter {
content: RefCell<String>,
ended: RefCell<bool>,
}
impl TestWriter {
fn new() -> Self {
Self {
content: RefCell::new(String::new()),
ended: RefCell::new(false),
}
}
fn get_content(&self) -> String {
self.content.borrow().clone()
}
fn is_ended(&self) -> bool {
*self.ended.borrow()
}
}
impl ResponseWriter for TestWriter {
fn write(&mut self, content: &str) -> Result<()> {
self.content.borrow_mut().push_str(content);
Ok(())
}
fn end(&mut self) -> Result<()> {
*self.ended.borrow_mut() = true;
Ok(())
}
}
#[test]
fn test_handle_raw() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle raw protocol"
);
assert_eq!(writer.get_content(), "Hello, WebUI!");
assert!(writer.is_ended());
}
#[test]
fn test_handle_signal() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello, "),
WebUIFragment::signal("name", false),
WebUIFragment::raw("!"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "WebUI"});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle signal protocol"
);
assert_eq!(writer.get_content(), "Hello, WebUI!");
assert!(writer.is_ended());
}
#[test]
fn test_handle_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("People: "),
WebUIFragment::for_loop("person", "people", "person-item"),
],
},
);
fragments.insert(
"person-item".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::signal("person.name", false),
WebUIFragment::raw(", "),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"people": [
{"name": "Alice"},
{"name": "Bob"},
{"name": "Charlie"}
]
});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle for loop protocol"
);
assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
assert!(writer.is_ended());
}
#[test]
fn test_handle_if_condition() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Status: "),
WebUIFragment::if_cond(
webui_protocol::ConditionExpr::identifier("isActive"),
"active-content",
),
WebUIFragment::raw("End"),
],
},
);
fragments.insert(
"active-content".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("Active")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state_true = test_json!({"isActive": true});
let mut writer_true = TestWriter::new();
assert!(
handle(
&protocol,
&state_true,
&RenderOptions::new("index.html", "/"),
&mut writer_true
)
.is_ok(),
"Failed to handle if condition (true case)"
);
assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
assert!(writer_true.is_ended());
let state_false = test_json!({"isActive": false});
let mut writer_false = TestWriter::new();
assert!(
handle(
&protocol,
&state_false,
&RenderOptions::new("index.html", "/"),
&mut writer_false
)
.is_ok(),
"Failed to handle if condition (false case)"
);
assert_eq!(writer_false.get_content(), "Status: End");
assert!(writer_false.is_ended());
}
#[test]
fn test_handle_component() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Component: "),
WebUIFragment::component("my-component"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle component protocol"
);
assert_eq!(
writer.get_content(),
"Component: <div>Component Content</div>"
);
assert!(writer.is_ended());
}
#[test]
fn test_missing_fragment() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("missing-component")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
let result = handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
);
assert!(result.is_err());
if let Err(HandlerError::MissingFragment(fragment_id)) = result {
assert_eq!(fragment_id, "missing-component");
} else {
panic!("Expected MissingFragment error");
}
}
#[test]
fn test_missing_signal_renders_empty() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello, "),
WebUIFragment::signal("missing_field", false),
WebUIFragment::raw("!"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Missing signal should not produce an error"
);
assert_eq!(writer.get_content(), "Hello, !");
assert!(writer.is_ended());
}
#[test]
fn test_boolean_attr_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("isDisabled"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button disabled>Click</button>");
}
#[test]
fn test_boolean_attr_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("isDisabled"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button>Click</button>");
}
#[test]
fn test_boolean_attr_missing() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input type=\"checkbox\""),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
}
#[test]
fn test_boolean_attr_multiple() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input type=\"checkbox\""),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("disabled"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": true, "disabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
}
#[test]
fn test_attribute_with_value() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute("value", "inputValue"),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"inputValue": "Hello"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input value=\"Hello\">");
}
#[test]
fn test_attribute_with_falsy_numeric() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div name=\"test\""),
WebUIFragment::attribute("handle", "number"),
WebUIFragment::raw("></div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"number": 0});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div name=\"test\" handle=\"0\"></div>"
);
}
#[test]
fn test_mixed_attribute_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_template("value", "attr-1"),
WebUIFragment::raw(">"),
],
},
);
fragments.insert(
"attr-1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("hello "),
WebUIFragment::signal("item", false),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "world"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input value=\"hello world\">");
}
#[test]
fn test_raw_signal_not_escaped() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::signal("html", false),
WebUIFragment::signal("html", true),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"html": "<strong>hi</strong>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<strong>hi</strong><strong>hi</strong>"
);
}
#[test]
fn test_nested_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outer".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"inner".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"innerItems": [{"name": "A"}, {"name": "B"}]},
{"innerItems": [{"name": "C"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
);
}
#[test]
fn test_nested_for_with_signals() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("innerItem.name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
{"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
);
}
#[test]
fn test_nested_for_with_global_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::signal("globalOuter", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("innerItem.name", false),
WebUIFragment::signal("globalInner", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalOuter": "GO",
"globalInner": "GI",
"outerItems": [
{"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
{"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
);
}
#[test]
fn test_if_in_for_uses_local_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
},
);
fragments.insert(
"item-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("item.visible"),
"visible-tpl",
)],
},
);
fragments.insert(
"visible-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item.name", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "Show");
}
#[test]
fn test_for_if_local_overrides_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
},
);
fragments.insert(
"item-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("item.flag"),
"show-tpl",
)],
},
);
fragments.insert(
"show-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("yes")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "yes");
}
#[test]
fn test_component_attr_state_simple() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
attr_start: true,
raw_value: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
);
}
#[test]
fn test_component_attr_state_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "title-attr".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"title-attr".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("hello "),
WebUIFragment::signal("item", false),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "<world>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
);
}
#[test]
fn test_component_attr_camel_case() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-title".into(),
template: "dt-attr".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"dt-attr".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("prefix "),
WebUIFragment::signal("item", false),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("dataTitle", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "a&b"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
);
}
#[test]
fn test_component_complex_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "complexItem".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.foo", false),
WebUIFragment::raw("</span><p>"),
WebUIFragment::signal("item.bar", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp><span>1</span><p>true</p></my-comp>"
);
}
#[test]
fn test_component_no_parent_pollution() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "var".into(),
value: "var".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent"),
WebUIFragment::raw("</parent>"),
],
},
);
fragments.insert(
"parent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Before: "),
WebUIFragment::signal("var", false),
WebUIFragment::raw("<child foo"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "var".into(),
value: "replaced".into(),
raw_value: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child"),
WebUIFragment::raw("Label</child>After: "),
WebUIFragment::signal("var", false),
],
},
);
fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"var": "original"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
);
}
#[test]
fn test_component_boolean_attr_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"show",
)],
},
);
fragments.insert(
"show".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("disabled!")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp disabled>disabled!</my-comp>"
);
}
fn render_signal(value: &str) -> String {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("v", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"v": value});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
writer.get_content()
}
#[test]
fn test_escape_ampersand() {
assert_eq!(render_signal("&"), "&");
}
#[test]
fn test_escape_less_than() {
assert_eq!(render_signal("<"), "<");
}
#[test]
fn test_escape_greater_than() {
assert_eq!(render_signal(">"), ">");
}
#[test]
fn test_escape_double_quote() {
assert_eq!(render_signal("\""), """);
}
#[test]
fn test_escape_single_quote() {
let result = render_signal("'");
assert!(
result == "'" || result == "'" || result == "'",
"Expected escaped single quote, got: {}",
result
);
}
#[test]
fn test_escape_multiple_special_chars() {
let result = render_signal("<script>alert('xss');</script>");
assert!(
result.contains("<") && result.contains(">"),
"Expected escaped HTML, got: {}",
result
);
assert!(
!result.contains("<script>"),
"Should not contain raw <script> tag"
);
}
#[test]
fn test_escape_no_special_chars() {
assert_eq!(render_signal("Hello World"), "Hello World");
}
#[test]
fn test_escape_empty_string() {
assert_eq!(render_signal(""), "");
}
#[test]
fn test_escape_special_at_beginning() {
let result = render_signal("<Hello");
assert!(
result.starts_with("<"),
"Expected < at start, got: {}",
result
);
}
#[test]
fn test_escape_special_at_end() {
let result = render_signal("Hello>");
assert!(
result.ends_with(">"),
"Expected > at end, got: {}",
result
);
}
#[test]
fn test_escape_special_in_middle() {
let result = render_signal("Hel&lo");
assert!(
result.contains("&"),
"Expected & in middle, got: {}",
result
);
}
#[test]
fn test_boolean_attr_truthy_values() {
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": 1});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": "yes"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": {}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": "false"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
}
#[test]
fn test_boolean_attr_falsy_values() {
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": 0});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": ""});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
}
#[test]
fn test_boolean_attr_expression_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"itemCount": 5});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button disabled>Click</button>");
}
#[test]
fn test_boolean_attr_expression_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"itemCount": 3});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button>Click</button>");
}
#[test]
fn test_nested_component_attr_capture() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "parent-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello "),
WebUIFragment::signal("who", false),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "child-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Child of "),
WebUIFragment::signal("title", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"who": "<world>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
);
}
#[test]
fn test_grandchild_attr_propagation() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "p-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"p-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "c-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"c-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("C("),
WebUIFragment::signal("title", false),
WebUIFragment::raw(")-"),
WebUIFragment::signal("cExtra", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<grandchild-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("grandchild-component"),
WebUIFragment::raw("</grandchild-component>"),
],
},
);
fragments.insert(
"grandchild-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"p": "<p>", "cExtra": "x&y"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"P:<p>\"><child-component title=\"C(P:<p>)-x&y\"><grandchild-component title=\"C(P:<p>)-x&y\"><span>C(P:<p>)-x&y</span></grandchild-component></child-component></parent-component>"
);
}
#[test]
fn test_for_loop_component_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "parent-title-loop".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-title-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Parent:"),
WebUIFragment::signal("who", false),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
},
);
fragments.insert(
"child-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "child-title-loop".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-title-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hi "),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("title", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A<1> / Parent:Bob\"><span>Hi A<1> / Parent:Bob</span></child-component><child-component title=\"Hi B&2 / Parent:Bob\"><span>Hi B&2 / Parent:Bob</span></child-component></parent-component>"
);
}
#[test]
fn test_multiple_template_attrs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "attr-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-title".into(),
template: "attr-data-title".into(),
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "aria-label".into(),
template: "attr-aria-label".into(),
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"attr-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
},
);
fragments.insert(
"attr-data-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
},
);
fragments.insert(
"attr-aria-label".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("|"),
WebUIFragment::signal("dataTitle", false),
WebUIFragment::raw("|"),
WebUIFragment::signal("ariaLabel", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"T:<t&1>\" data-title=\"D:d<2>\" aria-label=\"A:a&3\"><span>T:<t&1>|D:d<2>|A:a&3</span></my-component>"
);
}
#[test]
fn test_attr_priority_over_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
);
}
#[test]
fn test_attr_priority_over_local_and_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
},
);
fragments.insert(
"loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
);
}
#[test]
fn test_boolean_attr_first_component_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "label".into(),
value: "Component Label".into(),
raw_value: true,
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::raw("<span>"),
WebUIFragment::signal("label", false),
WebUIFragment::raw("</span>"),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
);
}
#[test]
fn test_hyphenated_attr_camelcase() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "key-hyphen".into(),
value: "Local Value".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("keyHyphen", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"keyHyphen": "Global Value"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
);
}
#[test]
fn test_skipped_component_attrs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<test-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "class".into(),
value: "skippedClass".into(),
attr_start: true,
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "style".into(),
value: "skippedStyle".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "role".into(),
value: "skippedRole".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-testid".into(),
value: "skippedDataTestid".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "aria-label".into(),
value: "skippedAriaLabel".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("test-component"),
WebUIFragment::raw("</test-component>"),
],
},
);
fragments.insert(
"test-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("class", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("style", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("role", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("dataTestid", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("ariaLabel", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"title": "Hello",
"skippedClass": "my-class",
"skippedStyle": "color:red",
"skippedRole": "button",
"skippedDataTestid": "test-id",
"skippedAriaLabel": "label-text"
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_attr_inherit_parent_to_child() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Parent Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h1><child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h2>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h2>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
);
}
#[test]
fn test_attr_inherit_deep() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Parent Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Child Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<grandchild-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("grandchild-component"),
WebUIFragment::raw("</grandchild-component>"),
],
},
);
fragments.insert(
"grandchild-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h3>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h3>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_complex_attr_access() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "complexItem".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.foo", false),
WebUIFragment::raw("</span><p>"),
WebUIFragment::signal("item.bar", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component><span>1</span><p>true</p></my-component>"
);
}
#[test]
fn test_complex_attr_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"item",
"list.items",
"listTemplate",
)],
},
);
fragments.insert(
"listTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "item".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::component("item_component"),
],
},
);
fragments.insert(
"item_component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
}
#[test]
fn test_complex_attr_nested_for() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"outer",
"data.outer",
"outerTemplate",
)],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"middle",
"outer.middle",
"middleTemplate",
)],
},
);
fragments.insert(
"middleTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"inner",
"middle.inner",
"innerTemplate",
)],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<card"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":outer".into(),
value: "outer".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":middle".into(),
value: "middle".into(),
attr_start: false,
complex: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":inner".into(),
value: "inner".into(),
attr_start: false,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("card_component"),
WebUIFragment::raw("</card>"),
],
},
);
fragments.insert(
"card_component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("outer.label", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("middle.label", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("inner.label", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"data": {"outer": [
{"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
{"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
]}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
);
}
#[test]
fn test_bool_component_state_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"enabledTemplate",
),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
},
);
fragments.insert(
"enabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component disabled><span>Disabled</span></my-component>"
);
}
#[test]
fn test_bool_component_state_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"enabledTemplate",
),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
},
);
fragments.insert(
"enabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component><span>Enabled</span></my-component>"
);
}
#[test]
fn test_bool_component_state_forward() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"parentDisabledTemplate",
),
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("disabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"parentDisabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"childDisabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"childEnabledTemplate",
),
],
},
);
fragments.insert(
"childDisabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
},
);
fragments.insert(
"childEnabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
},
);
{
let protocol = WebUIProtocol::new(fragments.clone());
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
);
}
{
let protocol = WebUIProtocol::new(fragments.clone());
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
);
}
}
#[test]
fn test_component_with_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element>"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("</custom-element>"),
],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<custom-element><div>Custom Element</div></custom-element>"
);
assert!(writer.is_ended());
}
#[test]
fn test_component_with_slots() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("Hello World</custom-element>"),
],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<slot></slot>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
);
assert!(writer.is_ended());
}
#[test]
fn test_multiple_nested_components() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateRepeat"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"custom-button".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<slot></slot>")],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-child>"),
WebUIFragment::component("custom-child"),
WebUIFragment::raw("</custom-child><slot></slot>"),
],
},
);
fragments.insert(
"custom-child".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
},
);
fragments.insert(
"templateRepeat".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element>"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("<custom-button>"),
WebUIFragment::component("custom-button"),
WebUIFragment::raw("Ok</custom-button></custom-element>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Item1"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
);
assert!(writer.is_ended());
}
#[test]
fn test_if_with_binary_expression() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(
ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
"if-1",
),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"if-1".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state_true = test_json!({"x": 10});
let mut writer_true = TestWriter::new();
handle(
&protocol,
&state_true,
&RenderOptions::new("index.html", "/"),
&mut writer_true,
)
.unwrap();
assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
let state_false = test_json!({"x": 1});
let mut writer_false = TestWriter::new();
handle(
&protocol,
&state_false,
&RenderOptions::new("index.html", "/"),
&mut writer_false,
)
.unwrap();
assert_eq!(writer_false.get_content(), "<div></div>");
}
#[test]
fn test_for_if_overlapping_local_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifBlock".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.label", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"flag": false,
"items": [
{"label": "A", "flag": true},
{"label": "B", "flag": false},
{"label": "C", "flag": true}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
);
}
#[test]
fn test_for_if_global_flag_no_effect() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifBlock".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.label", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"flag": true},
"items": [
{"label": "A", "flag": false},
{"label": "B", "flag": true},
{"label": "C", "flag": false}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div></div><div><span>B</span></div><div></div></div>"
);
}
#[test]
fn test_recursive_template_refs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
},
);
fragments.insert(
"static".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div expanded=\""),
WebUIFragment::signal("item.expanded", false),
WebUIFragment::raw("\" class=\""),
WebUIFragment::signal("testScenario", false),
WebUIFragment::raw("\"><span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("</span>"),
WebUIFragment::for_loop("item", "item.children", "static"),
WebUIFragment::raw("</div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"testScenario": "RecursiveTemplatesWithGlobalState",
"items": [
{"name": "A", "expanded": "false", "children": []},
{"name": "B", "expanded": "true", "children": [
{"name": "C", "expanded": "false"},
{"name": "D", "expanded": "false"}
]},
{"name": "E", "expanded": "false"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_component_in_for_no_local_access() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
);
}
#[test]
fn test_nested_for_hierarchical_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::raw(": "),
WebUIFragment::signal("innerItem.innerLabel", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "Prefix: ",
"outerItems": [
{"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
{"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_component_in_for_global_only() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state =
test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
);
}
#[test]
fn test_component_no_item_moniker() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state =
test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
);
}
#[test]
fn test_for_nonqualified_uses_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><span>GlobalName</span><span>GlobalName</span></div>"
);
}
#[test]
fn test_nested_for_if_interleaved() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::if_cond(
ConditionExpr::identifier("outerItem.include"),
"ifTemplate",
),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw(": "),
WebUIFragment::signal("innerItem.innerLabel", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "Prefix: ",
"globalSuffix": "Suffix",
"outerItems": [
{"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
{"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
{"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_nested_for_if_outer_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.label", false),
WebUIFragment::for_loop(
"middleItem",
"outerItem.middleItems",
"middleTemplate",
),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"middleTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(
ConditionExpr::identifier("outerItem.active"),
"ifTemplate",
),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("middleItem.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "GP-",
"outerItems": [
{"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
{"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
{"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<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>"
);
}
#[test]
fn test_nested_for_if_inner_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("outerItem.label", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<article>"),
WebUIFragment::if_cond(
ConditionExpr::identifier("innerItem.show"),
"ifTemplate",
),
WebUIFragment::raw("</article>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("innerItem.detail", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
{"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
);
}
#[test]
fn test_for_merge_local_global_monikers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.globalValue", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.localOnly", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.otherVal", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"globalValue": "GLOBAL", "otherVal": "other"},
"items": [
{"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
{"name": "Local2", "localOnly": "Only2"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
);
}
#[test]
fn test_component_in_for_global_moniker_shadow() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.globalValue", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("localOnly", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.otherVal", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"globalValue": "GLOBAL", "otherVal": "other"},
"items": [
{"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
{"name": "Local2", "localOnly": "Only2"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
);
}
#[test]
fn test_if_in_nested_for_local_flag() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("inner_item.flag"),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner_item.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
"inner_item": {"flag": false}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section><p>X</p></section></div>"
);
}
#[test]
fn test_if_in_nested_for_global_fallback() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("inner_item.flag"),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner_item.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
"inner_item": {"flag": true}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section><p>X</p><p>Y</p></section></div>"
);
}
#[test]
fn test_if_mixed_for_monikers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("outer.outerLabel", false),
WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::compound(
ConditionExpr::identifier("outer.active"),
LogicalOperator::And,
ConditionExpr::predicate(
"inner.value",
ComparisonOperator::GreaterThan,
"globalLimit",
),
),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalLimit": 10,
"list": {"outerItems": [
{"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
{"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
{"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
]}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
);
}
fn make_route_protocol() -> WebUIProtocol {
use webui_protocol::WebUiFragmentRoute;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>Shell</h1>"),
WebUIFragment::route_from(WebUiFragmentRoute {
path: "/".into(),
fragment_id: "dash-page".into(),
exact: true,
..Default::default()
}),
WebUIFragment::route_from(WebUiFragmentRoute {
path: "/contacts/:id".into(),
fragment_id: "detail-page".into(),
exact: true,
..Default::default()
}),
],
},
);
fragments.insert(
"dash-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
},
);
fragments.insert(
"detail-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
},
);
WebUIProtocol::new(fragments)
}
fn make_nested_route_protocol() -> WebUIProtocol {
use webui_protocol::WebUiFragmentRoute;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
path: "/".into(),
fragment_id: "app-shell".into(),
exact: false,
children: vec![WebUiFragmentRoute {
path: "sections/:id".into(),
fragment_id: "section-comp".into(),
exact: false,
children: vec![WebUiFragmentRoute {
path: "topics/:topicId".into(),
fragment_id: "topic-comp".into(),
exact: true,
children: vec![],
}],
}],
})],
},
);
fragments.insert(
"app-shell".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>Shell</h1>"),
WebUIFragment::outlet(),
],
},
);
fragments.insert(
"section-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h2>Section</h2>"),
WebUIFragment::outlet(),
],
},
);
fragments.insert(
"topic-comp".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
},
);
WebUIProtocol::new(fragments)
}
#[test]
fn test_route_renders_shell_always() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(html.contains("<h1>Shell</h1>"), "shell should render");
assert!(html.contains(" active>"), "matched route should be active");
assert!(
html.contains("style=\"display:none\""),
"non-matched routes should be hidden"
);
}
#[test]
fn test_route_matched_renders_visible() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<webui-route path=\"/\""),
"dashboard route should exist"
);
assert!(
html.contains("active>") && html.contains("<dash-page>"),
"matched route should be active with component tag: {html}"
);
assert!(
html.contains("<p>Dashboard</p>"),
"matched route should have content"
);
}
#[test]
fn test_route_non_matched_renders_hidden_empty() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<webui-route path=\"/contacts/:id\""),
"detail route element should exist"
);
let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
let after_detail = &html[detail_start..];
assert!(
after_detail.contains("style=\"display:none\">")
&& !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
"non-matched route should be hidden: {after_detail}"
);
let detail_end = after_detail.find("</webui-route>").expect("closing tag");
let detail_body = &after_detail[..detail_end];
assert!(
!detail_body.contains("<detail-page>"),
"non-matched route should not render component content: {detail_body}"
);
}
#[test]
fn test_route_parameterized_match() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/contacts/42"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("active>") && html.contains("<detail-page>"),
"detail route should be active: {html}"
);
assert!(html.contains("<p>Detail</p>"), "detail should have content");
let dash_start = html
.find("component=\"dash-page\"")
.expect("dashboard route");
let after_dash = &html[dash_start..];
assert!(
after_dash.contains("style=\"display:none\">"),
"dashboard should be hidden when detail matches: {after_dash}"
);
let dash_end = after_dash.find("</webui-route>").expect("closing tag");
let dash_body = &after_dash[..dash_end];
assert!(
!dash_body.contains("<dash-page>"),
"dashboard should not render component content: {dash_body}"
);
}
#[test]
fn test_route_no_match_all_hidden_empty() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/nonexistent"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(html.contains("<h1>Shell</h1>"));
assert!(
!html.contains("<p>Dashboard</p>"),
"no route content when nothing matches"
);
assert!(
!html.contains("<p>Detail</p>"),
"no route content when nothing matches"
);
}
#[test]
fn test_route_component_attr_emitted() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("component=\"dash-page\""),
"component attr should be on webui-route: {html}"
);
assert!(
html.contains("component=\"detail-page\""),
"component attr should be on webui-route: {html}"
);
}
#[test]
fn test_no_plugin_no_state_attributes() {
let protocol = make_route_protocol();
let state = test_json!({
"title": "Fish & Chips",
"cartOpen": true,
"items": [{"name": "A&B"}]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
!html.contains("data-state"),
"no data-state without plugin: {html}"
);
assert!(
!html.contains(r#"title="Fish"#),
"no scalar attrs without plugin: {html}"
);
}
#[test]
fn test_nested_routes_render_webui_route_as_light_dom() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root route should be active: {html}"
);
assert!(
!html.contains("<template shadowrootmode"),
"webui-route should be light DOM (no shadow template): {html}"
);
}
#[test]
fn test_nested_routes_render_outlet_as_light_dom() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
!html.contains("<webui-outlet>"),
"should not contain webui-outlet wrapper: {html}"
);
assert!(
html.contains("<webui-route"),
"should contain webui-route elements: {html}"
);
}
#[test]
fn test_nested_routes_match_child_at_outlet() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"section-comp\"") && html.contains("active>"),
"section route should be active: {html}"
);
assert!(
html.contains("<h2>Section</h2>"),
"section content should be present: {html}"
);
}
#[test]
fn test_nested_routes_three_levels_deep() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend/topics/react"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root active: {html}"
);
assert!(
html.contains("component=\"section-comp\"") && html.contains("active>"),
"section active: {html}"
);
assert!(
html.contains("component=\"topic-comp\"") && html.contains("exact active>"),
"topic active: {html}"
);
assert!(
html.contains("<p>Topic content</p>"),
"leaf content present: {html}"
);
}
#[test]
fn test_nested_routes_nonmatched_siblings_hidden() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains(r#"component="topic-comp" style="display:none">"#),
"topic should be hidden: {html}"
);
}
#[test]
fn test_nested_routes_root_only() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root active at /: {html}"
);
assert!(
html.contains("<h1>Shell</h1>"),
"shell renders at /: {html}"
);
assert!(
html.contains(r#"component="section-comp" style="display:none">"#),
"section hidden at /: {html}"
);
}
#[test]
fn test_css_module_emitted_once_before_component() {
let template = r#"<p><slot></slot></p>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("A".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("B</div>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("my-card".to_string())
.or_default()
.css = "p{color:red}".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let count = html
.matches(r#"<style type="module" specifier="my-card">"#)
.count();
assert_eq!(
count, 1,
"CSS module should be emitted once, got {count} in: {html}"
);
let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
assert_eq!(
tmpl_count, 2,
"Template should render twice, got {tmpl_count} in: {html}"
);
let css_pos = html
.find(r#"<style type="module""#)
.expect("CSS module missing");
let tmpl_pos = html
.find(r#"<p><slot></slot></p>"#)
.expect("template missing");
assert!(
css_pos < tmpl_pos,
"CSS module should precede first component: {html}"
);
}
#[test]
fn test_component_without_css_renders_normally() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-card")],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<p>hello</p>"),
"Non-module component should render normally: {html}"
);
}
#[test]
fn test_non_module_strategy_no_css_in_head() {
let template = r#"<p>hello</p>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
!html.contains(r#"<style type="module""#),
"Non-module strategy should not emit CSS modules in <head>: {html}"
);
assert!(
html.contains("<p>hello</p>"),
"Component should still render: {html}"
);
}
#[test]
fn test_css_module_emitted_in_component_light_dom() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-card>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</my-card>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("my-card".to_string())
.or_default()
.css = "p{color:red}".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let tag_open = html.find("<my-card>").expect("<my-card> missing");
let css_pos = html
.find(r#"<style type="module""#)
.expect("CSS module missing");
let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
assert!(
css_pos > tag_open && css_pos < tag_close,
"CSS module should be inside component light DOM: {html}"
);
}
#[test]
fn test_css_module_emitted_for_route_components() {
let template = r#"<h1>Dashboard</h1>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::route("/", "dash-page")],
},
);
fragments.insert(
"dash-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("dash-page".to_string())
.or_default()
.css = "h1{font-size:2rem}".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(
r#"<style type="module" specifier="dash-page">h1{font-size:2rem}</style>"#
),
"Route component should have CSS module: {html}"
);
assert!(
html.contains("<h1>Dashboard</h1>"),
"Route component should render content: {html}"
);
}
#[test]
fn test_head_css_link_skipped_for_components_without_css() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::component("has-css"),
WebUIFragment::component("no-css"),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"has-css".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
},
);
fragments.insert(
"no-css".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("has-css".to_string())
.or_default()
.css_href = "/has-css.css".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| {
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
});
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(r#"<link rel="stylesheet" href="/has-css.css">"#),
"Component with CSS should get a <link> in <head>: {html}"
);
assert!(
!html.contains("no-css.css"),
"Component without CSS must NOT get a <link> in <head>: {html}"
);
}
}