use htui::events::EventBindings;
use htui::layout::generate_box_tree;
use htui::script::ScriptEngine;
use htui::tdom::node::{NodeArena, NodeId, NodeType};
fn eval_str(engine: &mut ScriptEngine, expr: &str) -> String {
let v = engine
.eval_source(&format!("String({expr})"))
.expect("eval must succeed");
v.as_string()
.map(|s| s.to_std_string_lossy())
.unwrap_or_default()
}
fn find_node_by_id(arena: &NodeArena, id: &str) -> NodeId {
arena
.iter()
.find(|(_, n)| n.attributes.get("id").map(String::as_str) == Some(id))
.map(|(nid, _)| nid)
.expect("element with id exists")
}
fn data_attr(engine: &ScriptEngine, id: &str, name: &str) -> Option<String> {
let arena = engine.arena();
let node = find_node_by_id(&arena, id);
arena
.get(node)
.and_then(|n| n.attributes.get(name).cloned())
}
fn flatten_text_contains(
boxes: &[htui::layout::GeneratedBox],
arena: &NodeArena,
needle: &str,
) -> bool {
fn walk(
boxes: &[htui::layout::GeneratedBox],
arena: &NodeArena,
needle: &str,
found: &mut bool,
) {
for g in boxes {
if arena[g.node_id].node_type == NodeType::Text
&& arena[g.node_id].data.as_deref() == Some(needle)
{
*found = true;
return;
}
walk(&g.children, arena, needle, found);
if *found {
return;
}
}
}
let mut found = false;
walk(boxes, arena, needle, &mut found);
found
}
#[test]
fn dataset_reads_data_attributes_with_camel_case_mapping() {
let mut engine = ScriptEngine::new(
r#"<div id="d" data-foo="bar" data-max-count="3"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
assert_eq!(
eval_str(&mut engine, "document.getElementById('d').dataset.foo"),
"bar"
);
assert_eq!(
eval_str(&mut engine, "document.getElementById('d').dataset.maxCount"),
"3",
"camelCase property maps to the kebab-case data-* attribute"
);
assert_eq!(
eval_str(
&mut engine,
"String(document.getElementById('d').dataset.missing)"
),
"undefined",
"an absent data-* attribute reads as undefined"
);
assert_eq!(
eval_str(
&mut engine,
"document.getElementById('d').dataset['max-count']"
),
"3"
);
}
#[test]
fn dataset_empty_when_no_data_attributes() {
let mut engine = ScriptEngine::new(r#"<div id="d"></div>"#, EventBindings::default())
.expect("engine initializes");
assert_eq!(
eval_str(
&mut engine,
"Object.keys(document.getElementById('d').dataset).length"
),
"0"
);
assert_eq!(
eval_str(
&mut engine,
"String(document.getElementById('d').dataset.nope)"
),
"undefined"
);
}
#[test]
fn dataset_write_sets_the_kebab_cased_attribute() {
let mut engine = ScriptEngine::new(r#"<div id="d"></div>"#, EventBindings::default())
.expect("engine initializes");
eval_str(
&mut engine,
"document.getElementById('d').dataset.maxCount = '42'",
);
assert_eq!(data_attr(&engine, "d", "data-max-count"), Some("42".into()));
assert_eq!(
eval_str(&mut engine, "document.getElementById('d').dataset.maxCount"),
"42"
);
let mut engine = ScriptEngine::new(
r#"<div id="e" data-src="a"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
eval_str(
&mut engine,
"document.getElementById('e').dataset.src = 'b'",
);
assert_eq!(data_attr(&engine, "e", "data-src"), Some("b".into()));
}
#[test]
fn dataset_empty_string_removes_the_attribute() {
let mut engine = ScriptEngine::new(
r#"<div id="d" data-tmp="x"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
eval_str(&mut engine, "document.getElementById('d').dataset.tmp = ''");
assert_eq!(data_attr(&engine, "d", "data-tmp"), None);
assert_eq!(
eval_str(
&mut engine,
"String(document.getElementById('d').dataset.tmp)"
),
"undefined"
);
}
#[test]
fn dataset_delete_and_in_operator() {
let mut engine = ScriptEngine::new(
r#"<div id="d" data-a="1" data-b="2"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
assert_eq!(
eval_str(&mut engine, "'a' in document.getElementById('d').dataset"),
"true"
);
eval_str(&mut engine, "delete document.getElementById('d').dataset.a");
assert_eq!(
eval_str(&mut engine, "'a' in document.getElementById('d').dataset"),
"false"
);
assert_eq!(data_attr(&engine, "d", "data-a"), None);
assert_eq!(
eval_str(&mut engine, "'b' in document.getElementById('d').dataset"),
"true",
"deleting one key must not touch the others"
);
}
#[test]
fn dataset_enumerates_kebab_cased_keys() {
let mut engine = ScriptEngine::new(
r#"<div id="d" data-alpha="1" data-max-count="2" data-z="3"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
assert_eq!(
eval_str(
&mut engine,
"JSON.stringify(Object.keys(document.getElementById('d').dataset))"
),
r#"["alpha","max-count","z"]"#,
"Object.keys(dataset) lists the kebab-cased data-* names in order"
);
}
#[test]
fn dataset_unchanged_write_does_not_mark_dirty() {
let mut engine = ScriptEngine::new(
r#"<div id="d" data-foo="bar"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
eval_str(
&mut engine,
"document.getElementById('d').dataset.foo = 'bar'",
);
let dirty = engine.take_dirty();
assert!(
dirty.is_empty(),
"writing the same value must not schedule a re-render"
);
}
#[test]
fn dataset_is_live_across_mutations() {
let mut engine =
ScriptEngine::new(r#"<div id="d" data-a="1"></div>"#, EventBindings::default())
.expect("engine initializes");
eval_str(
&mut engine,
"globalThis.ds = document.getElementById('d').dataset",
);
eval_str(&mut engine, "globalThis.ds.b = '2'");
assert_eq!(data_attr(&engine, "d", "data-b"), Some("2".into()));
assert_eq!(
eval_str(&mut engine, "globalThis.ds.b"),
"2",
"a held dataset reference stays live"
);
}
#[test]
fn hidden_reads_the_hidden_attribute() {
let mut engine = ScriptEngine::new(
r#"<div id="d" hidden></div><div id="e"></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
assert_eq!(
eval_str(&mut engine, "document.getElementById('d').hidden"),
"true"
);
assert_eq!(
eval_str(&mut engine, "document.getElementById('e').hidden"),
"false"
);
}
#[test]
fn hidden_js_property_toggles_rendering() {
let mut engine = ScriptEngine::new(
r#"<div id="wrap"><p id="a">visible</p><p id="b">gone</p></div>"#,
EventBindings::default(),
)
.expect("engine initializes");
let root_id = engine.root_id();
let boxes = generate_box_tree(&engine.arena(), root_id);
assert!(flatten_text_contains(&boxes, &engine.arena(), "gone"));
eval_str(&mut engine, "document.getElementById('b').hidden = true");
assert_eq!(
eval_str(&mut engine, "document.getElementById('b').hidden"),
"true"
);
let dirty = engine.take_dirty();
assert!(
!dirty.is_empty(),
"hiding an element must schedule a re-render"
);
let boxes = generate_box_tree(&engine.arena(), root_id);
assert!(
!flatten_text_contains(&boxes, &engine.arena(), "gone"),
"a hidden element must not render"
);
assert!(flatten_text_contains(&boxes, &engine.arena(), "visible"));
eval_str(&mut engine, "document.getElementById('b').hidden = false");
assert_eq!(
eval_str(&mut engine, "document.getElementById('b').hidden"),
"false"
);
let boxes = generate_box_tree(&engine.arena(), root_id);
assert!(flatten_text_contains(&boxes, &engine.arena(), "gone"));
}
#[test]
fn hidden_unchanged_write_does_not_mark_dirty() {
let mut engine = ScriptEngine::new(r#"<div id="d" hidden></div>"#, EventBindings::default())
.expect("engine initializes");
eval_str(&mut engine, "document.getElementById('d').hidden = true");
assert!(
engine.take_dirty().is_empty(),
"re-setting an already-present hidden attribute must not re-render"
);
eval_str(&mut engine, "document.getElementById('d').hidden = false");
assert!(
!engine.take_dirty().is_empty(),
"clearing the hidden attribute must re-render"
);
}