use crate::format::{IrError, IslandTrigger, Opcode, PropsMode, SlotSource};
use crate::parser::IrModule;
use crate::slot::{SlotData, SlotValue};
struct IslandPending {
id: u16,
component_name: String,
inline_props: Option<String>,
trigger: IslandTrigger,
}
struct WalkState {
list_depth: u8,
fallback_stack: Vec<usize>,
pending_island: Option<IslandPending>,
pending_list_key: Option<String>,
script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
pending_tag_close: bool,
}
impl WalkState {
fn new() -> Self {
WalkState {
list_depth: 0,
fallback_stack: Vec::new(),
pending_island: None,
pending_list_key: None,
script_tag_props: std::collections::BTreeMap::new(),
pending_tag_close: false,
}
}
}
fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
if let Some(entry) = module.slots.entries().iter().find(|e| e.slot_id == slot_id) {
if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
#[cfg(feature = "tracing")]
tracing::warn!(
slot_id = slot_id,
"Server-sourced slot has no value at render time — handler may have a bug"
);
}
}
}
pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(len * 4); let mut state = WalkState::new();
let mut slots_mut = slots.clone();
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
if !state.script_tag_props.is_empty() {
let json =
serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
out.push_str("<script id=\"__forma_islands\" type=\"application/json\">");
out.push_str(&json.replace("</", "<\\/"));
out.push_str("</script>");
}
Ok(out)
}
pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
let entry = module
.islands
.entries()
.iter()
.find(|e| e.id == island_id)
.ok_or(IrError::IslandNotFound(island_id))?;
let byte_offset = entry.byte_offset as usize;
let ops = &module.opcodes;
if byte_offset >= ops.len() {
return Err(IrError::BufferTooShort {
expected: byte_offset + 1,
actual: ops.len(),
});
}
let opcode = Opcode::from_byte(ops[byte_offset])?;
if opcode != Opcode::IslandStart {
return Err(IrError::InvalidOpcode(ops[byte_offset]));
}
let content_start = byte_offset + 3;
if content_start > ops.len() {
return Err(IrError::BufferTooShort {
expected: content_start,
actual: ops.len(),
});
}
let mut out = String::with_capacity(256);
let mut state = WalkState::new();
let mut slots_mut = slots.clone();
walk_range_until_island_end(
module,
&mut slots_mut,
ops,
content_start,
island_id,
&mut out,
&mut state,
0,
)?;
Ok(out)
}
const MAX_LIST_DEPTH: u8 = 4;
const MAX_RECURSION_DEPTH: usize = 64;
enum WalkMode {
Range { end: usize },
UntilIslandEnd { target_island_id: u16 },
}
#[allow(clippy::too_many_arguments)]
fn walk_range(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
end: usize,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::Range { end },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_until_island_end(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
target_island_id: u16,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::UntilIslandEnd { target_island_id },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_impl(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
mode: WalkMode,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
if depth > MAX_RECURSION_DEPTH {
return Err(IrError::RecursionLimitExceeded);
}
let strings = &module.strings;
let mut pos = start;
let loop_bound = match mode {
WalkMode::Range { end } => end,
WalkMode::UntilIslandEnd { .. } => usize::MAX, };
while pos < loop_bound {
if pos >= ops.len() {
match mode {
WalkMode::Range { .. } => {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
WalkMode::UntilIslandEnd { .. } => break, }
}
let opcode = Opcode::from_byte(ops[pos])?;
pos += 1;
if state.pending_tag_close && opcode != Opcode::DynAttr {
out.push('>');
state.pending_tag_close = false;
}
match opcode {
Opcode::OpenTag => {
let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
let tag = strings.get(tag_str_idx)?;
out.push('<');
out.push_str(tag);
for (key, val) in &attrs {
if val.is_empty() {
out.push(' ');
out.push_str(key);
} else {
out.push(' ');
out.push_str(key);
out.push_str("=\"");
push_escaped_attr(out, val);
out.push('"');
}
}
if let Some(island) = state.pending_island.take() {
out.push_str(" data-forma-island=\"");
out.push_str(&island.id.to_string());
out.push_str("\" data-forma-component=\"");
push_escaped_attr(out, &island.component_name);
out.push_str("\" data-forma-status=\"pending\"");
let trigger_str = match island.trigger {
IslandTrigger::Load => "load",
IslandTrigger::Visible => "visible",
IslandTrigger::Interaction => "interaction",
IslandTrigger::Idle => "idle",
};
out.push_str(" data-forma-hydrate=\"");
out.push_str(trigger_str);
out.push('"');
if let Some(ref props_json) = island.inline_props {
out.push_str(" data-forma-props=\"");
push_escaped_attr(out, props_json);
out.push('"');
}
}
if let Some(key) = state.pending_list_key.take() {
out.push_str(" data-forma-key=\"");
push_escaped_attr(out, &key);
out.push('"');
}
state.pending_tag_close = true;
pos = new_pos;
}
Opcode::CloseTag => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let tag = strings.get(str_idx)?;
out.push_str("</");
out.push_str(tag);
out.push('>');
}
Opcode::VoidTag => {
let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
let tag = strings.get(tag_str_idx)?;
out.push('<');
out.push_str(tag);
for (key, val) in &attrs {
if val.is_empty() {
out.push(' ');
out.push_str(key);
} else {
out.push(' ');
out.push_str(key);
out.push_str("=\"");
push_escaped_attr(out, val);
out.push('"');
}
}
if let Some(island) = state.pending_island.take() {
out.push_str(" data-forma-island=\"");
out.push_str(&island.id.to_string());
out.push_str("\" data-forma-component=\"");
push_escaped_attr(out, &island.component_name);
out.push_str("\" data-forma-status=\"pending\"");
let trigger_str = match island.trigger {
IslandTrigger::Load => "load",
IslandTrigger::Visible => "visible",
IslandTrigger::Interaction => "interaction",
IslandTrigger::Idle => "idle",
};
out.push_str(" data-forma-hydrate=\"");
out.push_str(trigger_str);
out.push('"');
if let Some(ref props_json) = island.inline_props {
out.push_str(" data-forma-props=\"");
push_escaped_attr(out, props_json);
out.push('"');
}
}
if let Some(key) = state.pending_list_key.take() {
out.push_str(" data-forma-key=\"");
push_escaped_attr(out, &key);
out.push('"');
}
state.pending_tag_close = true;
pos = new_pos;
}
Opcode::Text => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
push_escaped_html(out, text);
}
Opcode::DynText => {
let slot_id = read_u16(ops, pos)?;
pos += 2;
let marker_id = read_u16(ops, pos)?;
pos += 2;
check_slot_source(module, slot_id, slots.get(slot_id));
let value = slots.get(slot_id).to_text();
out.push_str("<!--f:t");
out.push_str(&marker_id.to_string());
out.push_str("-->");
if value.is_empty() {
out.push('\u{200B}');
} else {
push_escaped_html(out, &value);
}
out.push_str("<!--/f:t");
out.push_str(&marker_id.to_string());
out.push_str("-->");
}
Opcode::DynAttr => {
let attr_str_idx = read_u32(ops, pos)?;
let slot_id = read_u16(ops, pos + 4)?;
pos += 6;
check_slot_source(module, slot_id, slots.get(slot_id));
if state.pending_tag_close {
let attr_name = strings.get(attr_str_idx)?;
let value = slots.get(slot_id).to_text();
if !value.is_empty() {
out.push(' ');
out.push_str(attr_name);
out.push_str("=\"");
push_escaped_attr(out, &value);
out.push('"');
}
}
}
Opcode::IslandStart => {
let island_id = read_u16(ops, pos)?;
pos += 2;
out.push_str("<!--f:i");
out.push_str(&island_id.to_string());
out.push_str("-->");
if let Some(entry) = module.islands.entries().iter().find(|e| e.id == island_id) {
let component_name = strings.get(entry.name_str_idx)?.to_string();
let inline_props = match entry.props_mode {
PropsMode::Inline => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, slots, &entry.slot_ids);
Some(
serde_json::to_string(&props)
.unwrap_or_else(|_| "{}".to_string()),
)
}
}
PropsMode::ScriptTag => {
if !entry.slot_ids.is_empty() {
let props = build_island_props(module, slots, &entry.slot_ids);
state
.script_tag_props
.insert(island_id, serde_json::Value::Object(props));
}
None
}
PropsMode::Deferred => None,
};
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
inline_props,
trigger: entry.trigger,
});
}
}
Opcode::IslandEnd => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if let WalkMode::UntilIslandEnd { target_island_id } = mode {
if island_id == target_island_id {
if state.pending_tag_close {
out.push('>');
state.pending_tag_close = false;
}
return Ok(());
}
}
out.push_str("<!--/f:i");
out.push_str(&island_id.to_string());
out.push_str("-->");
}
Opcode::Comment => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
out.push_str("<!--");
out.push_str(&text.replace("--", "--"));
out.push_str("-->");
}
Opcode::ShowIf => {
let slot_id = read_u16(ops, pos)?;
let then_len = read_u32(ops, pos + 2)? as usize;
let else_len = read_u32(ops, pos + 6)? as usize;
pos += 10;
check_slot_source(module, slot_id, slots.get(slot_id));
let condition = slots.get(slot_id).as_bool();
let then_start = pos;
let then_end = pos + then_len;
let else_start = then_end + 1; let else_end = else_start + else_len;
out.push_str("<!--f:s");
out.push_str(&slot_id.to_string());
out.push_str("-->");
if condition {
walk_range(
module,
slots,
ops,
then_start,
then_end,
out,
state,
depth + 1,
)?;
} else {
walk_range(
module,
slots,
ops,
else_start,
else_end,
out,
state,
depth + 1,
)?;
}
out.push_str("<!--/f:s");
out.push_str(&slot_id.to_string());
out.push_str("-->");
pos = else_end;
}
Opcode::ShowElse => {
}
Opcode::Switch => {
let slot_id = read_u16(ops, pos)?;
let case_count = read_u16(ops, pos + 2)? as usize;
pos += 4;
let mut cases = Vec::with_capacity(case_count);
for _ in 0..case_count {
let val_str_idx = read_u32(ops, pos)?;
let body_len = read_u32(ops, pos + 4)? as usize;
cases.push((val_str_idx, body_len));
pos += 8;
}
let slot_text = slots.get(slot_id).to_text();
let mut body_pos = pos;
for (val_str_idx, body_len) in &cases {
let case_val = strings.get(*val_str_idx)?;
if case_val == slot_text {
walk_range(
module,
slots,
ops,
body_pos,
body_pos + body_len,
out,
state,
depth + 1,
)?;
}
body_pos += body_len;
}
pos = body_pos; }
Opcode::List => {
let slot_id = read_u16(ops, pos)?;
let item_slot_id = read_u16(ops, pos + 2)?;
let body_len = read_u32(ops, pos + 4)? as usize;
pos += 8;
check_slot_source(module, slot_id, slots.get(slot_id));
let body_start = pos;
let body_end = pos + body_len;
let list_marker_id = slot_id;
out.push_str("<!--f:l");
out.push_str(&list_marker_id.to_string());
out.push_str("-->");
let items: Vec<SlotValue> = slots
.get(slot_id)
.as_array()
.map(|a| a.to_vec())
.unwrap_or_default();
if !items.is_empty() {
state.list_depth += 1;
if state.list_depth > MAX_LIST_DEPTH {
return Err(IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH,
});
}
for item in &items {
let mut shadow_slots = slots.clone();
shadow_slots.set(item_slot_id, item.clone());
walk_range(
module,
&mut shadow_slots,
ops,
body_start,
body_end,
out,
state,
depth + 1,
)?;
}
state.list_depth -= 1;
}
out.push_str("<!--/f:l");
out.push_str(&list_marker_id.to_string());
out.push_str("-->");
pos = body_end;
}
Opcode::TryStart => {
let fallback_len = read_u32(ops, pos)? as usize;
pos += 4;
state.fallback_stack.push(fallback_len);
}
Opcode::Fallback => {
if let Some(skip) = state.fallback_stack.pop() {
pos += skip;
}
}
Opcode::Preload => {
if pos >= ops.len() {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
let resource_type = ops[pos];
let url_str_idx = read_u32(ops, pos + 1)?;
let url = strings.get(url_str_idx)?;
pos += 5;
let (as_val, type_attr) = match resource_type {
1 => ("font", " type=\"font/woff2\" crossorigin"),
2 => ("style", ""),
3 => ("script", ""),
4 => ("image", ""),
_ => ("fetch", ""),
};
out.push_str("<link rel=\"preload\" href=\"");
push_escaped_attr(out, url);
out.push_str("\" as=\"");
out.push_str(as_val);
out.push('"');
out.push_str(type_attr);
out.push_str(">\n");
}
Opcode::ListItemKey => {
let key_str_idx = read_u32(ops, pos)?;
pos += 4;
let key_val = strings.get(key_str_idx)?;
state.pending_list_key = Some(key_val.to_string());
}
Opcode::Prop => {
let src_slot_id = read_u16(ops, pos)?;
let prop_str_idx = read_u32(ops, pos + 2)?;
let target_slot_id = read_u16(ops, pos + 6)?;
pos += 8;
let prop_name = strings.get(prop_str_idx)?;
let value = slots.get(src_slot_id).get_property(prop_name);
slots.set(target_slot_id, value);
}
}
}
match mode {
WalkMode::Range { .. } => {
if state.pending_tag_close {
out.push('>');
state.pending_tag_close = false;
}
Ok(())
}
WalkMode::UntilIslandEnd { target_island_id } => {
Err(IrError::IslandNotFound(target_island_id))
}
}
}
fn build_island_props(
module: &IrModule,
slots: &SlotData,
slot_ids: &[u16],
) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for &slot_id in slot_ids {
if let Some(entry) = module.slots.entries().iter().find(|e| e.slot_id == slot_id) {
if let Ok(name) = module.strings.get(entry.name_str_idx) {
let value = slots.get(slot_id).to_json();
map.insert(name.to_string(), value);
}
}
}
map
}
pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
if pos + 2 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 2,
actual: data.len(),
});
}
Ok(u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()))
}
pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
if pos + 4 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 4,
actual: data.len(),
});
}
Ok(u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()))
}
pub(crate) fn read_tag_with_attrs<'a>(
ops: &[u8],
pos: usize,
strings: &'a crate::parser::StringTable,
) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
let tag_str_idx = read_u32(ops, pos)?;
let attr_count = read_u16(ops, pos + 4)? as usize;
let mut cursor = pos + 6;
let mut attrs = Vec::with_capacity(attr_count);
for _ in 0..attr_count {
let key_idx = read_u32(ops, cursor)?;
let val_idx = read_u32(ops, cursor + 4)?;
let key = strings.get(key_idx)?;
let val = strings.get(val_idx)?;
attrs.push((key, val));
cursor += 8;
}
Ok((tag_str_idx, attrs, cursor))
}
fn push_escaped_html(out: &mut String, text: &str) {
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
}
fn push_escaped_attr(out: &mut String, text: &str) {
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::test_helpers::{
build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
};
use crate::slot::{SlotData, SlotValue};
fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&marker_id.to_le_bytes());
buf
}
fn encode_island_start(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0B];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_island_end(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0C];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_comment(str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x10];
buf.extend_from_slice(&str_idx.to_le_bytes());
buf
}
fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
let data = build_minimal_ir(strings, &[], opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
walk_to_html(&module, &slots).unwrap()
}
fn walk_with_slots(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn walk_with_islands(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
buf
}
fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
bytes.extend_from_slice(&slot_id.to_le_bytes());
bytes
}
#[test]
fn walk_static_div() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
assert_eq!(html, r#"<div class="container">Hello</div>"#);
}
#[test]
fn walk_void_tag() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "type", "email"], &opcodes);
assert_eq!(html, r#"<input type="email">"#);
}
#[test]
fn walk_nested_elements() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "span", "Hi"], &opcodes);
assert_eq!(html, "<div><span>Hi</span></div>");
}
#[test]
fn walk_text_escaping() {
let opcodes = encode_text(0);
let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
assert_eq!(html, "<script>alert('xss')</script>");
}
#[test]
fn walk_attr_escaping() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let html = walk_static(&["div", "title", "a\"b"], &opcodes);
assert_eq!(html, r#"<div title="a"b">"#);
}
#[test]
fn walk_empty_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div"], &opcodes);
assert_eq!(html, "<div></div>");
}
#[test]
fn walk_dyn_text() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_null_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let slots = SlotData::new(1);
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_empty_string_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(String::new()));
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_escaping() {
let opcodes = encode_dyn_text(0, 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
}
#[test]
fn walk_island_markers() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_text(0));
opcodes.extend_from_slice(&encode_island_end(0));
let html = walk_static(&["Hello"], &opcodes);
assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
}
#[test]
fn walk_comment() {
let opcodes = encode_comment(0);
let html = walk_static(&["This is a comment"], &opcodes);
assert_eq!(html, "<!--This is a comment-->");
}
#[test]
fn walk_boolean_attr() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "disabled", ""], &opcodes);
assert_eq!(html, "<input disabled>");
}
#[test]
fn walk_multiple_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(
&["div", "id", "main", "class", "container", "data-x", "42"],
&opcodes,
);
assert_eq!(
html,
r#"<div id="main" class="container" data-x="42"></div>"#
);
}
#[test]
fn walk_show_if_true_branch() {
let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Yes"), "should contain then-branch text");
assert!(!html.contains("No"), "should NOT contain else-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_false_branch() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("No"), "should contain else-branch text");
assert!(!html.contains("Yes"), "should NOT contain then-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_truthy_text() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["greeting", "Shown", "Hidden"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(
html.contains("Shown"),
"truthy text should render then branch"
);
assert!(
!html.contains("Hidden"),
"truthy text should NOT render else branch"
);
}
#[test]
fn walk_show_if_falsy_null() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["maybe", "Present", "Missing"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Missing"), "null should render else branch");
assert!(
!html.contains("Present"),
"null should NOT render then branch"
);
}
#[test]
fn walk_show_if_no_else() {
let then_ops = encode_text(1);
let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["flag", "Content"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><!--/f:s0-->",
"empty else should produce only markers"
);
}
#[test]
fn walk_show_if_nested() {
let inner_then = encode_text(4);
let inner_else = encode_text(5);
let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
let outer_then = inner_show_if; let outer_else = encode_text(3);
let opcodes = encode_show_if(0, &outer_then, &outer_else);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false));
let html = walk_with_slots(
&[
"outer",
"inner",
"outer-yes",
"outer-no",
"inner-yes",
"inner-no",
],
&[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("inner-no"),
"nested: inner false should render inner else"
);
assert!(
!html.contains("inner-yes"),
"nested: inner true branch should not render"
);
assert!(
!html.contains("outer-no"),
"nested: outer else should not render"
);
assert!(
html.contains("<!--f:s0-->"),
"should have outer opening marker"
);
assert!(
html.contains("<!--/f:s0-->"),
"should have outer closing marker"
);
assert!(
html.contains("<!--f:s1-->"),
"should have inner opening marker"
);
assert!(
html.contains("<!--/f:s1-->"),
"should have inner closing marker"
);
}
#[test]
fn walk_show_if_with_elements() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[]));
then_ops.extend_from_slice(&encode_text(2));
then_ops.extend_from_slice(&encode_close_tag(1));
let else_ops = encode_text(3);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["flag", "span", "Hello", "Bye"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
"then branch should produce full HTML element"
);
}
#[test]
fn walk_list_renders_items() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Text("Alice".to_string()),
SlotValue::Text("Bob".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain first item");
assert!(html.contains("Bob"), "should contain second item");
assert!(
html.starts_with("<!--f:l0-->"),
"should start with list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should end with list close marker"
);
}
#[test]
fn walk_list_empty_array() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Array(vec![]));
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"empty array should produce only markers"
);
}
#[test]
fn walk_list_with_elements() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_dyn_text(1, 0));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Text("A".to_string()),
SlotValue::Text("B".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
"should render li elements with content, got: {html}"
);
}
#[test]
fn walk_list_null_slot() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let slots = SlotData::new(2);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"null slot should produce only markers"
);
}
#[test]
fn walk_list_nested() {
let inner_body = encode_dyn_text(2, 0);
let inner_list = encode_list(1, 2, &inner_body);
let opcodes = encode_list(0, 1, &inner_list);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Text("b".to_string()),
]),
SlotValue::Array(vec![SlotValue::Text("c".to_string())]),
]),
);
let html = walk_with_slots(
&["outer", "inner", "item"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains("a"), "should contain nested item 'a'");
assert!(html.contains("b"), "should contain nested item 'b'");
assert!(html.contains("c"), "should contain nested item 'c'");
assert!(
html.starts_with("<!--f:l0-->"),
"should have outer list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should have outer list close marker"
);
assert!(
html.contains("<!--f:l1-->"),
"should have inner list open marker"
);
assert!(
html.contains("<!--/f:l1-->"),
"should have inner list close marker"
);
}
#[test]
fn walk_list_depth_exceeded() {
let innermost_body = encode_dyn_text(5, 0);
let level5 = encode_list(4, 5, &innermost_body);
let level4 = encode_list(3, 4, &level5);
let level3 = encode_list(2, 3, &level4);
let level2 = encode_list(1, 2, &level3);
let level1 = encode_list(0, 1, &level2);
let mut slots = SlotData::new(6);
slots.set(
0,
SlotValue::Array(vec![SlotValue::Array(vec![SlotValue::Array(vec![
SlotValue::Array(vec![SlotValue::Array(vec![SlotValue::Text(
"deep".to_string(),
)])]),
])])]),
);
let data = build_minimal_ir(
&["s0", "s1", "s2", "s3", "s4", "s5"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x04, 0x00, &[]),
(3, 3, 0x04, 0x00, &[]),
(4, 4, 0x04, 0x00, &[]),
(5, 5, 0x01, 0x00, &[]),
],
&level1,
&[],
);
let module = IrModule::parse(&data).unwrap();
let result = walk_to_html(&module, &slots);
assert!(result.is_err(), "should fail with depth exceeded");
match result.unwrap_err() {
IrError::ListDepthExceeded { max: 4 } => {}
other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
}
}
#[test]
fn walk_switch_matching_case() {
let case_home_body = encode_text(2); let case_about_body = encode_text(4); let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("About"), "should contain matching case text");
assert!(
!html.contains("Home"),
"should NOT contain non-matching case text"
);
}
#[test]
fn walk_switch_no_match() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("missing".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.is_empty(),
"no match should produce empty output, got: {html}"
);
}
#[test]
fn walk_switch_first_case() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("home".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Home"), "should contain first case text");
assert!(
!html.contains("About"),
"should NOT contain second case text"
);
}
#[test]
fn walk_switch_with_elements() {
let mut active_body = Vec::new();
active_body.extend_from_slice(&encode_open_tag(2, &[]));
active_body.extend_from_slice(&encode_text(3));
active_body.extend_from_slice(&encode_close_tag(2));
let inactive_body = encode_text(5);
let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("active".to_string()));
let html = walk_with_slots(
&["page", "active", "span", "Active", "inactive", "Inactive"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<span>Active</span>",
"should produce full HTML element from matching case"
);
}
#[test]
fn walk_try_renders_main() {
let main_ops = encode_text(0);
let fallback_ops = encode_text(1);
let opcodes = encode_try(&main_ops, &fallback_ops);
let html = walk_static(&["Main", "Fallback"], &opcodes);
assert!(html.contains("Main"), "should contain main body text");
assert!(
!html.contains("Fallback"),
"should NOT contain fallback text"
);
}
#[test]
fn walk_preload_font() {
let opcodes = encode_preload(1, 0);
let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
assert!(
html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
"should produce preload link for font, got: {html}"
);
}
#[test]
fn walk_preload_style() {
let opcodes = encode_preload(2, 0);
let html = walk_static(&["/_assets/styles.css"], &opcodes);
assert!(
html.contains(r#"as="style""#),
"should contain as=\"style\", got: {html}"
);
assert!(
html.contains(r#"href="/_assets/styles.css""#),
"should contain correct href, got: {html}"
);
}
#[test]
fn roundtrip_page_structure() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_open_tag(3, &[])); opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); opcodes.extend_from_slice(&encode_open_tag(7, &[])); opcodes.extend_from_slice(&encode_text(8)); opcodes.extend_from_slice(&encode_close_tag(7)); opcodes.extend_from_slice(&encode_close_tag(3)); opcodes.extend_from_slice(&encode_open_tag(9, &[])); opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); opcodes.extend_from_slice(&encode_close_tag(10)); opcodes.extend_from_slice(&encode_close_tag(9)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
"div", "id", "app",
],
&[],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
);
}
#[test]
fn roundtrip_dynamic_greeting() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_dyn_text(0, 0)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&["div", "class", "greeting", "content"],
&[(0, 3, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Hello, World".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
);
}
#[test]
fn roundtrip_conditional_content() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
then_ops.extend_from_slice(&encode_text(4));
then_ops.extend_from_slice(&encode_close_tag(1));
let mut else_ops = Vec::new();
else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
else_ops.extend_from_slice(&encode_text(5));
else_ops.extend_from_slice(&encode_close_tag(1));
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let ir = build_minimal_ir(
&["auth", "span", "class", "badge", "Logged In", "Guest"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&[],
);
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
);
}
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
);
}
}
#[test]
fn roundtrip_list_rendering() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[])); body.extend_from_slice(&encode_dyn_text(1, 0)); body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let ir = build_minimal_ir(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Text("Alice".into()),
SlotValue::Text("Bob".into()),
SlotValue::Text("Charlie".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
"<!--f:l0-->\
<li><!--f:t0-->Alice<!--/f:t0--></li>\
<li><!--f:t0-->Bob<!--/f:t0--></li>\
<li><!--f:t0-->Charlie<!--/f:t0--></li>\
<!--/f:l0-->"
);
}
#[test]
fn roundtrip_switch_view() {
let mut home_body = Vec::new();
home_body.extend_from_slice(&encode_open_tag(2, &[]));
home_body.extend_from_slice(&encode_text(3));
home_body.extend_from_slice(&encode_close_tag(2));
let mut about_body = Vec::new();
about_body.extend_from_slice(&encode_open_tag(2, &[]));
about_body.extend_from_slice(&encode_text(5));
about_body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
let ir = build_minimal_ir(
&["page", "home", "h1", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<h1>About</h1>");
}
#[test]
fn roundtrip_nested_structure() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(4, &[]));
then_ops.extend_from_slice(&encode_text(5));
then_ops.extend_from_slice(&encode_close_tag(4));
let show_if_ops = encode_show_if(0, &then_ops, &[]);
let mut list_body = Vec::new();
list_body.extend_from_slice(&encode_open_tag(9, &[]));
list_body.extend_from_slice(&encode_dyn_text(2, 0));
list_body.extend_from_slice(&encode_close_tag(9));
let list_ops = encode_list(1, 2, &list_body);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&show_if_ops); opcodes.extend_from_slice(&encode_open_tag(8, &[])); opcodes.extend_from_slice(&list_ops); opcodes.extend_from_slice(&encode_close_tag(8)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
],
&[
(0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Bool(true));
slots.set(
1,
SlotValue::Array(vec![
SlotValue::Text("Home".into()),
SlotValue::Text("About".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<nav class="main-nav"><!--f:s0--><span>Welcome</span><!--/f:s0--><ul><!--f:l1--><li><!--f:t0-->Home<!--/f:t0--></li><li><!--f:t0-->About<!--/f:t0--></li><!--/f:l1--></ul></nav>"#
);
}
#[test]
fn walk_island_attrs_on_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Hello", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 2, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_with_existing_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "class", "form", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 3, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div class="form" data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load"></div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["input", "Widget"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
);
}
#[test]
fn walk_inline_props_attribute() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("Hello".to_string()));
slots.set(1, SlotValue::Number(42.0));
let html = walk_with_islands(
&["div", "MyComponent", "title", "count"],
&[
(0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(
html.contains("data-forma-props="),
"should contain data-forma-props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id, got: {html}"
);
assert!(
html.contains(r#"data-forma-component="MyComponent""#),
"should contain component name, got: {html}"
);
let marker = "data-forma-props=\"";
let props_start = html.find(marker).unwrap() + marker.len();
let props_end = html[props_start..].find('"').unwrap() + props_start;
let raw_attr = &html[props_start..props_end];
let decoded = raw_attr
.replace(""", "\"")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
assert_eq!(props_json["title"], "Hello");
assert_eq!(props_json["count"], 42);
}
#[test]
fn walk_list_item_key_on_first_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "user-123", "item"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
}
#[test]
fn walk_list_item_key_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
let html = walk_static(&["hr", "key-42"], &opcodes);
assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
}
#[test]
fn walk_list_item_key_with_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(3));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(4));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
}
#[test]
fn walk_script_tag_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Click me".to_string()));
let html = walk_with_islands(
&["div", "Counter", "label"],
&[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"ScriptTag mode should not emit inline props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"<script id="__forma_islands" type="application/json">"#),
"should contain script tag, got: {html}"
);
assert!(html.contains("</script>"), "should close script tag");
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["label"], "Click me");
}
#[test]
fn walk_deferred_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("ignored".to_string()));
let html = walk_with_islands(
&["div", "LazyWidget", "data"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x03, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"Deferred mode should not emit inline props, got: {html}"
);
assert!(
!html.contains("__forma_islands"),
"Deferred mode should not emit script tag, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"data-forma-component="LazyWidget""#),
"should contain component name"
);
}
#[test]
fn walk_island_no_entry_in_table() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(5));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(5));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Hello"],
&[],
&[], &opcodes,
&slots,
);
assert_eq!(html, "<!--f:i5--><div>Hello</div><!--/f:i5-->");
}
#[test]
fn walk_inline_props_empty_slots() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Simple"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"empty slot_ids should not emit props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
}
#[test]
fn walk_island_and_list_key_combined() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Card", "item-1"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island attrs"
);
assert!(
html.contains(r#"data-forma-key="item-1""#),
"should contain list key attr, got: {html}"
);
}
#[test]
fn walk_multiple_islands_script_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
slots.set(1, SlotValue::Bool(true));
let html = walk_with_islands(
&["div", "span", "CompA", "CompB", "msg", "flag"],
&[
(0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
&[
(0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, 0, &[1]), ],
&opcodes,
&slots,
);
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["msg"], "hello");
assert_eq!(json["1"]["flag"], true);
}
#[test]
fn walk_list_item_key_consumed_once() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
}
#[test]
fn slot_value_to_json_all_types() {
assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
assert_eq!(
SlotValue::Text("hello".to_string()).to_json(),
serde_json::Value::String("hello".to_string())
);
assert_eq!(
SlotValue::Bool(true).to_json(),
serde_json::Value::Bool(true)
);
assert_eq!(
SlotValue::Bool(false).to_json(),
serde_json::Value::Bool(false)
);
assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
assert_eq!(
SlotValue::Array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Number(1.0)
])
.to_json(),
serde_json::json!(["a", 1])
);
assert_eq!(
SlotValue::Object(vec![(
"key".to_string(),
SlotValue::Text("val".to_string())
),])
.to_json(),
serde_json::json!({"key": "val"})
);
}
#[test]
fn dyn_attr_emits_attribute_with_value() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("password".to_string()));
let html = walk_with_slots(
&["input", "type", "input_type"],
&[(0, 2, 0x01, 0x01, b"password")], &opcodes,
&slots,
);
assert_eq!(html, r#"<input type="password"></input>"#);
}
#[test]
fn dyn_attr_skips_when_slot_null() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let slots = SlotData::new(1);
let html = walk_with_slots(
&["div", "class", "cls"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<div></div>");
}
#[test]
fn dyn_attr_multiple_on_same_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("email".to_string()));
slots.set(1, SlotValue::Text("form-input".to_string()));
let html = walk_with_slots(
&["input", "type", "class", "slot_type", "slot_class"],
&[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
}
#[test]
fn dyn_attr_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("checkbox".to_string()));
let html = walk_with_slots(
&["input", "type", "slot_type"],
&[(0, 2, 0x01, 0x01, b"text")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="checkbox">"#);
}
#[test]
fn walk_island_basic() {
let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
let close_p = encode_close_tag(2);
let island_end = encode_island_end(0);
let close_div = encode_close_tag(1);
let byte_offset = pre_island.len() as u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&pre_island);
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_p);
opcodes.extend_from_slice(&text_hello);
opcodes.extend_from_slice(&close_p);
opcodes.extend_from_slice(&island_end);
opcodes.extend_from_slice(&close_div);
let data = build_minimal_ir(
&["MyIsland", "div", "p", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[])], );
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(html, "<p>hello</p>");
}
#[test]
fn walk_island_not_found() {
let opcodes = encode_text(0);
let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let result = walk_island(&module, &slots, 5);
assert!(result.is_err());
match result.unwrap_err() {
IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
}
}
#[test]
fn walk_island_with_dyn_text() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let byte_offset = 0u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])], &opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(html, "<div><!--f:t0-->World<!--/f:t0--></div>");
}
#[test]
fn walk_island_byte_offset_out_of_bounds() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["BadIsland", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, 9999, &[])], );
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let result = walk_island(&module, &slots, 0);
assert!(result.is_err());
match result.unwrap_err() {
IrError::BufferTooShort { .. } => {} other => panic!("expected BufferTooShort, got {other:?}"),
}
}
fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
buf.extend_from_slice(&prop_str_idx.to_le_bytes());
buf.extend_from_slice(&target_slot_id.to_le_bytes());
buf
}
#[test]
fn walk_prop_in_list_body() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_dyn_text(2, 0)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
SlotValue::Object(vec![(
"name".to_string(),
SlotValue::Text("Bob".to_string()),
)]),
]),
);
let html = walk_with_slots(
&["items", "item", "name"],
&[
(0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
&opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain Alice, got: {html}");
assert!(html.contains("Bob"), "should contain Bob, got: {html}");
}
#[test]
fn walk_prop_multiple_properties() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_prop(1, 3, 3)); body.extend_from_slice(&encode_open_tag(4, &[])); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(2, 0)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(3, 1)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_close_tag(4)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(4);
slots.set(
0,
SlotValue::Array(vec![
SlotValue::Object(vec![
("name".to_string(), SlotValue::Text("Alice".to_string())),
(
"email".to_string(),
SlotValue::Text("alice@test.com".to_string()),
),
]),
SlotValue::Object(vec![
("name".to_string(), SlotValue::Text("Bob".to_string())),
(
"email".to_string(),
SlotValue::Text("bob@test.com".to_string()),
),
]),
]),
);
let html = walk_with_slots(
&["rows", "row", "name", "email", "tr", "td"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x05, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
(3, 3, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(
html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
"should contain Alice td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
"should contain alice email, got: {html}"
);
assert!(
html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
"should contain Bob td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
"should contain bob email, got: {html}"
);
}
#[test]
fn walk_prop_on_non_object() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 1, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["src", "name"],
&[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"non-object PROP should produce empty text, got: {html}"
);
}
#[test]
fn walk_prop_missing_property() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 2, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::Object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
);
let html = walk_with_slots(
&["src", "target", "missing"],
&[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"missing property should produce empty text, got: {html}"
);
}
#[test]
fn walk_nested_show_if_within_limit() {
let inner = encode_text(5);
let level4 = encode_show_if(4, &inner, &[]);
let level3 = encode_show_if(3, &level4, &[]);
let level2 = encode_show_if(2, &level3, &[]);
let level1 = encode_show_if(1, &level2, &[]);
let level0 = encode_show_if(0, &level1, &[]);
let mut slots = SlotData::new(5);
for i in 0..5 {
slots.set(i, SlotValue::Bool(true));
}
let html = walk_with_slots(
&["s0", "s1", "s2", "s3", "s4", "deep"],
&[
(0, 0, 0x02, 0x00, &[]),
(1, 1, 0x02, 0x00, &[]),
(2, 2, 0x02, 0x00, &[]),
(3, 3, 0x02, 0x00, &[]),
(4, 4, 0x02, 0x00, &[]),
],
&level0,
&slots,
);
assert!(
html.contains("deep"),
"5 levels of nesting should succeed, got: {html}"
);
}
#[test]
fn walk_script_tag_props_escapes_close_script() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("</script>alert(1)".to_string()));
let html = walk_with_islands(
&["div", "Comp", "payload"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("</script>alert"),
"script tag content must escape </script>, got: {html}"
);
assert!(
html.contains("<\\/script>"),
"should replace </ with <\\/ in script JSON, got: {html}"
);
}
#[test]
#[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
fn walk_real_benchmark_ir() {
let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
let ir_path = std::fs::read_dir(&dist_dir)
.expect("admin/dist must exist")
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with("platform-benchmark.") && s.ends_with(".ir")
})
.map(|e| e.path())
.expect("platform-benchmark.*.ir must exist in admin/dist");
let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
let module = IrModule::parse(&data).expect("IR must parse");
let slots = SlotData::new_from_defaults(&module.slots);
let html = walk_to_html(&module, &slots).expect("walk must succeed");
assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
assert!(
html.contains("data-forma-island=\"0\""),
"missing island 0 attr"
);
assert!(
html.contains("Search"),
"FilterBar must contain 'Search' label text in SSR"
);
assert!(
html.contains("filter-group"),
"FilterBar must contain filter-group divs in SSR"
);
assert!(
html.contains("Sort by"),
"FilterBar must contain 'Sort by' label text in SSR"
);
assert!(
html.contains("Performance"),
"PerfPanel must contain 'Performance' heading in SSR"
);
assert!(
html.contains("benchmark-data-table"),
"BenchmarkDataTable root class must exist"
);
if let Some(start) = html.find("<!--f:i0-->") {
let end = html.find("<!--/f:i0-->").unwrap_or(start + 200) + 12;
eprintln!("\n=== FilterBar island HTML ===");
eprintln!("{}", &html[start..end.min(html.len())]);
}
}
}