use htui::events::{EventBindings, Geometry, HitMap, interaction::HoverState};
use htui::html::css_parse::DynamicState;
use htui::html::html_parse;
use htui::layout::layout::TermMap;
use htui::layout::{BoxConstraint, Cursor, Rect, layout_tree};
use htui::script::{ScriptEngine, ScriptMsg, apply_message};
use htui::tdom::node::{NodeArena, NodeId, NodeType};
use htui::tui::graph_api::{InputState, RenderCmd, TermLayout, handle_click, paste_into_field};
use std::sync::Arc;
const COLS: u16 = 80;
const ROWS: u16 = 24;
fn mock_term_map() -> TermMap {
TermMap {
columns: COLS,
rows: ROWS,
}
}
fn setup(html: &str, css: &str) -> (NodeId, NodeArena) {
let mut parser = html_parse::Parser::new(html);
let (root_id, mut arena, embedded) = parser.parse();
let combined = if css.is_empty() {
embedded
} else if embedded.is_empty() {
css.to_string()
} else {
format!("{embedded}\n{css}")
};
if !combined.trim().is_empty() {
let mut css_parser = htui::html::css_parse::Parser::new(&combined);
let rules = css_parser.parse();
htui::html::css_parse::css_into_node(&mut arena, root_id, &rules);
}
(root_id, arena)
}
fn draw(html: &str, css: &str) -> (TermMap, TermLayout) {
let term_map = mock_term_map();
let (root_id, arena) = setup(html, css);
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
(term_map, tl)
}
fn layout_boxes(html: &str) -> (Vec<htui::layout::LayoutBox>, NodeArena) {
let (root_id, arena) = setup(html, "");
let containing = BoxConstraint::root(&mock_term_map());
let mut cursor = Cursor::new();
let boxes = layout_tree(
&arena,
root_id,
containing,
&mut cursor,
COLS as usize,
ROWS as usize,
);
(boxes, arena)
}
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 find_box(
boxes: &[htui::layout::LayoutBox],
node_id: NodeId,
) -> Option<&htui::layout::LayoutBox> {
for b in boxes {
if b.node_id == node_id {
return Some(b);
}
if let Some(found) = find_box(&b.children, node_id) {
return Some(found);
}
}
None
}
fn build_geometry(html: &str) -> (ScriptEngine, Geometry) {
let engine = ScriptEngine::new(html, "", EventBindings::default()).expect("engine initializes");
let arena = engine.arena();
let root_id = engine.root_id();
let term_map = mock_term_map();
let containing = BoxConstraint::root(&term_map);
let mut cursor = Cursor::new();
let boxes = layout_tree(
&arena,
root_id,
containing,
&mut cursor,
term_map.columns as usize,
term_map.rows as usize,
);
let geo = Geometry {
arena: Arc::new(arena),
boxes,
root_id,
};
(engine, geo)
}
fn center(rect: Rect) -> (u32, u32) {
(
rect.x as u32 + rect.width.max(1) as u32 / 2,
rect.y as u32 + rect.height.max(1) as u32 / 2,
)
}
fn apply(engine: &mut ScriptEngine, map: &HitMap, msg: ScriptMsg) -> Vec<RenderCmd> {
let mut hover = HoverState::new();
let ev = apply_message(engine, &mut hover, map, msg);
let mut cmds = ev.cmds;
if let Some(d) = ev.dirty {
cmds.push(RenderCmd::RenderArena {
arena: d.arena,
root_id: d.root_id,
scroll_y: 0,
zoom: 1.0,
dynamic: DynamicState {
hovered: ev.hovered,
..Default::default()
},
dirty: d.dirty,
});
}
cmds
}
fn click(state: &mut InputState, engine: &mut ScriptEngine, map: &HitMap, x: u32, y: u32) {
let arena = engine.arena();
let mut msgs = Vec::new();
let mut cmds = Vec::new();
handle_click(state, map, &arena, x, y, &mut msgs, &mut cmds);
for msg in msgs {
apply(engine, map, msg);
}
}
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()
}
#[test]
fn test_hidden_input_produces_no_box() {
let (boxes, arena) =
layout_boxes(r#"<div id="wrap">a<input id="h" type="hidden" value="x">b</div>"#);
let h = find_node_by_id(&arena, "h");
assert!(
find_box(&boxes, h).is_none(),
"hidden input must not produce a layout box"
);
let (term_map, tl) = draw(r#"<div>a<input type="hidden" value="x">b</div>"#, "");
let cols = term_map.columns as usize;
let joined: String = tl
.layout
.iter()
.enumerate()
.filter(|(_, b)| !b.word.is_empty() && b.word != " " && b.word != "[")
.map(|(i, b)| format!("{}{}", b.word, if i % cols == cols - 1 { " " } else { "" }))
.collect();
assert!(
joined.contains("ab"),
"text around the hidden input must still render (got {joined:?})"
);
}
#[test]
fn test_hidden_input_stays_in_dom() {
let (_, arena) = setup(
r#"<form><input id="h" type="hidden" name="tok" value="abc"></form>"#,
"",
);
let h = find_node_by_id(&arena, "h");
let n = &arena[h];
assert_eq!(
n.node_type,
NodeType::Input,
"hidden input stays a DOM node"
);
assert_eq!(
n.attributes.get("value").map(String::as_str),
Some("abc"),
"form data survives"
);
}
#[test]
fn test_password_input_masks_value() {
let (_, tl) = draw(r#"<input type="password" value="secret">"#, "");
let bullets = tl.layout.iter().filter(|b| b.word == "\u{2022}").count();
assert_eq!(bullets, 6, "every password character must be masked as `•`");
assert!(
!tl.layout.iter().any(|b| b.word == "s"
|| b.word == "e"
|| b.word == "c"
|| b.word == "r"
|| b.word == "t"),
"the plaintext must never be painted"
);
}
#[test]
fn test_checkbox_unchecked_renders_box_glyph() {
let (boxes, arena) = layout_boxes(r#"<div><input id="c" type="checkbox"></div>"#);
let c = find_node_by_id(&arena, "c");
let b = find_box(&boxes, c).expect("checkbox box");
assert_eq!(b.rect.width, 2, "checkbox is the glyph + one spacing cell");
let (_, tl) = draw(r#"<input type="checkbox">"#, "");
assert_eq!(tl.layout[0].word, "□");
assert_eq!(tl.layout[1].word, " ");
assert!(
tl.layout[..2].iter().all(|b| b.bcolor.is_none()),
"toggle controls must not paint a background of their own"
);
}
#[test]
fn test_checkbox_checked_renders_box_glyph() {
let (_, tl) = draw(r#"<input type="checkbox" checked>"#, "");
assert_eq!(tl.layout[0].word, "■");
}
#[test]
fn test_radio_renders_circle_glyphs() {
let (_, tl) = draw(r#"<input type="radio"><input type="radio" checked>"#, "");
let cols = COLS as usize;
assert_eq!(tl.layout[0].word, "○");
assert_eq!(tl.layout[cols].word, "●");
}
#[test]
fn test_paste_into_single_line_input_collapses_newlines() {
let mut state = InputState::new();
state.focused_input = Some(NodeId::default());
state.focused_is_textarea = false;
state.input_value = "ab".into();
state.input_cursor = 2;
let mut cmds = Vec::new();
assert!(paste_into_field(
&mut state,
"xy\nz",
NodeId::default(),
&mut cmds
));
assert_eq!(state.input_value, "abxyz");
assert_eq!(state.input_cursor, 5, "caret advances past the pasted text");
assert!(
cmds.iter()
.any(|c| matches!(c, RenderCmd::RenderInput { .. })),
"paste must repaint the field"
);
}
#[test]
fn test_paste_into_textarea_keeps_newlines() {
let mut state = InputState::new();
state.focused_input = Some(NodeId::default());
state.focused_is_textarea = true;
state.input_value = "ab".into();
state.input_cursor = 2;
let mut cmds = Vec::new();
assert!(paste_into_field(
&mut state,
"xy\nz",
NodeId::default(),
&mut cmds
));
assert_eq!(
state.input_value, "abxy\nz",
"textarea keeps pasted newlines"
);
assert_eq!(state.input_cursor, 6);
}
#[test]
fn test_paste_at_caret_inserts_in_middle() {
let mut state = InputState::new();
state.focused_input = Some(NodeId::default());
state.input_value = "abcd".into();
state.input_cursor = 2;
let mut cmds = Vec::new();
assert!(paste_into_field(
&mut state,
"XY",
NodeId::default(),
&mut cmds
));
assert_eq!(state.input_value, "abXYcd");
assert_eq!(state.input_cursor, 4);
}
#[test]
fn test_paste_only_newlines_into_input_is_noop() {
let mut state = InputState::new();
state.input_value = "ab".into();
state.input_cursor = 1;
state.focused_is_textarea = false;
let mut cmds = Vec::new();
assert!(
!paste_into_field(&mut state, "\r\n", NodeId::default(), &mut cmds),
"all-collapsed paste must not touch the value"
);
assert_eq!(state.input_value, "ab");
}
#[test]
fn test_checkbox_toggle_flips_checked_attribute() {
let (mut engine, geo) = build_geometry(r#"<div><input id="c" type="checkbox"></div>"#);
let map = HitMap::build(&geo);
let c = find_node_by_id(&engine.arena(), "c");
assert!(
!engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"starts unchecked"
);
let cmds = apply(&mut engine, &map, ScriptMsg::ToggleCheck { node_id: c });
assert!(
engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"first click checks the box"
);
assert!(
cmds.iter()
.any(|cmd| matches!(cmd, RenderCmd::RenderArena { .. })),
"toggle must mark the node dirty for re-render"
);
apply(&mut engine, &map, ScriptMsg::ToggleCheck { node_id: c });
assert!(
!engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"second click unchecks the box"
);
}
#[test]
fn test_radio_group_mutual_exclusion() {
let (mut engine, geo) = build_geometry(
r#"<div><input id="a" type="radio" name="g" checked><input id="b" type="radio" name="g"></div>"#,
);
let map = HitMap::build(&geo);
let a = find_node_by_id(&engine.arena(), "a");
let b = find_node_by_id(&engine.arena(), "b");
assert!(
engine
.arena()
.get(a)
.unwrap()
.attributes
.contains_key("checked")
);
apply(&mut engine, &map, ScriptMsg::ToggleCheck { node_id: b });
let arena = engine.arena();
assert!(
!arena.get(a).unwrap().attributes.contains_key("checked"),
"same-name radio sibling must be cleared"
);
assert!(
arena.get(b).unwrap().attributes.contains_key("checked"),
"clicked radio must be checked"
);
}
#[test]
fn test_radio_reclick_keeps_selection() {
let (mut engine, geo) =
build_geometry(r#"<div><input id="a" type="radio" name="g" checked></div>"#);
let map = HitMap::build(&geo);
let a = find_node_by_id(&engine.arena(), "a");
apply(&mut engine, &map, ScriptMsg::ToggleCheck { node_id: a });
assert!(
engine
.arena()
.get(a)
.unwrap()
.attributes
.contains_key("checked"),
"radio stays selected on re-click"
);
}
#[test]
fn test_checkbox_click_full_path_toggles_and_fires_change() {
let (mut engine, geo) = build_geometry(
r#"<script>
var seen = [];
document.getElementById('c').addEventListener('change', function () {
seen.push('change');
});
globalThis.__seen = seen;
</script>
<div><input id="c" type="checkbox"></div>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("c").expect("checkbox region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
let c = find_node_by_id(&engine.arena(), "c");
assert!(
engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"click must check the box"
);
assert_eq!(
eval_str(&mut engine, "globalThis.__seen.length"),
"1",
"first flip fires one change"
);
click(&mut state, &mut engine, &map, x, y);
assert!(
!engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"second click unchecks"
);
assert_eq!(
eval_str(&mut engine, "globalThis.__seen.length"),
"2",
"second flip fires another change"
);
}
#[test]
fn test_label_renders_inline_with_surrounding_text() {
let (boxes, arena) = layout_boxes(r#"<p>fo<label id="l">la</label></p>"#);
let l = find_node_by_id(&arena, "l");
assert!(
find_box(&boxes, l).is_none(),
"inline label must not produce a box"
);
let (term_map, tl) = draw(r#"<p>fo<label id="l">la</label></p>"#, "");
let cols = term_map.columns as usize;
let joined: String = tl
.layout
.iter()
.enumerate()
.filter(|(_, b)| !b.word.is_empty() && b.word != " " && b.word != "[")
.map(|(i, b)| format!("{}{}", b.word, if i % cols == cols - 1 { " " } else { "" }))
.collect();
assert!(
joined.contains("fola"),
"label text must flow inline with the paragraph (got {joined:?})"
);
}
#[test]
fn test_label_owns_hit_region_over_its_text() {
let (_, geo) = build_geometry(r#"<p>fo<label id="l">la</label></p>"#);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("l").expect("label hit region exists");
assert_eq!(
region.rect.width, 2,
"region covers the label's `la` text slice"
);
assert_eq!(region.rect.height, 1);
}
#[test]
fn test_label_for_click_focuses_input() {
let (mut engine, geo) =
build_geometry(r#"<label id="l" for="f">Name:</label><input id="f" type="text">"#);
let map = HitMap::build(&geo);
let f = find_node_by_id(&engine.arena(), "f");
let region = map.resolve_by_id("l").expect("label hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
state.focused_input,
Some(f),
"clicking `<label for>` text must focus the associated input"
);
}
#[test]
fn test_label_wrapping_checkbox_toggles_on_click() {
let (mut engine, geo) =
build_geometry(r#"<label id="l"><input id="c" type="checkbox"> Agree</label>"#);
let map = HitMap::build(&geo);
let c = find_node_by_id(&engine.arena(), "c");
let region = map.resolve_by_id("l").expect("label hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert!(
engine
.arena()
.get(c)
.unwrap()
.attributes
.contains_key("checked"),
"clicking a wrapping label must activate its control"
);
}
#[test]
fn test_label_wrapped_checkbox_renders_box() {
let (boxes, arena) =
layout_boxes(r#"<p><label><input id="c" type="checkbox"> Agree</label></p>"#);
let c = find_node_by_id(&arena, "c");
let b = find_box(&boxes, c).expect("wrapped checkbox must keep its box");
assert_eq!(
b.rect.width, 2,
"toggle control is the glyph + one spacing cell"
);
let (_, tl) = draw(r#"<p><label><input type="checkbox"> Agree</label></p>"#, "");
assert!(
tl.layout.iter().any(|b| b.word == "□"),
"wrapped checkbox must paint `□`"
);
}
#[test]
fn test_button_renders_centred_label() {
let (_, tl) = draw(r#"<button>Save</button>"#, "");
let glyphs: String = tl
.layout
.iter()
.filter(|b| !b.word.is_empty() && b.word != " ")
.map(|b| b.word.clone())
.collect();
assert_eq!(
glyphs, "Save",
"button paints its label on the filled background"
);
}
#[test]
fn test_disabled_button_swallows_click() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('b').addEventListener('click', function () { n.push(1); });
globalThis.__n = n;
</script>
<button id="b" disabled>No</button>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"0",
"a disabled button must swallow the click entirely"
);
}
#[test]
fn test_submit_button_fires_submit_on_containing_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><button id="b">Go</button></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"clicking a submit button must fire `submit` on the containing form"
);
}
#[test]
fn test_onsubmit_attribute_runs_on_submit_click() {
let (mut engine, geo) = build_geometry(
r#"<form id="f" onsubmit="globalThis.__s = (globalThis.__s || 0) + 1">
<button id="b">Go</button>
</form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__s"),
"1",
"the `onsubmit` attribute handler must run on submit"
);
}
#[test]
fn test_reset_button_fires_reset_on_containing_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('reset', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><button id="b" type="reset">Clear</button></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"clicking a reset button must fire `reset` on the containing form"
);
}
#[test]
fn test_type_button_does_not_submit() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><button id="b" type="button">Go</button></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"0",
"`type=button` must not fire submit"
);
}
#[test]
fn test_submit_button_nested_in_div_finds_ancestor_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><div><section><button id="b">Go</button></section></div></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("button hit region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"a submit button nested under divs must still find its ancestor form"
);
}
#[test]
fn test_radio_click_fires_change_once_for_group_flip() {
let (mut engine, geo) = build_geometry(
r#"<script>
var seen = [];
document.getElementById('b').addEventListener('change', function () {
seen.push('b');
});
globalThis.__seen = seen;
</script>
<div>
<input id="a" type="radio" name="g" checked>
<input id="b" type="radio" name="g">
</div>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("b").expect("radio region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__seen.length"),
"1",
"checking a radio fires change on it"
);
}
#[test]
fn test_input_submit_fires_submit_on_containing_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><input id="s" type="submit"></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("s").expect("submit input region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"clicking `<input type=submit>` must fire `submit` on the containing form"
);
}
#[test]
fn test_input_reset_fires_reset_on_containing_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('reset', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><input id="s" type="reset"></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("s").expect("reset input region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"clicking `<input type=reset>` must fire `reset` on the containing form"
);
}
#[test]
fn test_input_type_button_does_not_submit() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
document.getElementById('f').addEventListener('reset', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><input id="s" type="button" value="Go"></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("s").expect("button input region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"0",
"`type=button` input must not fire submit or reset"
);
}
#[test]
fn test_input_submit_click_does_not_focus() {
let (mut engine, geo) =
build_geometry(r#"<input id="f" type="text"><input id="s" type="submit">"#);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("s").expect("submit input region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
state.focused_input = Some(find_node_by_id(&engine.arena(), "f"));
click(&mut state, &mut engine, &map, x, y);
assert!(
state.focused_input.is_none(),
"clicking a submit input must blur any focused text field"
);
assert_eq!(
eval_str(
&mut engine,
"document.getElementById('s').getAttribute('type')"
),
"submit",
"the button input stays a submit input"
);
}
#[test]
fn test_input_image_fires_submit_on_containing_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><input id="s" type="image" alt="Go"></form>"#,
);
let map = HitMap::build(&geo);
let region = map.resolve_by_id("s").expect("image input region");
let (x, y) = center(region.rect);
let mut state = InputState::new();
click(&mut state, &mut engine, &map, x, y);
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"clicking `<input type=image>` must fire `submit` on the containing form"
);
}
#[test]
fn test_enter_implicit_submission_fires_submit_on_form() {
let (mut engine, geo) = build_geometry(
r#"<script>
var n = [];
document.getElementById('f').addEventListener('submit', function () { n.push(1); });
globalThis.__n = n;
</script>
<form id="f"><input id="t" type="text"></form>"#,
);
let map = HitMap::build(&geo);
let form = find_node_by_id(&engine.arena(), "f");
let cmds = apply(&mut engine, &map, ScriptMsg::FormSubmit { form_id: form });
assert_eq!(
eval_str(&mut engine, "globalThis.__n.length"),
"1",
"Enter must submit the containing form"
);
assert!(!cmds.is_empty() || eval_str(&mut engine, "globalThis.__n.length") == "1");
}
#[test]
fn test_readonly_input_focuses_but_disabled_does_not() {
let (mut engine, geo) = build_geometry(
r#"<input id="r" type="text" value="fixed" readonly><input id="d" type="text" disabled>"#,
);
let map = HitMap::build(&geo);
let mut state = InputState::new();
let region = map.resolve_by_id("r").expect("readonly region");
let (x, y) = center(region.rect);
click(&mut state, &mut engine, &map, x, y);
let r = find_node_by_id(&engine.arena(), "r");
assert_eq!(
state.focused_input,
Some(r),
"readonly fields stay focusable"
);
let region = map.resolve_by_id("d").expect("disabled region");
let (x, y) = center(region.rect);
click(&mut state, &mut engine, &map, x, y);
let d = find_node_by_id(&engine.arena(), "d");
assert_eq!(
state.focused_input,
Some(r),
"disabled fields must never take focus"
);
assert_ne!(state.focused_input, Some(d));
}
#[test]
fn test_css_border_paints_a_control_frame() {
let (term_map, tl) = draw(
r#"<input id="b" value="bordered">"#,
"input { border: 1ch solid yellow; }",
);
let cols = term_map.columns as usize;
let rows: Vec<String> = tl
.layout
.chunks(cols)
.map(|chunk| chunk.iter().map(|b| b.word.as_str()).collect())
.collect();
let words: Vec<&str> = tl.layout.iter().map(|b| b.word.as_str()).collect();
for glyph in ['┌', '┐', '└', '┘', '─', '│'] {
assert!(
words.iter().any(|w| *w == glyph.to_string()),
"border glyph `{glyph}` must be painted"
);
}
let frame_cell = tl
.layout
.iter()
.find(|b| b.word == "─")
.expect("a horizontal edge cell exists");
assert_eq!(
frame_cell.fcolor,
Some(crossterm::style::Color::Rgb {
r: 255,
g: 255,
b: 0
}),
"border must use the declared colour"
);
assert!(
rows.iter().any(|r| r.contains("bordered")),
"the value must render inside the bordered field"
);
}
#[test]
fn test_control_without_border_paints_no_frame() {
let (term_map, tl) = draw(r#"<input id="b" value="plain">"#, "");
assert!(
!tl.layout
.iter()
.any(|b| { matches!(b.word.as_str(), "┌" | "┐" | "└" | "┘" | "─" | "│") }),
"no frame glyphs without a CSS border"
);
let cols = term_map.columns as usize;
let rows: Vec<String> = tl
.layout
.chunks(cols)
.map(|chunk| chunk.iter().map(|b| b.word.as_str()).collect())
.collect();
assert!(
rows.iter().any(|r| r.contains("plain")),
"the value must render"
);
}