use std::path::Path;
use opy_rs::diag::{Position, Span};
use opy_rs::tooling::{self, SymbolKind, check};
const MULTI_MAIN: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/multi-file/main.opy"
);
#[test]
fn multi_file_project_checks_and_resolves_end_to_end() {
let source = std::fs::read_to_string(MULTI_MAIN).expect("fixture exists");
let root = Path::new(MULTI_MAIN).parent().expect("fixture parent");
let outcome = check(&source, MULTI_MAIN, root);
assert!(
outcome.is_clean(),
"multi-file fixture must check clean, got: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("a clean project resolves");
assert_eq!(outcome.files.len(), 2);
assert_eq!(outcome.files[0].id, 0);
assert_eq!(outcome.files[1].path, "shared/defs.opy");
assert_eq!(model.file(1), Some("shared/defs.opy"));
assert_eq!(model.declarations().len(), 5);
assert!(model
.declarations()
.iter()
.any(|decl| matches!(decl, opy_rs::hir::types::Declaration::GlobalVariable { name, .. } if name == "total")));
assert!(model
.declarations()
.iter()
.any(|decl| matches!(decl, opy_rs::hir::types::Declaration::PlayerVariable { name, .. } if name == "P")));
assert_eq!(model.rules().len(), 2);
assert!(model.rules().iter().any(|entry| matches!(
entry,
opy_rs::hir::types::RuleEntry::Rule(rule) if rule.name == "collect"
)));
assert!(model.rules().iter().any(|entry| matches!(
entry,
opy_rs::hir::types::RuleEntry::SubroutineDef { name, .. } if name == "finish"
)));
assert_eq!(model.defines().len(), 1);
assert_eq!(model.defines()[0].name, "SCALE");
assert_eq!(model.enums().len(), 1);
assert_eq!(model.enums()[0].name, "Direction");
assert_eq!(model.enums()[0].members.len(), 2);
let total = model.symbol("total").expect("globalvar from the include");
assert_eq!(total.kind, SymbolKind::Global);
assert_eq!(total.declaration.path, "shared/defs.opy");
assert_eq!(total.declaration.start.line, 1);
let p = model.symbol("P").expect("playervar from the main file");
assert_eq!(p.kind, SymbolKind::Player);
assert!(p.declaration.path.ends_with("main.opy"));
let reset = model
.symbol("resetScore")
.expect("subroutine from the include");
assert_eq!(reset.kind, SymbolKind::Subroutine);
assert_eq!(
model
.symbol("doubleIt")
.expect("macro from the include")
.kind,
SymbolKind::Macro
);
let finish_symbols: Vec<_> = model
.symbols()
.iter()
.filter(|symbol| symbol.name == "finish")
.collect();
assert_eq!(finish_symbols.len(), 2);
assert!(
finish_symbols
.iter()
.any(|symbol| symbol.kind == SymbolKind::Subroutine)
);
assert!(
finish_symbols
.iter()
.any(|symbol| symbol.kind == SymbolKind::Def)
);
assert_eq!(total.references.len(), 4);
let main_refs: Vec<_> = total
.references
.iter()
.filter(|reference| reference.file_id == 0)
.collect();
assert_eq!(main_refs.len(), 3);
assert!(
main_refs
.iter()
.all(|reference| reference.path.ends_with("main.opy"))
);
let defs_ref = total
.references
.iter()
.find(|reference| reference.file_id == 1)
.expect("the def body reference");
assert_eq!(defs_ref.path, "shared/defs.opy");
assert_eq!(defs_ref.start.line, 13);
assert_eq!(reset.references.len(), 1);
assert_eq!(reset.references[0].start.line, 11);
assert_eq!(model.symbol("doubleIt").expect("macro").references.len(), 1);
let main_reference = total
.references
.iter()
.find(|reference| reference.start.line == 9)
.expect("the augmented-assignment reference in the main file");
let at_reference = model
.provenance(main_reference.to_span())
.expect("reference provenance");
assert_eq!(at_reference.path, MULTI_MAIN);
assert_eq!(at_reference.start.line, 9);
assert_eq!(
model
.symbol_at(main_reference.to_span())
.expect("symbol at reference")
.name,
"total"
);
let json = serde_json::to_value(&model).expect("the model serializes");
assert_eq!(json["hir"]["protocol"]["name"], "wright/opy-hir");
assert!(json["symbols"].as_array().expect("symbols array").len() >= 5);
assert!(json["enums"].as_array().expect("enums array").len() == 1);
}
#[test]
fn tooling_exposes_define_member_identity() {
let outcome = check(
"#!defineMember VALUE 2\nglobalvar result\nrule \"member define\":\n @Event global\n result = VALUE\n",
"main.opy",
Path::new("."),
);
let model = outcome
.model
.expect("member define project must check clean");
assert_eq!(model.defines().len(), 1);
assert!(model.defines()[0].is_member);
}
#[rustfmt::skip]
const STABLE_DIAGNOSTICS: &[(&str, &str)] = &[
("globalvar money\nrule \"r\":\n @Event global\n money += {\n Mei.GENERIC: 10,\n }\n", "dict-access"),
("rule \"x\"\n @Event global\n", "parse-error"),
("rule \"r\":\n @Event global\n frobnicate()\n", "unknown-action"),
("globalvar x\nrule \"r\":\n @Event global\n x = frobnicate()\n", "unknown-value"),
("globalvar x\nrule \"r\":\n @Event global\n x = nope\n", "unknown-identifier"),
("rule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n", "unknown-member"),
("globalvar g\nrule \"r\":\n @Event global\n chaseOverTime(g, 10, 3, 4, 5)\n", "invalid-arity"),
("rule \"r\":\n @Event global\n isGameInProgress()\n", "value-in-action-position"),
("#!define double(x) x + x\nrule \"r\":\n @Event global\n double(1, 2)\n", "macro-arity"),
("settings {\n \"gamemodes\": {}\n", "settings-invalid"),
];
#[test]
fn diagnostic_codes_are_stable_for_malformed_inputs() {
for (source, expected_code) in STABLE_DIAGNOSTICS {
let outcome = check(source, "main.opy", Path::new(""));
let diagnostic = outcome
.diagnostics
.first()
.unwrap_or_else(|| panic!("expected a '{expected_code}' diagnostic for:\n{source}"));
assert_eq!(
diagnostic.code, *expected_code,
"unexpected first diagnostic for:\n{source}\ngot: {:?}",
outcome.diagnostics
);
assert!(
diagnostic.span.is_some(),
"the '{expected_code}' diagnostic must be source-located:\n{source}"
);
assert!(outcome.model.is_none(), "a failing project has no model");
}
}
#[test]
fn workshop_script_source_reports_exactly_one_diagnostic() {
let outcome = check(
"rule \"workshop style\" {\n event {\n Ongoing - Global;\n }\n actions {\n Wait(1, Ignore Condition);\n }\n}\n",
"main.opy",
Path::new(""),
);
assert_eq!(
outcome.diagnostics.len(),
1,
"expected one diagnostic, got {:?}",
outcome.diagnostics
);
let diagnostic = &outcome.diagnostics[0];
assert_eq!(diagnostic.code, "workshop-source");
assert_eq!(diagnostic.span.as_ref().expect("span").path, "main.opy");
assert!(outcome.model.is_none(), "a failing project has no model");
}
#[test]
fn include_failures_are_stable_and_source_located() {
let outcome = check(
"#!include \"nope.opy\"\n",
"main.opy",
Path::new("/nonexistent-opy-root"),
);
let diagnostic = outcome.diagnostics.first().expect("include-not-found");
assert_eq!(diagnostic.code, "include-not-found");
assert_eq!(diagnostic.span.as_ref().expect("span").start.line, 1);
let dir = std::env::temp_dir().join(format!("opy-tooling-cycle-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
let outcome = check(&main, "a.opy", &dir);
assert_eq!(
outcome.diagnostics.first().expect("include-cycle").code,
"include-cycle"
);
assert_eq!(outcome.files.len(), 3);
assert_eq!(outcome.files[1].path, "b.opy");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn all_parse_diagnostics_are_reported() {
let outcome = check(
"rule \"a\"\n @Event global\nrule \"b\"\n",
"main.opy",
Path::new(""),
);
assert_eq!(outcome.diagnostics.len(), 3);
assert!(
outcome
.diagnostics
.iter()
.all(|diagnostic| diagnostic.code == "parse-error")
);
assert!(outcome.model.is_none());
}
#[test]
fn semantic_errors_follow_the_compile_first_error_contract() {
let outcome = check(
"rule \"r\":\n @Event global\n x = frobnicate()\n",
"main.opy",
Path::new(""),
);
let diagnostic = outcome.diagnostics.first().expect("first semantic error");
assert_eq!(diagnostic.code, "unknown-identifier");
let compile_error = opy_rs::compile(
"rule \"r\":\n @Event global\n x = frobnicate()\n",
"main.opy",
Path::new(""),
)
.unwrap_err();
assert_eq!(compile_error.code, diagnostic.code);
}
#[test]
fn check_with_overlay_resolves_unsaved_includes() {
let mut overlay = std::collections::BTreeMap::new();
overlay.insert(
"shared/defs.opy".to_string(),
"globalvar total\n".to_string(),
);
let outcome = tooling::check_with_overlay(
"#!include \"shared/defs.opy\"\nrule \"r\":\n @Event global\n total = 1\n",
"main.opy",
Path::new(""),
&overlay,
);
assert!(
outcome.is_clean(),
"overlay include must resolve: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("model");
assert_eq!(
model.symbol("total").expect("symbol").kind,
SymbolKind::Global
);
}
#[test]
fn unknown_span_lookup_returns_none() {
let outcome = check("globalvar total\n", "main.opy", Path::new(""));
let model = outcome.model.expect("clean project");
let nowhere = Span::new(42, Position::new(1, 1), Position::new(1, 1));
assert!(model.symbol_at(nowhere).is_none());
assert!(model.provenance(nowhere).is_none());
assert_eq!(model.file(42), None);
}
#[test]
fn player_member_references_use_exact_member_span() {
let source = "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n";
let outcome = check(source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"player member source must check clean: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("clean project");
let symbol = model.symbol("I").expect("player variable symbol");
assert_eq!(symbol.references.len(), 2);
assert!(
symbol
.references
.iter()
.any(|reference| reference.start.line == 4 && reference.start.col == 20)
);
assert!(
symbol
.references
.iter()
.any(|reference| reference.start.line == 5 && reference.start.col == 20)
);
}
#[test]
fn settings_emission_agreement_between_check_and_compile() {
let unknown = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\", \"emptyKey\": \"\"},\n",
" \"lobby\": {\"mapRotation\": \"sometimes\"},\n",
" \"gamemodes\": {\"ffa\": {\"notASetting\": 3, \"aList\": [\"x\", 1], \"anObject\": {\"k\": true}}},\n",
" \"heroes\": {\"allTeams\": {\"shion\": {\"ability2Duration\": \"500%\"}}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(unknown, "main.opy", Path::new(""));
assert!(outcome.model.is_some(), "{:?}", outcome.diagnostics);
let warnings = outcome
.diagnostics
.iter()
.map(|diagnostic| (diagnostic.severity, diagnostic.code.as_str()))
.collect::<Vec<_>>();
assert_eq!(
warnings,
[(tooling::DiagnosticSeverity::Warning, "unknown-setting"); 6]
);
let lines = compiled_lines(unknown);
for line in [
"emptyKey:",
"Map Rotation: sometimes",
"notASetting: 3",
"aList {",
"x",
"1",
"anObject {",
"k: true",
"ability2Duration: 500%",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
let invalid = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\"},\n",
" \"gamemodes\": {}, \"heroes\": {\"allTeams\": {\"notAHero\": 1}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(invalid, "main.opy", Path::new(""));
assert!(outcome.model.is_none());
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.severity == tooling::DiagnosticSeverity::Error)
.expect("a team-level scalar must fail check");
assert_eq!(diagnostic.code, "workshop-emission");
assert_eq!(diagnostic.span.as_ref().expect("span").path, "main.opy");
let compile_error = opy_rs::compile(invalid, "main.opy", Path::new("")).unwrap_err();
assert_eq!(compile_error.code, "workshop-emission");
let valid = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\"},\n",
" \"gamemodes\": {\"ffa\": {\"enabledMaps\": [\"workshopIsland\"], \"enableKillCam\": false}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(valid, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"inherited gamemode key must check clean: {:?}",
outcome.diagnostics
);
}
#[test]
fn a_list_under_an_enum_key_compiles_as_the_reference_block() {
let source = concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"lobby\": {\"mapRotation\": [\"a\", \"paused\", 1, true]}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"the carried block must check clean: {:?}",
outcome.diagnostics
);
let lines = compiled_lines(source);
let block = lines
.iter()
.position(|line| line == "Map Rotation {")
.expect("the enum key writes a display-name block");
assert_eq!(&lines[block + 1..block + 5], ["a", "paused", "1", "true"]);
}
#[test]
fn an_object_under_an_enum_key_compiles_as_the_reference_block() {
let source = concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"lobby\": {\"mapRotation\": {\"a\": 1, \"b\": {\"c\": 2}}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"the carried block must check clean: {:?}",
outcome.diagnostics
);
let lines = compiled_lines(source);
let block = lines
.iter()
.position(|line| line == "Map Rotation {")
.expect("the enum key writes a display-name block");
assert_eq!(&lines[block + 1..block + 4], ["a: 1", "b {", "c: 2"]);
assert_eq!(lines[block + 4], "}");
}
#[test]
fn settings_numbers_render_like_the_pinned_oracle() {
let source = concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"main\": {\"a\": 0b101, \"b\": 0o17, \"c\": 0x1F, \"d\": 1e21,\n",
" \"e\": 0.0000001, \"f\": 123456789012345678901234, \"g\": 0.5e3,\n",
" \"h\": 1.0, \"i\": 1e20, \"j\": 12e2, \"k\": 0.1,\n",
" \"list\": [0b101, 0o17, 0x1F, 1e21, 0.0000001]}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let lines = compiled_lines(source);
for line in [
"a: 5",
"b: 15",
"c: 31",
"d: 1e+21",
"e: 1e-7",
"f: 1.2345678901234569e+23",
"g: 500",
"h: 1",
"i: 100000000000000000000",
"j: 1200",
"k: 0.1",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
let list = lines
.iter()
.position(|line| line == "list {")
.expect("list block");
assert_eq!(
&lines[list + 1..list + 6],
["5", "15", "31", "1e+21", "1e-7"]
);
}
#[test]
fn hero_settings_apply_the_pinned_per_hero_schema() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"ana\": {\"ability3Cooldown%\": 50},\n",
" \"wreckingBall\": {\"ability3Cooldown%\": 50},\n",
" \"reinhardt\": {\"ammoClipSize%\": 50},\n",
" \"mercy\": {\"ammoClipSize%\": 50},\n",
" \"dva\": {\"ability2Height%\": 50},\n",
" \"freja\": {\"ability2Height%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in [
"ability3Cooldown%: 50",
"ammoClipSize%: 50",
"ability2Height%: 50",
"Piledriver Cooldown Time: 50%",
"Ammunition Clip Size Scalar: 50%",
"Updraft Height: 50%",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
for translated in [
"Sleep Dart Cooldown Time",
"Dynamite Cooldown Time",
"Biotic Grenade",
] {
assert!(
!lines.iter().any(|line| line.contains(translated)),
"{translated:?} unexpectedly in {lines:?}"
);
}
}
#[test]
fn hero_settings_rewrite_the_pinned_name_and_key_aliases() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"mccree\": {\"ability1Cooldown%\": 50},\n",
" \"dva\": {\"ability1KB%\": 50},\n",
" \"ana\": {\"ability1KB%\": 50},\n",
" \"hammond\": {\"ability3Cooldown%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in [
"Cassidy {",
"Combat Roll Cooldown Time: 50%",
"Boosters Knockback Scalar: 50%",
"ability1Kb%: 50",
"Piledriver Cooldown Time: 50%",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
assert!(
!lines.iter().any(|line| line.contains("ability1KB%")),
"authored spelling must not survive in {lines:?}"
);
}
#[test]
fn hero_roster_lists_emit_after_hero_groups_with_canonical_names() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"enabledHeroes\": [\"mccree\", \"hammond\"],\n",
" \"dva\": {\"health%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
let enabled = lines
.iter()
.position(|line| line == "enabled heroes {")
.expect("hero list");
let dva = lines
.iter()
.position(|line| line == "D.Va {")
.expect("hero group");
assert!(
dva < enabled,
"hero list must follow hero groups: {lines:?}"
);
for line in ["Cassidy", "Wrecking Ball"] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
}
#[test]
fn hero_kb_rename_uses_the_pinned_assign_delete_order() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"dva\": {\"ability1KB%\": 20, \"health%\": 150}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
let health = lines
.iter()
.position(|line| line == "Health: 150%")
.expect("health member");
let kb = lines
.iter()
.position(|line| line == "Boosters Knockback Scalar: 20%")
.expect("renamed member");
assert!(health < kb, "renamed member must move last: {lines:?}");
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"dva\": {\"ability1Kb%\": 10, \"health%\": 150, \"ability1KB%\": 20}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
let health = lines
.iter()
.position(|line| line == "Health: 150%")
.expect("health member");
let kb = lines
.iter()
.position(|line| line == "Boosters Knockback Scalar: 20%")
.expect("renamed member");
assert!(
kb < health,
"collapsed member keeps the destination position: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.contains("10%")),
"the source value wins over the authored destination: {lines:?}"
);
}
#[test]
fn hero_group_rename_uses_the_pinned_assign_delete_order() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"mccree\": {\"health%\": 50},\n",
" \"ana\": {\"health%\": 150}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
let ana = lines
.iter()
.position(|line| line == "Ana {")
.expect("ana group");
let cassidy = lines
.iter()
.position(|line| line == "Cassidy {")
.expect("renamed group");
assert!(ana < cassidy, "renamed group must move last: {lines:?}");
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"mccree\": {\"health%\": 50},\n",
" \"cassidy\": {\"health%\": 10}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
assert_eq!(
lines.iter().filter(|line| **line == "Cassidy {").count(),
1,
"the canonical group collapses to one: {lines:?}"
);
assert!(
lines.contains(&"Health: 50%".to_string())
&& !lines.iter().any(|line| line.contains("10%")),
"the source members win: {lines:?}"
);
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"hammond\": {\"health%\": 50},\n",
" \"mccree\": {\"health%\": 60}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
let cassidy = lines
.iter()
.position(|line| line == "Cassidy {")
.expect("cassidy group");
let wrecking_ball = lines
.iter()
.position(|line| line == "Wrecking Ball {")
.expect("wrecking ball group");
assert!(
cassidy < wrecking_ball,
"the mccree pass appends before the hammond pass: {lines:?}"
);
}
#[test]
fn general_children_are_not_reprocessed_as_hero_members() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"general\": {\n",
" \"dva\": {\"ability1KB%\": 50},\n",
" \"damageDealt%\": 50\n",
" },\n",
" \"ana\": {\"health%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in ["ability1KB%: 50", "Damage Dealt: 50%", "Health: 50%"] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
assert!(
!lines
.iter()
.any(|line| line.contains("Boosters Knockback Scalar")),
"a nested hero dict stays verbatim: {lines:?}"
);
}
#[test]
fn a_non_dict_general_yields_index_members_or_drops() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"general\": \"ab\",\n",
" \"ana\": {\"health%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in ["0: a", "1: b", "Health: 50%"] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"general\": [10, 20],\n",
" \"ana\": {\"health%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in ["0: 10", "1: 20"] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"general\": 5,\n",
" \"ana\": {\"health%\": 50}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
assert!(
lines.contains(&"Health: 50%".to_string())
&& !lines.iter().any(|line| line == "general: 5"),
"{lines:?} must drop the number member and keep the hero members"
);
}
#[test]
fn both_hero_lists_in_one_team_error() {
let error = opy_rs::compile(
concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"enabledHeroes\": [\"ana\"],\n",
" \"disabledHeroes\": [\"genji\"]\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
),
"main.opy",
Path::new(""),
)
.unwrap_err();
assert_eq!(error.code, "settings-hero-lists");
assert_eq!(
error.message,
"Cannot have both 'enabledHeroes' and 'disabledHeroes' in team 'allTeams'"
);
}
#[test]
fn inapplicable_hero_key_with_enum_value_stays_verbatim() {
let lines = compiled_lines(concat!(
"settings {\n",
" \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n",
" \"heroes\": {\"allTeams\": {\n",
" \"ana\": {\"enableSecondaryFire\": false},\n",
" \"genji\": {\"enableGenericSecondaryFire\": true}\n",
" }}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
));
for line in [
"enableSecondaryFire: false",
"enableGenericSecondaryFire: true",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
}
#[test]
fn misspelled_settings_key_warns_with_a_single_candidate_suffix() {
let source = concat!(
"settings {\n",
" \"main\": { \"descriptino\": \"x\" },\n",
" \"gamemodes\": {}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let expected = "unknown settings key 'descriptino' is passed through unchanged (did you mean 'description'?)";
let outcome = check(source, "main.opy", Path::new(""));
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "unknown-setting")
.expect("the misspelled key must warn");
assert_eq!(diagnostic.severity, tooling::DiagnosticSeverity::Warning);
assert_eq!(diagnostic.message, expected);
assert!(compiled_lines(source).contains(&"descriptino: x".to_string()));
}
fn compiled_lines(source: &str) -> Vec<String> {
opy_rs::Compiler::new()
.expect("compiler")
.compile_source(source, "main.opy", Path::new(""))
.expect("compiles")
.workshop
.lines()
.map(|line| line.trim().to_string())
.collect()
}
#[test]
fn empty_block_bodies_parse_like_the_pinned_oracle() {
for (name, source) in [
("def at eof", "def f():"),
(
"def before a decl",
"def f():\nrule \"r\":\n @Event global\n pass",
),
("rule at eof", "rule \"r\":"),
("rule before a decl", "rule \"r\":\nglobalvar h"),
("enum at eof", "enum e:"),
(
"enum before a decl",
"enum e:\nrule \"r\":\n @Event global\n pass",
),
("if at eof", "rule \"r\":\n @Event global\n if g:"),
(
"for at eof",
"rule \"r\":\n @Event global\n for g in range(2):",
),
(
"while at eof",
"rule \"r\":\n @Event global\n while g:",
),
(
"switch at eof",
"rule \"r\":\n @Event global\n switch g:",
),
(
"case at eof",
"rule \"r\":\n @Event global\n switch g:\n case 1:",
),
(
"default at eof",
"rule \"r\":\n @Event global\n switch g:\n default:",
),
(
"elif at eof",
"rule \"r\":\n @Event global\n if g:\n pass\n elif g:",
),
(
"else at eof",
"rule \"r\":\n @Event global\n if g:\n pass\n else:",
),
(
"do before same-indent while",
"rule \"r\":\n @Event global\n do:\n while g",
),
(
"if dedents to a sibling",
"rule \"r\":\n @Event global\n if g:\n g = 2",
),
(
"settings-only if body",
"rule \"r\":\n @Event global\n if g:\n settings {\n \"gamemodes\": {\"ffa\": {\"enabled\": true}}\n }",
),
] {
let source = format!("globalvar g\n{source}");
let outcome = check(&source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"{name} must check clean: {:?}",
outcome.diagnostics
);
opy_rs::compile(&source, "main.opy", Path::new(""))
.unwrap_or_else(|error| panic!("{name} must compile: {error:?}"));
}
let lines = compiled_lines(concat!(
"globalvar g\n",
"rule \"r\":\n @Event global\n if g:\n g = 2\n"
));
assert!(
lines.contains(&"Set Global Variable(g, 2);".to_string()),
"the dedented statement stays: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.starts_with("If(")),
"an empty lone if emits nothing: {lines:?}"
);
let lines = compiled_lines("def f():");
assert!(
lines.contains(&"0: f".to_string()),
"an empty def still registers the subroutine: {lines:?}"
);
for (name, source) in [
("macro at eof", "macro m():"),
(
"macro empty before a decl",
"macro m():\nrule \"r\":\n @Event global\n pass",
),
("do at eof", "rule \"r\":\n @Event global\n do:"),
(
"do with an indented while",
"rule \"r\":\n @Event global\n do:\n while g",
),
] {
let source = format!("globalvar g\n{source}");
let outcome = check(&source, "main.opy", Path::new(""));
assert!(
!outcome.is_clean(),
"{name} must reject: {:?}",
outcome.diagnostics
);
}
}
#[test]
fn elif_else_and_do_while_tails_obey_the_enclosing_block_floor() {
for (name, source) in [
(
"mid-dedent elif attaches",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n g = 2\n elif g:\n g = 1",
),
(
"mid-dedent else attaches",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n g = 2\n else:\n g = 1",
),
(
"mid-dedent do/while tail",
"globalvar g\nrule \"r\":\n @Event global\n do:\n while g",
),
(
"empty if, mid-dedent elif",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n elif g:\n g = 1",
),
] {
let outcome = check(source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"{name} must check clean: {:?}",
outcome.diagnostics
);
opy_rs::compile(source, "main.opy", Path::new(""))
.unwrap_or_else(|error| panic!("{name} must compile: {error:?}"));
}
for (name, source) in [
(
"else below the rule floor",
"globalvar g\nrule \"r\":\n @Event global\n if g:\nelse:\n g = 1",
),
(
"elif below the rule floor",
"globalvar g\nrule \"r\":\n @Event global\n if g:\nelif g:\n g = 1",
),
(
"do/while below the rule floor",
"globalvar g\nrule \"r\":\n @Event global\n do:\nwhile g",
),
(
"else below a nested floor",
"globalvar g\nrule \"r\":\n @Event global\n for g in range(2):\n if g:\n else:\n g = 1",
),
] {
let outcome = check(source, "main.opy", Path::new(""));
assert!(
!outcome.is_clean(),
"{name} must reject: {:?}",
outcome.diagnostics
);
}
}
#[test]
fn orphan_elif_else_warns_lone_else_and_rejects_instead_of_misparsing() {
for (name, keyword, source) in [
(
"elif after a sibling",
"elif",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n g = 5\n elif g:\n g = 1",
),
(
"else after a sibling",
"else",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n g = 5\n else:\n g = 1",
),
(
"else if after a sibling",
"else",
"globalvar g\nrule \"r\":\n @Event global\n if g:\n g = 5\n else if g:\n g = 1",
),
(
"bare elif",
"elif",
"globalvar g\nrule \"r\":\n @Event global\n elif g:\n g = 1",
),
] {
let outcome = check(source, "main.opy", Path::new(""));
assert!(
!outcome.is_clean(),
"{name} must reject: {:?}",
outcome.diagnostics
);
assert!(
outcome.diagnostics.iter().any(|diagnostic| {
diagnostic.severity == opy_rs::tooling::DiagnosticSeverity::Warning
&& diagnostic.code == "w_lone_else"
&& diagnostic.message.contains(&format!("Found '{keyword}'"))
}),
"{name} must surface the pinned w_lone_else warning: {:?}",
outcome.diagnostics
);
assert!(
outcome
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "unsupported-construct"),
"{name} must reject the unrepresentable marker: {:?}",
outcome.diagnostics
);
assert!(
opy_rs::compile(source, "main.opy", Path::new("")).is_err(),
"{name} must fail compile"
);
}
}