workshop-rs 0.6.0

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
use std::collections::HashSet;

use workshop_rs::actions::{ElementCountError, ElementNodeKind};
use workshop_rs::catalog::{Catalog, Locale};
use workshop_rs::convert::{self, ConvertOptions};
use workshop_rs::parser;
use workshop_rs::settings::{Settings, SettingsNode};
use workshop_rs::{Action, Condition, Event, Program, Rule, Value, Variable};

fn catalog() -> Catalog {
    Catalog::builtin().unwrap()
}

fn program_with_value(value: Value) -> Program {
    let mut program = Program::default();
    program.global_variable(Variable::new("result")).rule(
        Rule::new("count", Event::Global).action(Action::SetGlobalVariable {
            variable: "result".to_string(),
            value,
        }),
    );
    program
}

#[test]
fn ordinary_rule_and_action_have_structured_per_rule_counts() {
    let program = parser::parse(
        include_str!("fixtures/corpus/basic-rule.ws"),
        &catalog(),
        &Locale::new("en-US"),
    )
    .unwrap();
    let report = program.element_count(&catalog()).unwrap();

    assert_eq!(report.total, 2, "one rule plus one action");
    assert_eq!(report.rule_counts().collect::<Vec<_>>(), vec![("setup", 2)]);
    assert_eq!(report.rules[0].base_count, 1);
    assert_eq!(report.rules[0].children.len(), 1);
    assert_eq!(report.rules[0].children[0].count, 1);
}

#[test]
fn conditions_and_top_level_arguments_follow_the_documented_adjustments() {
    let program = parser::parse(
        "rule (\"condition\") { event { Ongoing - Global; } conditions { Is Game In Progress; } actions { Disable Inspector Recording; } }",
        &catalog(),
        &Locale::new("en-US"),
    )
    .unwrap();
    let report = program.element_count(&catalog()).unwrap();

    assert_eq!(report.total, 3, "rule + condition + action");
    let condition = &report.rules[0].children[0];
    assert_eq!(condition.kind, ElementNodeKind::Condition);
    assert_eq!(condition.count, 1);
    assert_eq!(condition.children[0].base_count, 1);
    assert_eq!(condition.children[0].adjustment, -1);
    assert_eq!(condition.children[0].count, 0);
}

#[test]
fn arrays_localized_strings_and_hero_pairs_are_visible_in_the_tree() {
    let array = Value::Array(vec![Value::number(1.0), Value::number(2.0)]);
    let array_program = program_with_value(array);
    let array_report = array_program.element_count(&catalog()).unwrap();
    assert_eq!(
        array_report.total, 5,
        "rule + action + (array 2 + literals 2 - top-level 1)"
    );

    let hero_program = program_with_value(Value::Array(vec![
        Value::Enum {
            value_type: "Hero".to_string(),
            value: "ANA".to_string(),
        },
        Value::Enum {
            value_type: "Hero".to_string(),
            value: "DVA".to_string(),
        },
    ]));
    let hero_report = hero_program.element_count(&catalog()).unwrap();
    assert_eq!(hero_report.total, 6, "hero pair surcharge adds one element");
    assert_eq!(hero_report.rules[0].children[0].adjustment, 1);

    let localized_report = program_with_value(Value::LocalizedString("hello".to_string()))
        .element_count(&catalog())
        .unwrap();
    assert_eq!(
        localized_report.total, 3,
        "localized string costs two before top-level reduction"
    );
}

#[test]
fn custom_settings_and_disabled_rules_do_not_change_cost() {
    let mut program = parser::parse(
        "disabled rule (\"disabled\") { event { Ongoing - Global; } actions { Disable Inspector Recording; } }",
        &catalog(),
        &Locale::new("en-US"),
    )
    .unwrap();
    program.settings = Some(Settings {
        span: None,
        children: vec![SettingsNode::Raw {
            name: "project-defined".to_string(),
            value: "value".to_string(),
            span: None,
        }],
    });
    assert_eq!(program.element_count(&catalog()).unwrap().total, 2);
}

#[test]
fn locale_conversion_preserves_the_canonical_count() {
    let catalog = catalog();
    let source = include_str!("fixtures/corpus/basic-rule.ws");
    let english = parser::parse(source, &catalog, &Locale::new("en-US")).unwrap();
    let converted = convert::convert(
        source,
        &catalog,
        &Locale::new("en-US"),
        &Locale::new("zh-CN"),
        &ConvertOptions::default(),
    )
    .unwrap();
    let chinese = parser::parse(&converted.text, &catalog, &Locale::new("zh-CN")).unwrap();

    assert_eq!(
        english.element_count(&catalog).unwrap().total,
        chinese.element_count(&catalog).unwrap().total
    );
}

#[test]
fn representative_corpus_program_produces_a_report() {
    let catalog = catalog();
    let program = parser::parse(
        include_str!("fixtures/corpus/expressions-values.ws"),
        &catalog,
        &Locale::new("en-US"),
    )
    .unwrap();
    let report = program.element_count(&catalog).unwrap();

    assert_eq!(report.rules.len(), 2);
    assert!(report.total > 2);
}

#[test]
fn public_report_preserves_rule_condition_and_action_order_with_spans() {
    let catalog = catalog();
    let source = "rule (\"ordered\") {\n    event { Ongoing - Global; }\n    conditions {\n        Is Game In Progress;\n        Is Game In Progress;\n    }\n    actions {\n        Disable Inspector Recording;\n        Disable Inspector Recording;\n    }\n}\n";
    let program = parser::parse(source, &catalog, &Locale::new("en-US")).unwrap();
    let report = program.element_count(&catalog).unwrap();
    let rule = &report.rules[0];

    assert_eq!(rule.kind, ElementNodeKind::Rule);
    assert_eq!(rule.span, program.rule_span(0));
    let mut ids = HashSet::new();
    assert_unique_node_ids(rule, &mut ids);

    let conditions = rule
        .children
        .iter()
        .filter(|node| node.kind == ElementNodeKind::Condition)
        .collect::<Vec<_>>();
    assert_eq!(conditions.len(), 2);
    for (index, condition) in conditions.iter().enumerate() {
        assert_eq!(condition.span, program.condition_span(0, index));
    }

    let actions = rule
        .children
        .iter()
        .filter(|node| node.kind == ElementNodeKind::Action)
        .collect::<Vec<_>>();
    assert_eq!(actions.len(), 2);
    for (index, action) in actions.iter().enumerate() {
        assert_eq!(action.span, program.action_span(0, index));
    }
}

#[test]
fn public_report_keeps_nested_values_and_actions_inspectable() {
    let mut program = Program::new();
    program.rule(
        Rule::new("nested", Event::Global)
            .action(Action::If {
                condition: Value::Bool(true),
            })
            .action(Action::call("wait", [Value::number(1.0)]))
            .action(Action::End),
    );

    let report = program.element_count(&catalog()).unwrap();
    let conditional = &report.rules[0].children[0];
    assert_eq!(conditional.kind, ElementNodeKind::Action);
    assert_eq!(conditional.name, "if");
    assert_eq!(conditional.children[0].kind, ElementNodeKind::Value);
    assert_eq!(conditional.children[1].kind, ElementNodeKind::Action);
    assert_eq!(conditional.children[1].name, "wait");
    assert_eq!(
        conditional.children[1].children[0].kind,
        ElementNodeKind::Value
    );
}

#[test]
fn public_api_rejects_unsupported_and_invalid_programs_explicitly() {
    let mut unknown_action = Program::new();
    unknown_action.rule(
        Rule::new("unknown", Event::Global)
            .action(Action::call("notCanonicalAction", std::iter::empty())),
    );
    let unknown_error = unknown_action
        .element_count(&catalog())
        .expect_err("unknown action must not produce an exact count");
    assert!(matches!(
        unknown_error,
        ElementCountError::InvalidProgram { message } if message.contains("unknown action")
    ));

    let mut invalid = Program::new();
    invalid.rule(
        Rule::new("invalid", Event::Global).condition(Condition::disabled(Value::Bool(true))),
    );
    let invalid_error = invalid
        .element_count(&catalog())
        .expect_err("unsupported condition must not produce an exact count");
    assert!(matches!(
        invalid_error,
        ElementCountError::InvalidProgram { message } if message.contains("disabled condition")
    ));
}

fn assert_unique_node_ids(node: &workshop_rs::actions::ElementCountNode, ids: &mut HashSet<usize>) {
    assert!(
        ids.insert(node.id),
        "duplicate report-local node id {}",
        node.id
    );
    for child in &node.children {
        assert_unique_node_ids(child, ids);
    }
}