use super::*;
use crate::resolve::{MarkerKind, NodeKind};
fn rv4(src: &str) -> Program {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
resolve(&lowered, &[]).expect("resolve")
}
fn rv4_err(src: &str) -> String {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).expect("parse");
let result = crate::desugar::desugar(&file).and_then(|f| resolve(&f, &[]));
match result {
Err(e) => e.message,
Ok(_) => panic!("expected an error resolving {src:?}"),
}
}
fn num(p: &Program, node: usize, attr: &str) -> Option<f64> {
p.scene.nodes[node].attrs.number(attr)
}
fn ident<'a>(p: &'a Program, node: usize, attr: &str) -> Option<&'a str> {
match p.scene.nodes[node].attrs.get(attr) {
Some(ResolvedValue::Ident(s)) => Some(s.as_str()),
_ => None,
}
}
#[test]
fn bare_node_resolves() {
let p = rv4("|box#x|\n");
assert_eq!(p.scene.nodes.len(), 1);
assert_eq!(p.scene.nodes[0].id.as_deref(), Some("x"));
assert_eq!(p.scene.nodes[0].kind, NodeKind::Block);
}
#[test]
fn dumb_core_has_no_hidden_defaults() {
let src = "|block#x|\n";
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).expect("parse");
let p = resolve(&file, &[]).expect("resolve");
let attrs = &p.scene.nodes[0].attrs;
assert!(
attrs.get("radius").is_none(),
"no default radius in the core"
);
assert!(
attrs.get("padding").is_none(),
"no default padding in the core"
);
assert!(attrs.get("gap").is_none(), "no default gap in the core");
}
#[test]
fn element_rule_reaches_the_node() {
let p = rv4("{ |box| { radius: 4; } }\n|box#x|\n");
assert_eq!(num(&p, 0, "radius"), Some(4.0));
}
#[test]
fn descendant_rule_matches_a_nested_node() {
let p = rv4("{ |group| |box| { fill: gray; } }\n|group#g| [\n |box#a|\n]\n");
let a = &p.scene.nodes[0].children[0];
assert!(matches!(a.attrs.get("fill"), Some(ResolvedValue::Ident(s)) if s == "gray"));
}
#[test]
fn id_rule_targets_one_node() {
let p = rv4("{ #hero { fill: gold; } }\n|box#hero|\n|box#other|\n");
assert_eq!(ident(&p, 0, "fill"), Some("gold"));
assert_eq!(ident(&p, 1, "fill"), None);
}
#[test]
fn instance_block_beats_id_rule() {
let p = rv4("{ #hero { fill: gold; } }\n|box#hero| { fill: red }\n");
assert_eq!(ident(&p, 0, "fill"), Some("red"));
}
#[test]
fn class_rule_applies() {
let p = rv4("{ .hot { stroke: red; } }\n|box#x| .hot\n");
assert_eq!(ident(&p, 0, "stroke"), Some("red"));
assert_eq!(p.scene.nodes[0].applied_styles, vec!["hot"]);
}
#[test]
fn instance_block_beats_element_rule() {
let p = rv4("{ |box| { fill: white; } }\n|box#x| { fill: red; }\n");
assert_eq!(ident(&p, 0, "fill"), Some("red"));
}
#[test]
fn label_becomes_a_centred_text_child() {
let p = rv4("|box#cat| \"cat\"\n");
let label = &p.scene.nodes[0].children[0];
assert_eq!(label.kind, NodeKind::Text);
assert_eq!(label.label.as_deref(), Some("cat"));
}
#[test]
fn an_empty_label_draws_nothing() {
let p = rv4("|box#cat| \"\"\n");
assert!(p.scene.nodes[0].children.is_empty());
}
#[test]
fn caption_is_a_small_text_plain_title() {
let p = rv4("|group#g| [\n |caption| \"Title\"\n]\n");
let cap = &p.scene.nodes[0].children[0];
assert!(cap.type_chain.iter().any(|t| t == "caption"));
assert!(matches!(
cap.attrs.get("pin"),
Some(ResolvedValue::Tuple(_))
));
assert!(cap.attrs.get("mount").is_none());
assert!(matches!(cap.attrs.get("font-size"), Some(ResolvedValue::Number(n)) if *n == 12.0));
assert_eq!(cap.children[0].label.as_deref(), Some("Title"));
}
#[test]
fn group_label_lowers_to_a_caption() {
let p = rv4("|group#k| \"Kitchen\"\n");
let cap = &p.scene.nodes[0].children[0];
assert!(cap.type_chain.iter().any(|t| t == "caption"));
assert_eq!(cap.children[0].label.as_deref(), Some("Kitchen"));
}
#[cfg(feature = "icons")]
#[test]
fn icon_named_by_symbol_with_optional_text() {
let p = rv4("|icon#i| { symbol: house } [ \"3\" ]\n");
assert_eq!(p.scene.nodes[0].kind, NodeKind::Icon);
assert_eq!(ident(&p, 0, "symbol"), Some("house"));
assert_eq!(p.scene.nodes[0].label, None);
let child = &p.scene.nodes[0].children[0];
assert_eq!(child.kind, NodeKind::Text);
assert_eq!(child.label.as_deref(), Some("3"));
}
#[cfg(feature = "icons")]
#[test]
fn icon_label_sets_the_symbol() {
let p = rv4("|icon#i| \"house\"\n");
assert_eq!(ident(&p, 0, "symbol"), Some("house"));
}
#[cfg(feature = "icons")]
#[test]
fn icon_symbol_set_twice_errors() {
assert!(
rv4_err("|icon#i| \"house\" { symbol: heart }\n")
.contains("symbol is its label or 'symbol:', not both")
);
}
#[test]
fn text_properties_inherit_to_descendants() {
let p = rv4("|group#g| { font-size: 10 } [\n \"hi\"\n]\n");
let t = &p.scene.nodes[0].children[0];
assert_eq!(t.kind, NodeKind::Text);
assert_eq!(t.attrs.number("font-size"), Some(10.0));
}
#[test]
fn define_body_materializes_per_instance() {
let p = rv4("{ |room::group| [\n |box#inlet|\n] }\n|room#r|\n");
let inlet = &p.scene.nodes[0].children[0];
assert_eq!(inlet.id.as_deref(), Some("inlet"));
}
#[test]
fn root_link_auto_creates_undeclared_endpoints() {
let p = rv4("cat -> dog\n");
let ids: Vec<&str> = p
.scene
.nodes
.iter()
.filter_map(|n| n.id.as_deref())
.collect();
assert!(ids.contains(&"cat") && ids.contains(&"dog"));
assert_eq!(p.links.len(), 1);
}
#[test]
fn link_selector_styles_every_link() {
let p = rv4("{ |-| { stroke: red; stroke-width: 3 } }\na -> b\n");
assert!(matches!(p.links[0].attrs.get("stroke"), Some(ResolvedValue::Ident(s)) if s == "red"));
assert_eq!(p.links[0].attrs.number("stroke-width"), Some(3.0));
}
#[test]
fn scoped_link_rule_overrides_the_root_one() {
let p = rv4(
"{ |-| { stroke: --gray }\n#g |-| { stroke: --red-ink } }\n|box#a|\n|group#g| [\n |box#x|\n |box#y|\n x -> y\n]\na -> g\n",
);
let stroke_var = |i: usize| match p.links[i].attrs.get("stroke") {
Some(ResolvedValue::LiveVar { name, .. }) => name.clone(),
other => panic!("expected a var stroke, got {other:?}"),
};
assert_eq!(stroke_var(0), "gray");
assert_eq!(stroke_var(1), "red-ink");
}
#[test]
fn clearance_cascades_from_a_container_block() {
let p = rv4(
"{ clearance: 8 }\n|box#a|\n|group#g| { clearance: 20 } [\n |box#x|\n |box#y|\n x -> y\n]\na -> g\n",
);
assert_eq!(p.links[0].attrs.number("clearance"), Some(8.0)); assert_eq!(p.links[1].attrs.number("clearance"), Some(20.0)); }
#[test]
fn removed_routing_is_rejected() {
assert_eq!(
rv4_err("{ routing: curved }\na -> b\n"),
"routing takes orthogonal, natural, or straight — 'curved' was replaced by 'natural'"
);
rv4("{ routing: orthogonal }\na -> b\n"); let p = rv4("{ routing: natural }\na -> b\n");
assert_eq!(p.links[0].routing, crate::resolve::Strategy::Natural);
let p = rv4("{ routing: straight }\na -> b\n");
assert_eq!(p.links[0].routing, crate::resolve::Strategy::Straight);
}
#[test]
fn operator_sets_markers_and_line_style() {
let p = rv4("|box#a|\n|box#b|\na --> b\n");
let w = &p.links[0];
assert_eq!(w.markers.end, MarkerKind::Arrow);
assert!(matches!(w.attrs.get("stroke-style"), Some(ResolvedValue::Ident(s)) if s == "dashed"));
}
#[test]
fn fan_expands_to_one_link_per_pair() {
let p = rv4("|box#a|\n|box#b|\n|box#c|\na & b -> c\n");
assert_eq!(p.links.len(), 2);
}
#[test]
fn a_sequence_frame_is_scope_transparent() {
let p = rv4("{ layout: sequence }\n|box#api|\n|cyl#db|\napi -> db\n|alt| [\n db --> api\n]\n");
assert_eq!(p.links.len(), 2);
assert!(
p.links.iter().all(|w| w.scope.is_empty()),
"frame message hoisted to scene scope"
);
let ret = &p.links[1];
assert_eq!(ret.endpoints[0].path, "db");
assert_eq!(ret.endpoints[1].path, "api");
let alt = p
.scene
.nodes
.iter()
.find(|n| n.type_chain.iter().any(|t| t == "alt"))
.expect("the alt frame");
assert!(
alt.children.iter().all(|c| c.id.is_none()),
"no phantom boxes inside the frame"
);
}
#[test]
fn internal_link_resolves_with_scoped_paths() {
let p =
rv4("{ |room::group| [\n |box#inlet|\n |box#outlet|\n inlet -> outlet\n] }\n|room#r|\n");
let w = &p.links[0];
assert_eq!(w.endpoints[0].path, "r.inlet");
assert_eq!(w.endpoints[1].path, "r.outlet");
}
#[test]
fn unknown_type_errors() {
assert!(rv4_err("|ghost#x|\n").contains("unknown type 'ghost'"));
}
#[test]
fn unknown_class_errors() {
assert!(rv4_err("|box#x| .nope\n").contains("unknown class '.nope'"));
}
#[test]
fn duplicate_id_errors() {
assert!(rv4_err("|box#a|\n|oval#a|\n").contains("duplicate id 'a'"));
}
#[test]
fn side_names_are_free_ids() {
let p = rv4("|box#top|\n");
assert_eq!(p.scene.nodes[0].id.as_deref(), Some("top"));
}
#[test]
fn body_link_endpoint_not_found_suggests() {
let e = rv4_err("|group#g| [\n |box#x|\n g.y -> x\n]\n");
assert!(e.contains("not found"), "got: {e}");
}