use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::walker::walk_island;
use crate::{
walk_to_html, IrError, IrModule, SlotData, SlotTable, SlotType, SlotValue, StringTable,
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_row(base: &str, props: &[String], row: usize) -> SlotValue {
if props.is_empty() {
return SlotValue::Text(format!("{base}[{row}]"));
}
SlotValue::object(
props
.iter()
.map(|p| (p.clone(), SlotValue::Text(format!("{base}[{row}].{p}"))))
.collect(),
)
}
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)) => {
SlotValue::array((0..LIST_ROWS).map(|r| list_row(&base, &props, 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 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 = SlotData::new_from_defaults(&module.slots);
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 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_islands_over_named_slots() {
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!(
!entry.slot_ids.is_empty(),
"{name}: island '{island}' carries no slot ids, so the walker emits no \
data-forma-props and the client hydrates it with nothing"
);
}
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}");
}
}