use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::walker::walk_island;
use crate::{
walk_to_html, walk_to_html_counted, IrError, IrModule, SlotData, SlotSource, SlotTable,
SlotType, SlotValue, StringTable, WalkLimits, HEADER_SIZE,
};
const EXPECTED_CASES: &[&str] = &[
"barrel-exports",
"create-show",
"dynamic-attrs",
"dynamic-text",
"eager-concat",
"imported-const-table",
"imported-signal-defaults",
"jsx-page",
"list-same-source",
"nested-islands",
"static-page",
"sub-component-scopes",
"ternary-asymmetric",
"unicode-emoji",
];
const RESOLUTION_CASES: &[&str] = &[
"barrel-exports",
"imported-const-table",
"imported-signal-defaults",
"jsx-page",
];
const LIST_ROWS: usize = 3;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/js-emitter")
}
fn read_case(name: &str) -> Vec<u8> {
let path = fixture_dir().join(format!("{name}.ir"));
fs::read(&path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
}
fn parse_case(name: &str) -> IrModule {
IrModule::parse(&read_case(name))
.unwrap_or_else(|e| panic!("{name}.ir must parse as FMIR, got {e:?}"))
}
fn string_at(module: &IrModule, str_idx: u32) -> &str {
module
.strings
.get(str_idx)
.expect("parse() validated every name_str_idx against the string table")
}
fn raw_tables(bytes: &[u8]) -> (StringTable, SlotTable) {
let table = crate::SectionTable::parse(&bytes[HEADER_SIZE..]).expect("section table");
table.validate(bytes.len()).expect("tiling");
let strings = table.get(2).expect("strings");
let slots = table.get(3).expect("slots");
let (str_off, str_len) = (strings.offset as usize, strings.size as usize);
let (slot_off, slot_len) = (slots.offset as usize, slots.size as usize);
(
StringTable::parse(&bytes[str_off..str_off + str_len]).expect("string table must parse"),
SlotTable::parse(&bytes[slot_off..slot_off + slot_len]).expect("slot table must parse"),
)
}
fn list_props(module: &IrModule, array_slot_name: &str) -> Option<(String, Vec<String>)> {
let base = array_slot_name
.strip_prefix("list:")
.and_then(|rest| rest.strip_suffix(":array"))?;
let prefix = format!("list:{base}:");
let props = module
.slots
.entries()
.iter()
.filter_map(|e| {
string_at(module, e.name_str_idx)
.strip_prefix(&prefix)
.map(str::to_string)
})
.filter(|p| p != "item" && p != "array")
.collect();
Some((base.to_string(), props))
}
fn list_key_paths(module: &IrModule, array_slot_id: u16) -> Vec<Vec<String>> {
module
.key_paths
.iter()
.filter(|path| {
let at = path.list_offset as usize;
module
.opcodes
.get(at + 1..at + 3)
.map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]))
== Some(array_slot_id)
})
.map(|path| {
path.segments
.iter()
.map(|segment| string_at(module, *segment).to_string())
.collect()
})
.collect()
}
fn insert_path(fields: &mut Vec<(String, SlotValue)>, path: &[String], value: SlotValue) {
let (head, rest) = match path.split_first() {
Some(split) => split,
None => return,
};
let position = fields.iter().position(|(name, _)| name == head);
if rest.is_empty() {
if position.is_none() {
fields.push((head.clone(), value));
}
return;
}
let mut inner = match position.map(|i| fields.remove(i)) {
Some((_, SlotValue::Object(pairs))) => pairs.to_vec(),
_ => Vec::new(),
};
insert_path(&mut inner, rest, value);
fields.push((head.clone(), SlotValue::object(inner)));
}
fn list_row(base: &str, props: &[String], key_paths: &[Vec<String>], row: usize) -> SlotValue {
if props.is_empty() && key_paths.is_empty() {
return SlotValue::Text(format!("{base}[{row}]"));
}
let mut fields: Vec<(String, SlotValue)> = props
.iter()
.map(|p| (p.clone(), SlotValue::Text(format!("{base}[{row}].{p}"))))
.collect();
for path in key_paths {
let value = SlotValue::Text(format!("{base}[{row}].{}", path.join(".")));
insert_path(&mut fields, path, value);
}
SlotValue::object(fields)
}
fn distinguishing_slots(module: &IrModule, bools: bool) -> SlotData {
let capacity = module
.slots
.entries()
.iter()
.map(|e| e.slot_id as usize + 1)
.max()
.unwrap_or(0);
let mut data = SlotData::new(capacity);
for entry in module.slots.entries() {
let name = string_at(module, entry.name_str_idx);
let id = entry.slot_id;
let value = match entry.type_hint {
SlotType::Text => SlotValue::Text(format!("[{name}#{id}]")),
SlotType::Bool => SlotValue::Bool(bools),
SlotType::Number => SlotValue::Number(1000.0 + f64::from(id)),
SlotType::Array => match list_props(module, name) {
Some((base, props)) => {
let keys = list_key_paths(module, id);
SlotValue::array(
(0..LIST_ROWS)
.map(|r| list_row(&base, &props, &keys, r))
.collect(),
)
}
None => SlotValue::array(
(0..LIST_ROWS)
.map(|r| SlotValue::Text(format!("[{name}#{id}][{r}]")))
.collect(),
),
},
SlotType::Object => SlotValue::object(vec![(
"unbound".to_string(),
SlotValue::Text(format!("<unbound {name}#{id}>")),
)]),
};
data.set(id, value);
}
data
}
fn empty_text_slots(module: &IrModule) -> SlotData {
let mut data = distinguishing_slots(module, true);
for entry in module.slots.entries() {
if entry.type_hint == SlotType::Text {
data.set(entry.slot_id, SlotValue::Text(String::new()));
}
}
data
}
fn default_slots(module: &IrModule) -> SlotData {
let mut defaults = SlotData::new_from_defaults(&module.slots);
let request = distinguishing_slots(module, true);
for entry in module.slots.entries() {
if entry.source == SlotSource::Server {
defaults.set(entry.slot_id, request.get(entry.slot_id).clone());
}
}
defaults
}
fn push_section(out: &mut String, label: &str, html: &str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("<!-- @{label} -->\n{html}\n"));
}
fn render_all(module: &IrModule) -> Result<String, IrError> {
let mut out = String::new();
let on = distinguishing_slots(module, true);
let off = distinguishing_slots(module, false);
let empty = empty_text_slots(module);
let defaults = default_slots(module);
push_section(&mut out, "page bools=true", &walk_to_html(module, &on)?);
push_section(&mut out, "page bools=false", &walk_to_html(module, &off)?);
push_section(&mut out, "page text-empty", &walk_to_html(module, &empty)?);
push_section(&mut out, "page defaults", &walk_to_html(module, &defaults)?);
for entry in module.islands.entries() {
let name = string_at(module, entry.name_str_idx);
let label = format!("island {} {}", entry.id, name);
push_section(&mut out, &label, &walk_island(module, &on, entry.id)?);
}
Ok(out)
}
fn read_golden(path: &Path) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.replace("\r\n", "\n"))
}
#[test]
fn corpus_contains_every_expected_case() {
let dir = fixture_dir();
for name in EXPECTED_CASES {
let ir = dir.join(format!("{name}.ir"));
assert!(
ir.is_file(),
"corpus case '{name}' has no {} — re-run `npm run emit:corpus` in forma-tools and copy the .ir files here",
ir.display()
);
}
let mut on_disk: Vec<String> = fs::read_dir(&dir)
.expect("fixture directory must exist")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "ir"))
.filter_map(|p| Some(p.file_stem()?.to_str()?.to_string()))
.collect();
on_disk.sort();
assert_eq!(
on_disk, EXPECTED_CASES,
"the .ir files on disk and EXPECTED_CASES must agree"
);
}
#[test]
fn every_case_parses_as_fmir() {
for name in EXPECTED_CASES {
let module = parse_case(name);
assert!(
!module.opcodes.is_empty(),
"{name}: parsed module has an empty bytecode section"
);
}
}
#[test]
fn slot_names_are_pairwise_unique() {
for case in EXPECTED_CASES {
let bytes = read_case(case);
let (strings, slots) = raw_tables(&bytes);
let mut seen: HashMap<&str, u16> = HashMap::new();
for entry in slots.entries() {
let name = strings
.get(entry.name_str_idx)
.unwrap_or_else(|e| panic!("{case}: slot name index out of range: {e:?}"));
if let Some(prev) = seen.insert(name, entry.slot_id) {
panic!(
"{case}: slot name '{name}' is minted twice — slots {prev} and {}",
entry.slot_id
);
}
}
}
}
#[test]
fn slot_ids_are_pairwise_unique() {
for case in EXPECTED_CASES {
let bytes = read_case(case);
let (strings, slots) = raw_tables(&bytes);
let mut seen: HashMap<u16, &str> = HashMap::new();
for entry in slots.entries() {
let name = strings
.get(entry.name_str_idx)
.unwrap_or_else(|e| panic!("{case}: slot name index out of range: {e:?}"));
if let Some(prev) = seen.insert(entry.slot_id, name) {
panic!(
"{case}: slot id {} is declared twice — '{prev}' and '{name}'",
entry.slot_id
);
}
}
}
}
#[test]
fn every_case_walks_to_html() {
for name in EXPECTED_CASES {
let module = parse_case(name);
let slots = distinguishing_slots(&module, true);
walk_to_html(&module, &slots)
.unwrap_or_else(|e| panic!("{name}: walk_to_html failed with {e:?}"));
}
}
#[test]
fn closed_list_defaults_render_real_rows_in_both_projections() {
let module = parse_case("list-same-source");
let html = walk_to_html(&module, &default_slots(&module)).unwrap();
assert_eq!(html.matches("<li").count(), 4);
assert_eq!(html.matches("data-forma-key=").count(), 4);
assert_eq!(html.matches("Write the corpus").count(), 2);
assert_eq!(html.matches("Feed it to Rust").count(), 2);
assert!(html.contains("ada"));
assert!(html.contains("grace"));
}
#[test]
fn strict_corpus_near_misses_render_their_real_defaults() {
let attributes = parse_case("dynamic-attrs");
let html = walk_to_html(&attributes, &default_slots(&attributes)).unwrap();
assert!(html.contains("href=\"/docs\""));
assert!(html.contains(" value>") || html.contains(" value=\"\""));
let scopes = parse_case("sub-component-scopes");
let html = walk_to_html(&scopes, &default_slots(&scopes)).unwrap();
assert!(html.contains("panel is-open"));
assert!(html.contains(">THEN</p>"));
assert!(html.contains("-->7<!--"));
assert!(html.contains("data-ready=\"true\""));
}
#[test]
fn every_island_entry_walks_as_a_fragment() {
let mut islands_seen = 0usize;
for name in EXPECTED_CASES {
let module = parse_case(name);
let slots = distinguishing_slots(&module, true);
for entry in module.islands.entries() {
let html = walk_island(&module, &slots, entry.id)
.unwrap_or_else(|e| panic!("{name}: walk_island({}) failed with {e:?}", entry.id));
assert!(
html.contains("data-forma-island="),
"{name}: island {} fragment has no data-forma-island root: {html}",
entry.id
);
islands_seen += 1;
}
}
assert!(
islands_seen >= 3,
"the corpus must keep exercising islands (nested-islands alone declares 3), saw {islands_seen}"
);
}
#[test]
fn resolution_cases_ship_named_contracts_and_client_defaults() {
for name in RESOLUTION_CASES {
let module = parse_case(name);
assert!(
!module.islands.entries().is_empty(),
"{name}: no islands — the page compiled to placeholder IR, so nothing here is server-rendered"
);
for entry in module.islands.entries() {
let island = string_at(&module, entry.name_str_idx);
assert!(
!island.starts_with("island_") && island != "createList",
"{name}: island {} is named '{island}', the compiler's give-up name — it renders \
nothing server-side and only appears once the client bundle hydrates",
entry.id
);
assert_eq!(
entry.slot_ids.len(),
module.schema.objects[entry.schema_object as usize].len(),
"{name}: ordered inputs must match declared fields, not captured/local signals"
);
assert!(
module.island_contract_bytes(entry.id).is_some(),
"{name}: missing paired contract"
);
assert_eq!(entry.digest.len(), 8, "{name}: missing contract identity");
}
let anonymous = |n: &str| {
n.starts_with("text:")
|| n.starts_with("attr:")
|| n.starts_with("show:")
|| n.starts_with("list:")
};
let named: Vec<&str> = module
.slots
.entries()
.iter()
.map(|e| string_at(&module, e.name_str_idx))
.filter(|n| !anonymous(n))
.collect();
assert!(
!named.is_empty(),
"{name}: every slot is anonymous — the compiler never read the component's \
createSignal defaults, so nothing on this page can be injected by name"
);
let with_defaults = module
.slots
.entries()
.iter()
.filter(|e| !anonymous(string_at(&module, e.name_str_idx)))
.filter(|e| !e.default_bytes.is_empty())
.count();
assert!(
with_defaults > 0,
"{name}: named slots {named:?} all have empty default_bytes — the signal defaults \
were dropped somewhere between the source and the slot table"
);
}
}
#[test]
fn corpus_exercises_multibyte_utf8() {
let module = parse_case("unicode-emoji");
let mut multibyte = 0usize;
let mut four_byte = 0usize;
for idx in 0..module.strings.len() {
let s = module.strings.get(idx as u32).expect("in-bounds index");
if s.len() != s.chars().count() {
multibyte += 1;
}
if s.chars().any(|c| c.len_utf8() == 4) {
four_byte += 1;
}
}
assert!(
multibyte > 0 && four_byte > 0,
"unicode-emoji must keep strings whose byte length differs from their \
character count, including 4-byte sequences (multibyte={multibyte}, four_byte={four_byte})"
);
let has_multibyte_default = module
.slots
.entries()
.iter()
.any(|e| !e.default_bytes.is_empty() && e.default_bytes.iter().any(|b| *b >= 0x80));
assert!(
has_multibyte_default,
"unicode-emoji must keep a slot whose default_bytes are non-ASCII"
);
}
#[test]
fn rendered_html_matches_golden() {
let update = std::env::var_os("UPDATE_GOLDEN").is_some();
let mut stale = Vec::new();
for name in EXPECTED_CASES {
let module = parse_case(name);
let rendered = render_all(&module)
.unwrap_or_else(|e| panic!("{name}: rendering the golden set failed with {e:?}"));
let path = fixture_dir().join(format!("{name}.html"));
if update {
fs::write(&path, &rendered)
.unwrap_or_else(|e| panic!("writing {}: {e}", path.display()));
continue;
}
match read_golden(&path) {
Some(golden) if golden == rendered => {}
Some(golden) => {
stale.push(format!(
"--- {name} ---\nexpected (golden):\n{golden}\nactual (this walker):\n{rendered}"
));
}
None => stale.push(format!("--- {name} ---\nno golden at {}", path.display())),
}
}
assert!(
stale.is_empty(),
"the compiler's FMIR no longer renders to the committed HTML.\n\
If the change was intentional, re-run with UPDATE_GOLDEN=1 and review the diff.\n\n{}",
stale.join("\n\n")
);
}
#[test]
fn canonical_dumps_match_the_typescript_emitter() {
for name in EXPECTED_CASES {
let module = parse_case(name);
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js-emitter")
.join(format!("{name}.dump"));
let expected = std::fs::read_to_string(path).expect("committed TypeScript canonical dump");
assert_eq!(crate::dump::dump_ir(&module), expected, "{name}");
}
}
#[test]
fn state_snapshots_match_golden() {
let update = std::env::var_os("UPDATE_GOLDEN").is_some();
for name in EXPECTED_CASES {
let module = parse_case(name);
let variants = [
("bools=true", distinguishing_slots(&module, true)),
("bools=false", distinguishing_slots(&module, false)),
("text-empty", empty_text_slots(&module)),
("defaults", default_slots(&module)),
];
let mut states = serde_json::Map::new();
for (label, slots) in variants {
let output = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
assert_eq!(output.html, walk_to_html(&module, &slots).unwrap());
let state: serde_json::Value =
serde_json::from_str(output.island_state.as_json()).unwrap();
assert_eq!(
state["s"],
serde_json::json!([]),
"captured/local signals are not handler inputs"
);
assert_eq!(
state["i"].as_array().unwrap().len(),
module.islands.entries().len()
);
assert!(!output.html.contains("data-forma-props"));
states.insert(label.into(), state);
}
let actual = serde_json::to_string_pretty(&states).unwrap() + "\n";
let path = fixture_dir().join(format!("{name}.state.json"));
if update {
fs::write(&path, actual).unwrap();
} else {
assert_eq!(
read_golden(&path).as_deref(),
Some(actual.as_str()),
"{name}: state snapshot drift"
);
}
}
}