use workshop_rs::catalog::{Catalog, Locale};
use workshop_rs::parser;
use workshop_rs::roundtrip;
use workshop_rs::validate;
use workshop_rs::wir::{self, Action, Value};
fn catalog() -> Catalog {
Catalog::builtin().expect("built-in catalog")
}
fn program_with(conditions: &str, actions: &str) -> wir::Program {
let source = format!(
r#"variables
{{
global:
0: probe
}}
rule ("contextual semantics")
{{
event {{ Ongoing - Global; }}
conditions {{
{conditions}
}}
actions {{
{actions}
}}
}}"#
);
parser::parse(&source, &catalog(), &Locale::new("en-US")).expect("contextual source parses")
}
fn program(actions: &str) -> wir::Program {
program_with("", actions)
}
fn first_action_args(program: &wir::Program) -> &[wir::ValueId] {
let rule = program.rules.iter().next().expect("rule");
match program.actions.get(rule.actions[0]).expect("action") {
Action::Call { args, .. } => args,
other => panic!("expected a catalog action call, got {other:?}"),
}
}
fn validate_program(program: &wir::Program) {
validate::validate_canonical_ids(program, &catalog()).expect("canonical validation");
}
fn first_value_call<'a>(program: &'a wir::Program, name: &str) -> &'a [wir::ValueId] {
program
.values
.iter()
.find_map(|node| match &node.value {
Value::Call {
name: value_name,
args,
} if value_name == name => Some(args.as_slice()),
_ => None,
})
.unwrap_or_else(|| panic!("missing value call {name}"))
}
#[test]
fn numeric_boolean_aliases_share_canonical_wir_only_when_declared() {
for (boolean, number, expected) in [("True", "1", 1.0), ("False", "0", 0.0)] {
let boolean_program = program(&format!("Wait({boolean}, Ignore Condition);"));
let number_program = program(&format!("Wait({number}, Ignore Condition);"));
validate_program(&boolean_program);
validate_program(&number_program);
assert!(roundtrip::equivalent(&boolean_program, &number_program));
assert!(matches!(
&boolean_program
.values
.get(first_action_args(&boolean_program)[0])
.expect("wait duration")
.value,
Value::Number { value, .. } if *value == expected
));
}
}
#[test]
fn one_sided_contextual_aliases_reject_the_other_boolean() {
let accepted = program("Start Forcing Spawn Room(Team 1, False);");
validate_program(&accepted);
assert!(matches!(
&accepted
.values
.get(first_action_args(&accepted)[1])
.expect("spawn room")
.value,
Value::Number { value, .. } if *value == 0.0
));
let rejected = program("Start Forcing Spawn Room(Team 1, True);");
let error = validate::validate_canonical_ids(&rejected, &catalog())
.expect_err("True is not a documented alias for the spawn-room parameter");
assert!(format!("{error:?}").contains("semantic type"));
}
#[test]
fn wait_until_keeps_numeric_exception_without_global_truthiness() {
let numeric = program("Wait Until(1, 2);");
validate_program(&numeric);
assert!(matches!(
&numeric
.values
.get(first_action_args(&numeric)[0])
.expect("continue condition")
.value,
Value::Number { value, .. } if *value == 1.0
));
let vector = program("Wait Until(Vector(1, 2, 3), 2);");
let error = validate::validate_canonical_ids(&vector, &catalog())
.expect_err("Wait Until must not accept arbitrary values as conditions");
assert!(format!("{error:?}").contains("semantic type"));
}
#[test]
fn null_and_empty_string_contexts_normalize_at_their_positions() {
let teleport = program("Teleport(Event Player, 0);");
validate_program(&teleport);
assert!(matches!(
&teleport
.values
.get(first_action_args(&teleport)[1])
.expect("teleport position")
.value,
Value::Null
));
let position = program("Start Forcing Player Position(Event Player, 0, False);");
validate_program(&position);
assert!(matches!(
&position
.values
.get(first_action_args(&position)[1])
.expect("forced position")
.value,
Value::Null
));
let dummy =
program("Create Dummy Bot(Hero(Bastion), Team 2, 0, Vector(0, 0, 0), Vector(0, 0, 0));");
validate_program(&dummy);
let dummy_args = first_action_args(&dummy);
assert!(matches!(
&dummy
.values
.get(dummy_args[3])
.expect("dummy position")
.value,
Value::Null
));
assert!(matches!(
&dummy
.values
.get(dummy_args[4])
.expect("dummy direction")
.value,
Value::Null
));
let name = program("Start Forcing Dummy Bot Name(Event Player, Empty Array);");
validate_program(&name);
assert!(matches!(
&name.values
.get(first_action_args(&name)[1])
.expect("forced name")
.value,
Value::String(value) if value.is_empty()
));
let string_source = r#"variables
{
global:
0: probe
}
rule ("empty string")
{
event { Ongoing - Global; }
actions { Set Global Variable(probe, String Replace(Empty Array, Empty Array, Empty Array)); }
}"#;
let reparsed = roundtrip::round_trip_with_context(
string_source,
&catalog(),
&Locale::new("en-US"),
&catalog(),
);
assert!(
reparsed.equivalent,
"empty string contextual alias must round-trip"
);
}
#[test]
fn nested_numeric_boolean_aliases_normalize_inside_vector_components() {
let program = program("Set Global Variable(probe, Vector(1, True, False));");
validate_program(&program);
let args = first_value_call(&program, "vector");
assert_eq!(args.len(), 3);
for (value_id, expected) in [(args[0], 1.0), (args[1], 1.0), (args[2], 0.0)] {
assert!(matches!(
&program.values.get(value_id).expect("vector component").value,
Value::Number { value, .. } if *value == expected
));
}
}
#[test]
fn comparisons_preserve_polymorphic_operand_types() {
let program = program_with("1 == True;", "Wait(1, Ignore Condition);");
validate_program(&program);
let condition = program
.values
.get(program.rules.iter().next().expect("rule").conditions[0])
.expect("comparison");
let Value::Call { name, args } = &condition.value else {
panic!("expected comparison call, got {:?}", condition.value);
};
assert_eq!(name, "==");
assert!(matches!(
&program.values.get(args[0]).expect("left operand").value,
Value::Number { value, .. } if *value == 1.0
));
assert!(matches!(
&program.values.get(args[1]).expect("right operand").value,
Value::Bool(true)
));
}
#[test]
fn arithmetic_operators_apply_contextual_aliases_but_comparisons_do_not() {
let program = program("Set Global Variable(probe, True + 1);");
validate_program(&program);
let args = first_value_call(&program, "add");
assert!(matches!(
&program.values.get(args[0]).expect("left operand").value,
Value::Number { value, .. } if *value == 1.0
));
}
#[test]
fn indexed_player_variable_sugar_uses_canonical_parameter_positions() {
let source = r#"variables
{
global:
0: probe
player:
0: indexed
}
rule ("indexed contextual semantics")
{
event { Ongoing - Global; }
actions { Set Player Variable At Index(Event Player, indexed, True, Null); }
}"#;
let parsed =
parser::parse(source, &catalog(), &Locale::new("en-US")).expect("indexed source parses");
validate::validate_canonical_ids(&parsed, &catalog()).expect("canonical validation");
let action = parsed
.actions
.get(parsed.rules.iter().next().expect("rule").actions[0])
.expect("indexed action");
let Action::Call { args, .. } = action else {
panic!("expected indexed action call, got {action:?}");
};
assert!(matches!(
&parsed.values.get(args[1]).expect("index").value,
Value::Number { value, .. } if *value == 1.0
));
}
#[test]
fn conditional_operator_applies_branch_contextual_aliases() {
let parsed = program("Set Global Variable(probe, True ? 0 : 1);");
validate_program(&parsed);
let args = first_value_call(&parsed, "ifThenElse");
assert!(matches!(
&parsed.values.get(args[1]).expect("true branch").value,
Value::Null
));
}
#[test]
fn audited_catalog_parameters_cover_boolean_numeric_aliases() {
for (source, expected) in [
("Set Gravity(Event Player, True);", 1.0),
("Set Ultimate Charge(Event Player, False);", 0.0),
("Set Team Score(Team 1, True);", 1.0),
] {
let parsed = program(source);
validate_program(&parsed);
assert!(matches!(
&parsed
.values
.get(first_action_args(&parsed)[1])
.expect("numeric parameter")
.value,
Value::Number { value, .. } if *value == expected
));
}
let parsed = program("Set Global Variable(probe, Is Objective Complete(True));");
validate_program(&parsed);
let args = first_value_call(&parsed, "isObjectiveComplete");
assert!(matches!(
&parsed.values.get(args[0]).expect("objective index").value,
Value::Number { value, .. } if *value == 1.0
));
}
#[test]
fn array_index_sugar_applies_value_in_array_context() {
let parsed = program("Set Global Variable(probe, Array(1)[True]);");
validate_program(&parsed);
let args = first_value_call(&parsed, "valueInArray");
assert!(matches!(
&parsed.values.get(args[1]).expect("array index").value,
Value::Number { value, .. } if *value == 1.0
));
}
#[test]
fn modify_contexts_apply_operation_specific_replacements() {
let source = r#"variables
{
global:
0: g
player:
0: p
}
rule ("modify contextual semantics")
{
event { Ongoing - Global; }
actions {
Modify Global Variable(g, Add, False);
Modify Global Variable(g, Append To Array, 0);
Modify Player Variable(Event Player, p, Subtract, True);
Modify Global Variable At Index(g, 0, Append To Array, 0);
Global.g[False] += 1;
}
}"#;
let parsed =
parser::parse(source, &catalog(), &Locale::new("en-US")).expect("modify source parses");
validate_program(&parsed);
let direct_values: Vec<_> = parsed
.actions
.iter()
.filter_map(|action| match action {
Action::ModifyGlobalVariable { value, .. }
| Action::ModifyPlayerVariable { value, .. } => Some(value),
_ => None,
})
.collect();
assert!(matches!(
&parsed.values.get(*direct_values[0]).expect("add value").value,
Value::Number { value, .. } if *value == 0.0
));
assert!(matches!(
&parsed
.values
.get(*direct_values[1])
.expect("append value")
.value,
Value::Null
));
assert!(matches!(
&parsed.values.get(*direct_values[2]).expect("subtract value").value,
Value::Number { value, .. } if *value == 1.0
));
let indexed: Vec<_> = parsed
.actions
.iter()
.filter_map(|action| match action {
Action::Call { name, args, .. } if name == "modifyGlobalVariableAtIndex" => Some(args),
_ => None,
})
.collect();
assert_eq!(indexed.len(), 2);
assert!(matches!(
&parsed
.values
.get(indexed[0][3])
.expect("indexed modify value")
.value,
Value::Null
));
assert!(matches!(
&parsed
.values
.get(indexed[1][1])
.expect("indexed assignment index")
.value,
Value::Number { value, .. } if *value == 0.0
));
}