From a8539d00305013b53b3c7dfdc69c57d4be37ffc5 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Thu, 24 Sep 2026 21:05:10 -0400 Subject: [PATCH] Add goal scripting, as well as unit testing for the app. --- Cargo.lock | 95 ++++++++++++++++++- Cargo.toml | 7 ++ README.md | 123 +++++++++++++++++++++--- scripted_match.tsrc | 21 +++++ src/lib.rs | 1 + src/main.rs | 64 ++++++++++--- src/tsrc.rs | 126 +++++++++++++++++++++++++ tests/tssr.rs | 224 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 634 insertions(+), 27 deletions(-) create mode 100755 scripted_match.tsrc create mode 100644 src/lib.rs create mode 100644 tests/tssr.rs diff --git a/Cargo.lock b/Cargo.lock index 36cd508..bfc57c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,34 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "getrandom" version = "0.2.17" @@ -19,12 +41,35 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -52,6 +97,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.8" @@ -79,7 +130,20 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -93,11 +157,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tssr" version = "0.1.0" dependencies = [ "rand", + "tempfile", ] [[package]] @@ -112,6 +190,21 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zerocopy" version = "0.8.58" diff --git a/Cargo.toml b/Cargo.toml index da68254..efbffdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" description = "Tiny Soccer Simulator Rust - generates random soccer matches on the spot" +[lib] +name = "tssr" +path = "src/lib.rs" + [[bin]] name = "tssr" path = "src/main.rs" @@ -11,6 +15,9 @@ path = "src/main.rs" [dependencies] rand = "0.8" +[dev-dependencies] +tempfile = "3" + [profile.release] opt-level = 3 lto = true diff --git a/README.md b/README.md index f5702d0..ec549f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tssr — Tiny Soccer Simulator Rust -A blazing-fast CLI that generates completely random, fabricated soccer matches on the spot. Run it, get a match. That's it. +A blazing-fast CLI that generates completely random soccer matches on the spot — or run scripted matches with the `.tsrc` (Tiny Soccer Sim Rust C) language. ## Build @@ -13,8 +13,28 @@ The binary will be at `target/release/tssr`. ## Usage ```bash -./tssr # Print a unicode table match report -./tssr --json # Print the same match as JSON +./tssr # random match (table output) +./tssr --json # random match (JSON output) +./tssr --script match.tsrc # scripted match (table output) +./tssr --script match.tsrc --json # scripted match (JSON output) +./tssr --help # show usage info +``` + +### Shebang + +`.tsrc` scripts can be run directly with a shebang: + +```tsrc +#!/usr/bin/env tssr +string home = "Team A"; +string away = "Team B"; +// ... +match(home, away); +``` + +```bash +chmod +x match.tsrc +./match.tsrc ``` ## Output @@ -28,15 +48,15 @@ The binary will be at `target/release/tssr`. ║ Man Page City 2 - 1 Libre Pool║ ╠════════════════════════════════════════╣ ║ ║ -║ Shots 10 8 ║ -║ On Target 4 3 ║ -║ Possession 55% 45% ║ +║ Shots 10 8 ║ +║ On Target 4 3 ║ +║ Possession 55% 45% ║ ║ ║ ╠════════════════════════════════════════╣ ║ Goals: ║ -║ Man Page City: Marco Smith (23') ║ -║ Man Page City: Leo Brown (67') ║ -║ Libre Pool: Dan Garcia (45') ║ +║ Man Page City: Marco Smith (23') ║ +║ Man Page City: Leo Brown (67') ║ +║ Libre Pool: Dan Garcia (45') ║ ╚════════════════════════════════════════╝ ``` @@ -68,9 +88,66 @@ The binary will be at `target/release/tssr`. } ``` -## Team Names +## `.tsrc` Scripting Language -All team names are PES-style parodies referencing free software and open source: +`.tsrc` (Tiny Soccer Sim Rust C) is a minimal C-like DSL for defining teams, players, and match events programmatically. + +### Language Reference + +```tsrc +// Declare a team variable +string var_name = "Team Name"; + +// Assign a player to a team (by variable or literal) +players["Player Name"] = var_name; +players["Player Name"] = "Team Name"; + +// Script a goal: minute (1-90), team (var or literal), player (literal) +goal(45, var_name, "Player Name"); +goal(45, "Team Name", "Player Name"); + +// Select teams for the match (required) +match(var_home, var_away); +``` + +### Rules + +- Variables must be declared with `string` before use +- `match()` directive is required — references variable names +- `players[]` references team names (values), not variable names — but variables are resolved automatically +- `goal()` arguments: `minute, team, player` — team can be variable or literal +- All semicolons are required +- `//` for single-line comments +- Strict validation: any inconsistency produces an error with a helpful message and exits immediately + +### Validation Errors + +The parser is strict — it will fail with a clear message if: + +- A variable is used before being declared +- A variable is defined twice +- A player is added to a team that isn't in `match()` +- A player is added to the same team twice +- A goal references a player not on that team +- A goal references a team not in the match +- A goal minute is outside 1–90 +- The same player scores at the same minute twice +- A match team has no players assigned +- `match()` references the same team for both home and away + +### Scripted Goals + +When `goal()` is used, the match becomes deterministic: +- Score is derived from the count of scripted goals per team +- Goal scorers and minutes are exactly as specified +- Shots and shots-on-target are floored at the goal count +- Possession remains randomized + +Without `goal()`, everything is fully randomized. + +## Team Names (Random Mode) + +When no script is provided, teams are randomly selected from PES-style parodies referencing free software and open source: - Man Page City, Libre Pool, ArsenAL, Shellsea, TomThumb FC - Real Madread, BarcelON, BayErR, Door Mount, Inter Mission @@ -86,9 +163,29 @@ All team names are PES-style parodies referencing free software and open source: - Possession percentage - Goal scorers with minute of goal +## Example Files + +| File | Description | +|------|-------------| +| `scripted_match.tsrc` | GNU United vs BSD Athletic — 5 scripted goals with mixed variable/literal team references | +| `gnu-bsd-soccer.tsrc` | Variable-reference players, random goals | +| `test_match.tsrc` | Intentionally broken — demonstrates error handling | + +## Testing + +```bash +cargo test +``` + +13 integration tests cover: +- Goal validation (player on team, minute range, duplicates) +- Script validation (undefined variables, same team, missing match, no players) +- Happy paths (valid script, variable references, full scripted match) + ## Performance -- Single dependency (`rand`) +- Single runtime dependency (`rand`) +- `tempfile` is a dev-dependency only (for tests) - Release profile uses LTO, single codegen unit, panic=abort, and symbol stripping - Designed for sub-second cold compilation and instant execution - +- Binary size: ~340KB stripped diff --git a/scripted_match.tsrc b/scripted_match.tsrc new file mode 100755 index 0000000..62e03bd --- /dev/null +++ b/scripted_match.tsrc @@ -0,0 +1,21 @@ +#!/home/mrkmntal/tssr/target/release/tssr + +// GNU United vs BSD Athletic — fully scripted match + +string home = "GNU United"; +string away = "BSD Athletic"; + +players["Richard Stallman"] = home; +players["Brian Fox"] = home; +players["Linus Torvalds"] = home; +players["Theo de Raadt"] = away; +players["Jordan Hubbard"] = away; +players["Ken Thompson"] = away; + +goal(12, home, "Richard Stallman"); +goal(34, away, "Theo de Raadt"); +goal(56, home, "Brian Fox"); +goal(78, "BSD Athletic", "Ken Thompson"); +goal(89, home, "Linus Torvalds"); + +match(home, away); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8a3af11 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod tsrc; diff --git a/src/main.rs b/src/main.rs index 8d8424a..47d8a46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -mod tsrc; - use rand::seq::SliceRandom; use rand::Rng; use std::env; use std::path::Path; use std::process; +use tssr::tsrc; + const TEAM_NAMES: &[&str] = &[ "Man Page City", "Libre Pool", @@ -112,20 +112,58 @@ fn gen_match( } }; - let home_score = rng.gen_range(0..=5u8); - let away_score = rng.gen_range(0..=5u8); + let (home_score, away_score, home_scorers, away_scorers, scripted) = match script { + Some(s) if s.goals.is_some() => { + let goals = s.goals.as_ref().unwrap(); + let mut home_g: Vec<(String, u8)> = Vec::new(); + let mut away_g: Vec<(String, u8)> = Vec::new(); + for g in goals { + if g.team == home_name { + home_g.push((g.player.clone(), g.minute)); + } else { + away_g.push((g.player.clone(), g.minute)); + } + } + let hs = home_g.len() as u8; + let as_ = away_g.len() as u8; + (hs, as_, home_g, away_g, true) + } + _ => { + let hs = rng.gen_range(0..=5u8); + let as_ = rng.gen_range(0..=5u8); + let home_roster = script.and_then(|s| s.roster.get(&home_name)).map(|v| v.as_slice()).unwrap_or(&[]); + let away_roster = script.and_then(|s| s.roster.get(&away_name)).map(|v| v.as_slice()).unwrap_or(&[]); + ( + hs, + as_, + pick_scorers(home_roster, hs as usize, rng), + pick_scorers(away_roster, as_ as usize, rng), + false, + ) + } + }; + let possession = rng.gen_range(38..=62u8); - let home_shots = rng.gen_range(4..=18u8); - let away_shots = rng.gen_range(4..=18u8); - let home_on_target = rng.gen_range(1..=home_shots.min(10)); - let away_on_target = rng.gen_range(1..=away_shots.min(10)); + let (home_shots, home_on_target) = if scripted { + let shots = home_score.max(rng.gen_range(4..=18u8)); + let on_target = home_score.max(rng.gen_range(1..=shots.min(10))); + (shots, on_target) + } else { + let shots = rng.gen_range(4..=18u8); + let on_target = rng.gen_range(1..=shots.min(10)); + (shots, on_target) + }; - let home_roster = script.and_then(|s| s.roster.get(&home_name)).map(|v| v.as_slice()).unwrap_or(&[]); - let away_roster = script.and_then(|s| s.roster.get(&away_name)).map(|v| v.as_slice()).unwrap_or(&[]); - - let home_scorers = pick_scorers(home_roster, home_score as usize, rng); - let away_scorers = pick_scorers(away_roster, away_score as usize, rng); + let (away_shots, away_on_target) = if scripted { + let shots = away_score.max(rng.gen_range(4..=18u8)); + let on_target = away_score.max(rng.gen_range(1..=shots.min(10))); + (shots, on_target) + } else { + let shots = rng.gen_range(4..=18u8); + let on_target = rng.gen_range(1..=shots.min(10)); + (shots, on_target) + }; Match { home: Team { diff --git a/src/tsrc.rs b/src/tsrc.rs index 872f26c..641ada5 100644 --- a/src/tsrc.rs +++ b/src/tsrc.rs @@ -1,11 +1,20 @@ use std::collections::HashMap; use std::fs; +#[derive(Debug)] +pub struct ScriptedGoal { + pub minute: u8, + pub team: String, + pub player: String, +} + +#[derive(Debug)] pub struct TsrcScript { pub teams: HashMap, pub roster: HashMap>, pub home_var: String, pub away_var: String, + pub goals: Option>, } pub fn parse_tsrc(path: &str) -> Result { @@ -20,6 +29,8 @@ pub fn parse_tsrc(path: &str) -> Result { let mut var_lines: HashMap = HashMap::new(); let mut player_entries: HashMap<(String, String), usize> = HashMap::new(); + let mut goals: Vec = Vec::new(); + let mut goal_entries: HashMap<(u8, String), usize> = HashMap::new(); for (line_num, raw_line) in content.lines().enumerate() { let n = line_num + 1; @@ -33,6 +44,9 @@ pub fn parse_tsrc(path: &str) -> Result { parse_string_decl(rest, n, &mut teams, &mut var_lines)?; } else if let Some(rest) = line.strip_prefix("players") { parse_player_insert(rest, n, &mut roster, &mut player_entries, &teams)?; + } else if let Some(rest) = line.strip_prefix("goal") { + let goal = parse_goal(rest, n, &teams, &roster, &mut goal_entries)?; + goals.push(goal); } else if let Some(rest) = line.strip_prefix("match") { let rest = rest.trim_end_matches(';'); (home_var, away_var) = parse_match(rest, n, &teams)?; @@ -48,11 +62,18 @@ pub fn parse_tsrc(path: &str) -> Result { validate_script(&teams, &roster, &home_var, &away_var)?; + if !goals.is_empty() { + let home_team = teams.get(&home_var).unwrap(); + let away_team = teams.get(&away_var).unwrap(); + validate_goals(&goals, home_team, away_team, &roster)?; + } + Ok(TsrcScript { teams, roster, home_var, away_var, + goals: if goals.is_empty() { None } else { Some(goals) }, }) } @@ -213,6 +234,111 @@ fn parse_match(s: &str, n: usize, teams: &HashMap) -> Result<(St Ok((var1.to_string(), var2.to_string())) } +fn parse_goal( + s: &str, + n: usize, + teams: &HashMap, + roster: &HashMap>, + goal_entries: &mut HashMap<(u8, String), usize>, +) -> Result { + let s = s.trim().trim_end_matches(';'); + + if !s.starts_with('(') || !s.ends_with(')') { + return Err(format!("tsrc error: goal() requires parentheses on line {}", n)); + } + + let inner = &s[1..s.len() - 1]; + let parts: Vec<&str> = inner.splitn(3, ',').collect(); + if parts.len() != 3 { + return Err(format!( + "tsrc error: goal() requires 3 arguments (minute, team, player) on line {}", + n + )); + } + + let minute: u8 = parts[0].trim().parse().map_err(|_| { + format!("tsrc error: goal() minute must be a number on line {}", n) + })?; + if minute < 1 || minute > 90 { + return Err(format!( + "tsrc error: goal() minute must be between 1 and 90 on line {}", + n + )); + } + + let team_part = parts[1].trim(); + let team_name = if team_part.starts_with('"') { + parse_string_literal(team_part, n)? + } else { + if !is_valid_ident(team_part) { + return Err(format!( + "tsrc error: goal() team must be a string literal or variable on line {}", + n + )); + } + match teams.get(team_part) { + Some(v) => v.clone(), + None => { + return Err(format!( + "tsrc error: undefined variable '{}' in goal() on line {} (declare it with `string {} = \"...\"` first)", + team_part, n, team_part + )); + } + } + }; + + let player_name = parse_string_literal(parts[2].trim(), n)?; + + let key = (minute, player_name.clone()); + if let Some(&first_line) = goal_entries.get(&key) { + return Err(format!( + "tsrc error: duplicate goal on line {} — {} already scored at minute {} (line {})", + n, player_name, minute, first_line + )); + } + goal_entries.insert(key, n); + + if let Some(players) = roster.get(&team_name) { + if !players.contains(&player_name) { + return Err(format!( + "tsrc error: goal() on line {} — player \"{}\" is not on team \"{}\"", + n, player_name, team_name + )); + } + } + + Ok(ScriptedGoal { + minute, + team: team_name, + player: player_name, + }) +} + +fn validate_goals( + goals: &[ScriptedGoal], + home_team: &str, + away_team: &str, + roster: &HashMap>, +) -> Result<(), String> { + for goal in goals { + if goal.team != home_team && goal.team != away_team { + return Err(format!( + "tsrc error: goal() for \"{}\" references team \"{}\" which is not a match team (home=\"{}\", away=\"{}\")", + goal.player, goal.team, home_team, away_team + )); + } + if let Some(players) = roster.get(&goal.team) { + if !players.contains(&goal.player) { + return Err(format!( + "tsrc error: goal() — player \"{}\" is not on team \"{}\"", + goal.player, goal.team + )); + } + } + } + Ok(()) +} + fn parse_string_literal(s: &str, n: usize) -> Result { let s = s.trim(); if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') { diff --git a/tests/tssr.rs b/tests/tssr.rs new file mode 100644 index 0000000..6318f4c --- /dev/null +++ b/tests/tssr.rs @@ -0,0 +1,224 @@ +use std::io::Write; +use tempfile::NamedTempFile; + +fn write_tsrc(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +fn parse(content: &str) -> Result { + let f = write_tsrc(content); + tssr::tsrc::parse_tsrc(f.path().to_str().unwrap()) +} + +#[test] +fn goal_player_not_on_team() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + goal(10, h, "Wrong Player"); + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("is not on team"), "got: {}", err); + assert!(err.contains("Wrong Player"), "got: {}", err); +} + +#[test] +fn goal_duplicate_same_minute_player() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + goal(10, h, "P1"); + goal(10, a, "P1"); + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("duplicate goal"), "got: {}", err); +} + +#[test] +fn goal_minute_out_of_range() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + goal(95, h, "P1"); + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("between 1 and 90"), "got: {}", err); +} + +#[test] +fn goal_undefined_team() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + goal(10, "Ghost Team", "P1"); + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("not a match team"), "got: {}", err); +} + +#[test] +fn goal_undefined_variable() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + goal(10, ghost, "P1"); + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("undefined variable"), "got: {}", err); + assert!(err.contains("ghost"), "got: {}", err); +} + +#[test] +fn script_valid_full_with_goals() { + let r = parse(r#" + string home = "GNU United"; + string away = "BSD Athletic"; + players["Richard Stallman"] = home; + players["Brian Fox"] = home; + players["Linus Torvalds"] = home; + players["Theo de Raadt"] = away; + players["Jordan Hubbard"] = away; + players["Ken Thompson"] = away; + goal(12, home, "Richard Stallman"); + goal(34, away, "Theo de Raadt"); + goal(56, home, "Brian Fox"); + goal(78, "BSD Athletic", "Ken Thompson"); + goal(89, home, "Linus Torvalds"); + match(home, away); + "#); + assert!(r.is_ok(), "got: {:?}", r.err()); + let s = r.unwrap(); + let goals = s.goals.unwrap(); + assert_eq!(goals.len(), 5); + let home_goals: Vec<_> = goals.iter().filter(|g| g.team == "GNU United").collect(); + let away_goals: Vec<_> = goals.iter().filter(|g| g.team == "BSD Athletic").collect(); + assert_eq!(home_goals.len(), 3); + assert_eq!(away_goals.len(), 2); + assert_eq!(home_goals[0].player, "Richard Stallman"); + assert_eq!(home_goals[0].minute, 12); +} + +#[test] +fn script_variable_ref_in_players() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + match(h, a); + "#); + assert!(r.is_ok(), "got: {:?}", r.err()); + let s = r.unwrap(); + assert!(s.roster.contains_key("Team A")); + assert!(s.roster.contains_key("Team B")); +} + +#[test] +fn script_mismatch_roster_team_name() { + let r = parse(r#" + string h = "Linux City"; + string a = "Libre Pool"; + players["P1"] = "Man Page City"; + players["P2"] = a; + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("not a match team"), "got: {}", err); + assert!(err.contains("Man Page City"), "got: {}", err); +} + +#[test] +fn script_duplicate_player() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P1"] = h; + players["P2"] = a; + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("duplicate player"), "got: {}", err); + assert!(err.contains("P1"), "got: {}", err); +} + +#[test] +fn script_duplicate_variable() { + let r = parse(r#" + string home = "Team A"; + string home = "Team B"; + players["P1"] = home; + players["P2"] = home; + match(home, home); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("redefined"), "got: {}", err); + assert!(err.contains("home"), "got: {}", err); +} + +#[test] +fn script_no_match_directive() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("no match() directive found"), "got: {}", err); +} + +#[test] +fn script_same_team_in_match() { + let r = parse(r#" + string h = "Team A"; + string a = "Team A"; + players["P1"] = h; + players["P2"] = a; + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("same team"), "got: {}", err); + assert!(err.contains("Team A"), "got: {}", err); +} + +#[test] +fn script_no_players_for_team() { + let r = parse(r#" + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("no players assigned"), "got: {}", err); +}