Add goal scripting, as well as unit testing for the app.
This commit is contained in:
parent
5b41327649
commit
a8539d0030
8 changed files with 634 additions and 27 deletions
95
Cargo.lock
generated
95
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
README.md
123
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
|
||||
|
|
|
|||
21
scripted_match.tsrc
Executable file
21
scripted_match.tsrc
Executable file
|
|
@ -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);
|
||||
1
src/lib.rs
Normal file
1
src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod tsrc;
|
||||
64
src/main.rs
64
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 {
|
||||
|
|
|
|||
126
src/tsrc.rs
126
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<String, String>,
|
||||
pub roster: HashMap<String, Vec<String>>,
|
||||
pub home_var: String,
|
||||
pub away_var: String,
|
||||
pub goals: Option<Vec<ScriptedGoal>>,
|
||||
}
|
||||
|
||||
pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
|
||||
|
|
@ -20,6 +29,8 @@ pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
|
|||
|
||||
let mut var_lines: HashMap<String, usize> = HashMap::new();
|
||||
let mut player_entries: HashMap<(String, String), usize> = HashMap::new();
|
||||
let mut goals: Vec<ScriptedGoal> = 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<TsrcScript, String> {
|
|||
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<TsrcScript, String> {
|
|||
|
||||
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<String, String>) -> Result<(St
|
|||
Ok((var1.to_string(), var2.to_string()))
|
||||
}
|
||||
|
||||
fn parse_goal(
|
||||
s: &str,
|
||||
n: usize,
|
||||
teams: &HashMap<String, String>,
|
||||
roster: &HashMap<String, Vec<String>>,
|
||||
goal_entries: &mut HashMap<(u8, String), usize>,
|
||||
) -> Result<ScriptedGoal, String> {
|
||||
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<String, Vec<String>>,
|
||||
) -> 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<String, String> {
|
||||
let s = s.trim();
|
||||
if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') {
|
||||
|
|
|
|||
224
tests/tssr.rs
Normal file
224
tests/tssr.rs
Normal file
|
|
@ -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<tssr::tsrc::TsrcScript, String> {
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue