diff --git a/.github/codecov.yml b/.github/codecov.yml index 66eaee7..25ace92 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -5,6 +5,10 @@ coverage: default: target: 80% # the required coverage value threshold: 1% # the leniency in hitting the target + patch: + default: + target: 80% + threshold: 1% # Test files aren't important for coverage diff --git a/Cargo.lock b/Cargo.lock index 96754f2..7932ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,54 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "anstream" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys", +] + [[package]] name = "autocfg" version = "1.2.0" @@ -29,12 +77,64 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "bytecount" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "errno" version = "0.3.8" @@ -68,6 +168,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "lazy_static" version = "1.4.0" @@ -102,12 +214,56 @@ dependencies = [ "libm", ] +[[package]] +name = "papergrid" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "ppv-lite86" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "proptest" version = "1.4.0" @@ -134,6 +290,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rand" version = "0.8.5" @@ -204,6 +369,67 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "table_to_html" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df745cc3774c5ff6c21a215fb6b7446a138b85ad0e75bc8000c2a36813061a9" +dependencies = [ + "tabled", +] + +[[package]] +name = "tabled" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e" +dependencies = [ + "papergrid", + "tabled_derive", + "unicode-width", +] + +[[package]] +name = "tabled_derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tempfile" version = "3.10.1" @@ -222,6 +448,30 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "wait-timeout" version = "0.2.0" @@ -307,5 +557,8 @@ checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" name = "wolf_quake" version = "0.1.0" dependencies = [ + "clap", "proptest", + "table_to_html", + "tabled", ] diff --git a/Cargo.toml b/Cargo.toml index 957006a..bca1cb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,12 @@ license = "MIT OR Apache-2.0" keywords = ["parser", "cli", "quake"] categories = ["command-line-utilities", "parsing"] edition = "2021" -rust-version = "1.60.0" +rust-version = "1.74.0" [dependencies] +clap = { version = "4.5.4", features = ["derive"] } +tabled = "0.15.0" +table_to_html = "0.4.0" [dev-dependencies] proptest = "1.4.0" @@ -78,4 +81,6 @@ cargo = { level = "warn", priority = 0 } # allow list needless_return = { level = "allow", priority = 6 } +multiple_crate_versions = { level = "allow", priority = 6 } # either clap or tabled are calling some outdated dependencies +module_name_repetitions = { level = "allow", priority = 6 } #question_mark_used = "allow" diff --git a/README.md b/README.md index 119b01f..3a173cf 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,31 @@ - + ## 🏞️ Overview Wolf Quake is a parser for Quake 3 Arena log files. +You can find a log file: [file](https://gist.github.com/cloudwalk-tests/be1b636e58abff14088c8b5309f575d8) -You can find the log file: [file](https://gist.github.com/cloudwalk-tests/be1b636e58abff14088c8b5309f575d8) +### Usage +```shell +Quake 3 log parser + +Usage: wolf_quake [OPTIONS] -:warning: Wolf Quake is a wip +Arguments: + The path to the log file, required + +Options: + -r, --report-type The type of report to generate - Report with player ranking and mean of death ranking - Report with player ranking - Report with mean of death ranking Default: all [default: all] [possible values: all, player-rank, mean-death] + -f, --report-format The format of the report to generate - Text table report in console - Html table report Default: text [default: text] [possible values: html, text] + -o, --output-file The output file to write the report If not provided, the report will be printed to the console + -h, --help Print help (see more with '--help') + -V, --version Print version +``` ## :scroll: Documentation @@ -44,9 +58,11 @@ It also heavily applies clippy lints to ensure the code is idiomatic and follows Current status: - [x] Enviroment setup: CI, local and github -- [-] Happy path log parsing and tests -- [ ] CLI -- [ ] Bug handling in original log file +- [x] Happy path log parsing and tests +- [x] Bug handling in original log file +- [x] CLI +- [ ] Integration tests +- [ ] Documentation For testing, Wolf Quake uses the [proptest](https://docs.rs/proptest/latest/proptest/) crate, which is kind of a more purpose-oriented fuzzy testing tool. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..097fc10 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,250 @@ +use clap::{Parser, ValueEnum}; +use std::path::PathBuf; + +#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)] +/// Type of report to generate: +/// - Report with player ranking and mean of death ranking +/// - Report with player ranking +/// - Report with mean of death ranking +pub enum ReportType { + /// Player kill score ranking + mean of death ranking + All, + /// Only player kill score ranking + PlayerRank, + /// Only mean of death ranking + MeanDeath, +} + +#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)] +/// Format of report to generate: +/// - Text table report in console +/// - Html table report +pub enum ReportFormat { + /// HTML table report + Html, + /// Text console report with tabled crate + Text, +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +/// The CLI struct +/// Defines the declarative CLI interface using the `clap` crate +pub struct Cli { + /// The path to the log file, required + pub log_file: PathBuf, + + #[arg(short, long, value_enum, default_value = "all")] + /// The type of report to generate + /// - Report with player ranking and mean of death ranking + /// - Report with player ranking + /// - Report with mean of death ranking + /// Default: all + pub report_type: ReportType, + + #[arg(short = 'f', long, value_enum, default_value = "text")] + /// The format of the report to generate + /// - Text table report in console + /// - Html table report + /// Default: text + pub report_format: ReportFormat, + + #[arg(short, long, value_name = "FILE")] + /// The output file to write the report + /// If not provided, the report will be printed to the console + pub output_file: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn report_type() -> impl Strategy { + prop_oneof![ + Just(ReportType::All), + Just(ReportType::PlayerRank), + Just(ReportType::MeanDeath), + ] + } + + fn report_format() -> impl Strategy { + prop_oneof![Just(ReportFormat::Html), Just(ReportFormat::Text)] + } + + proptest! { + #[test] + fn verify_cmd_default( + log_file in "\\w+" + ) { + let cmd = Cli::parse_from(&["test", &log_file]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, ReportType::All); + assert_eq!(cmd.report_format, ReportFormat::Text); + assert_eq!(cmd.output_file, None); + } + } + + proptest! { + #[test] + fn verify_cmd_default_flag_like_log_file( + log_file in "--\\PC*" + ) { + let cmd = Cli::try_parse_from(&["test", &log_file]); + assert!(cmd.is_err()); + } + } + + #[test] + fn verify_cmd_default_empty_log_file() { + let cmd = Cli::try_parse_from(&["test", ""]); + assert!(cmd.is_err()); + } + + proptest! { + #[test] + fn verify_cmd_with_report_type( + log_file in "\\w+", + report_type in report_type(), + ) { + let arg_text = match report_type { + ReportType::All => { + "all" + } + ReportType::PlayerRank => { + "player-rank" + } + ReportType::MeanDeath => { + "mean-death" + } + }; + let cmd = Cli::parse_from(&["test", &log_file, "--report-type", arg_text]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, report_type); + assert_eq!(cmd.report_format, ReportFormat::Text); + assert_eq!(cmd.output_file, None); + + let cmd = Cli::parse_from(&["test", &log_file, "-r", arg_text]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, report_type); + assert_eq!(cmd.report_format, ReportFormat::Text); + assert_eq!(cmd.output_file, None); + } + } + + proptest! { + #[test] + fn verify_cmd_with_report_format( + log_file in "\\w+", + report_format in report_format(), + ) { + let arg_text = match report_format { + ReportFormat::Html => { + "html" + } + ReportFormat::Text => { + "text" + } + }; + let cmd = Cli::parse_from(&["test", &log_file, "--report-format", arg_text]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, ReportType::All); + assert_eq!(cmd.report_format, report_format); + assert_eq!(cmd.output_file, None); + + let cmd = Cli::parse_from(&["test", &log_file, "-f", arg_text]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, ReportType::All); + assert_eq!(cmd.report_format, report_format); + assert_eq!(cmd.output_file, None); + } + } + + proptest! { + #[test] + fn verify_cmd_with_output_file( + log_file in "\\w+", + output_file in "\\w+" + ) { + let cmd = Cli::parse_from(&["test", &log_file, "--output-file", &output_file]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, ReportType::All); + assert_eq!(cmd.report_format, ReportFormat::Text); + assert_eq!(cmd.output_file, Some(PathBuf::from(&output_file))); + + let cmd = Cli::parse_from(&["test", &log_file, "-o", &output_file]); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, ReportType::All); + assert_eq!(cmd.report_format, ReportFormat::Text); + assert_eq!(cmd.output_file, Some(PathBuf::from(&output_file))); + } + } + + proptest! { + #[test] + fn verify_cmd_with_output_file_flag_like_log_file( + log_file in "\\w+", + output_file in "--\\PC*" + ) { + let cmd = Cli::try_parse_from(&["test", &log_file, "--output-file", &output_file]); + assert!(cmd.is_err()); + } + } + + proptest! { + #[test] + fn verify_cmd_with_empty_output_file( + log_file in "\\w+", + ) { + let cmd = Cli::try_parse_from(&["test", &log_file, "--output-file", ""]); + assert!(cmd.is_err()); + } + } + + proptest! { + #[test] + fn verify_cmd_full( + log_file in "\\w+", + report_type in report_type(), + report_format in report_format(), + output_file in "\\w+" + ) { + let type_text = match report_type { + ReportType::All => { + "all" + } + ReportType::PlayerRank => { + "player-rank" + } + ReportType::MeanDeath => { + "mean-death" + } + }; + + let format_text = match report_format { + ReportFormat::Html => { + "html" + } + ReportFormat::Text => { + "text" + } + }; + + let cmd = Cli::parse_from( + &["test", &log_file, "--report-type", type_text, "--report-format", format_text, "--output-file", &output_file] + ); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, report_type); + assert_eq!(cmd.report_format, report_format); + assert_eq!(cmd.output_file, Some(PathBuf::from(&output_file))); + + let cmd = Cli::parse_from( + &["test", &log_file, "-r", type_text, "-f", format_text, "-o", &output_file] + ); + assert_eq!(cmd.log_file, PathBuf::from(&log_file)); + assert_eq!(cmd.report_type, report_type); + assert_eq!(cmd.report_format, report_format); + assert_eq!(cmd.output_file, Some(PathBuf::from(&output_file))); + } + } +} diff --git a/src/main.rs b/src/main.rs index 22f8779..eaee609 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,20 +2,32 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] +/// Module responsible for the CLI +/// Both the CLI configuration and argument parsing +mod cli; /// Module responsible for the data representation from the log /// like the means of death and the players data /// the `PlayerData` struct and the `MeanDeath` enum mod quake3_data; /// Module responsible for the parsing functionalities mod quake3_parser; +/// Module responsible for the report generation +/// both the text and html reports +mod report; +use cli::Cli; use quake3_parser::parser::{scan_file, Game}; -use std::{fs, path::Path}; +use report::get_report; + +use clap::Parser; +use std::fs; #[cfg_attr(coverage_nightly, coverage(off))] /// main function fn main() { - let filepath = Path::new("./static/qgames.log"); + let cli = Cli::parse(); + + let filepath = &cli.log_file; let log_str = fs::read_to_string(filepath).expect("Error reading file"); let games: Vec = match scan_file(&log_str) { @@ -26,10 +38,15 @@ fn main() { } }; - for game in games { - let total_kills = game.total_kills; - println!("Total kills: {total_kills:?}"); - let players_data = game.players_data; - println!("Players data: {players_data:?}"); + let result = get_report(&games, &cli.report_type, &cli.report_format); + match result { + Ok(term_table) => match &cli.output_file { + Some(output_file) => { + let output_str = term_table.to_string(); + fs::write(output_file, output_str).expect("Error writing file"); + } + None => println!("{term_table}"), + }, + Err(err) => eprintln!("Could not generate report: {err}"), } } diff --git a/src/quake3_data.rs b/src/quake3_data.rs index 891554b..c994fdd 100644 --- a/src/quake3_data.rs +++ b/src/quake3_data.rs @@ -13,7 +13,7 @@ pub struct PlayerData { /// The player name pub name: String, /// The player score - pub kills: u32, + pub kills: i32, } impl PartialOrd for PlayerData { @@ -23,12 +23,14 @@ impl PartialOrd for PlayerData { } impl Ord for PlayerData { + /// Sorts by the number of kills in descending order + /// The player with the most kills is first fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.kills.cmp(&other.kills) + other.kills.cmp(&self.kills) } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[allow(clippy::missing_docs_in_private_items)] /// The means of death enum /// Contains the possible means of death in Quake 3 @@ -142,31 +144,60 @@ mod tests { use proptest::prelude::*; prop_compose! { - fn arb_player_data()(name in "[a-z]*", kills in any::()) -> PlayerData { + fn arb_player_data_pos()(name in "[a-z]*", kills in 0..i32::MAX) -> PlayerData { PlayerData { name, kills } } } prop_compose! { - fn arb_players() - (player_data in arb_player_data()) + fn arb_players_pos() + (player_data in arb_player_data_pos()) (name in "[a-z]*", kills in 0..player_data.kills, player_data in Just(player_data)) -> (PlayerData, PlayerData) { (player_data, PlayerData { name, kills }) } } + prop_compose! { + fn arb_player_data_neg()(name in "[a-z]*", kills in i32::MIN..0) -> PlayerData { + PlayerData { name, kills } + } + } + + prop_compose! { + fn arb_players_neg() + (player_data in arb_player_data_neg()) + (name in "[a-z]*", kills in player_data.kills..0, player_data in Just(player_data)) + -> (PlayerData, PlayerData) { + (player_data, PlayerData { name, kills }) + } + } + + proptest! { + #[test] + fn test_player_data_ordering_pos((a_player, other_player) in arb_players_pos()) { + prop_assert!(a_player < other_player); + } + } + proptest! { #[test] - fn test_player_data_ordering((a_player, other_player) in arb_players()) { + fn test_player_data_ordering_neg((a_player, other_player) in arb_players_neg()) { prop_assert!(a_player > other_player); } } proptest! { #[test] - fn test_player_data_ordering_follows_kills((a_player, other_player) in arb_players()) { - prop_assert_eq!(a_player.cmp(&other_player), a_player.kills.cmp(&other_player.kills)); + fn test_player_data_ordering_follows_kills_pos((a_player, other_player) in arb_players_pos()) { + prop_assert_eq!(a_player.cmp(&other_player), other_player.kills.cmp(&a_player.kills)); + } + } + + proptest! { + #[test] + fn test_player_data_ordering_follows_kills_neg((a_player, other_player) in arb_players_neg()) { + prop_assert_eq!(a_player.cmp(&other_player), other_player.kills.cmp(&a_player.kills)); } } diff --git a/src/quake3_parser/parser.rs b/src/quake3_parser/parser.rs index 0ea7a44..4eb841a 100644 --- a/src/quake3_parser/parser.rs +++ b/src/quake3_parser/parser.rs @@ -5,9 +5,14 @@ use std::collections::HashMap; /// Represents a game with the total kills and the players data #[derive(Debug)] pub struct Game { - /// The total kills in the game, counts also world kills - /// is represented by a vector of `MeanDeath` - pub total_kills: Vec, + /// Even though this info could be derived by summing + /// all the kills in the `means_death` hashmap + /// we would have to iterate over all the keys in the hashmap + /// so let's also keeps this as a running total + pub total_kills: u32, + /// The total kills in the game by means of death + /// is represented by a hashmap with the mean of death as key and the number of kills as value + pub kills_by_means_death: HashMap, /// The players data in the game /// is represented by a hashmap with the player id as key and the player data as value /// the player data contains the player name and the number of kills @@ -20,15 +25,18 @@ pub struct Game { /// to start a new game fn finish_game_and_set_new_game( games: &mut Vec, - total_kills: &mut Vec, + total_kills: &mut u32, + kills_by_means_death: &mut HashMap, players_data: &mut HashMap, ) { games.push(Game { - total_kills: total_kills.clone(), + total_kills: *total_kills, + kills_by_means_death: kills_by_means_death.clone(), players_data: players_data.clone(), }); + *total_kills = 0; + kills_by_means_death.clear(); players_data.clear(); - total_kills.clear(); } /// parses the `ClientConnect` event and initializes the `players_data` @@ -86,8 +94,9 @@ where /// fn parse_kill<'part, I>( parts: &mut I, + total_kills: &mut u32, + kills_by_means_death: &mut HashMap, players_data: &mut HashMap, - total_kills: &mut Vec, ) -> Result<(), ParsingError> where I: Iterator, @@ -109,18 +118,36 @@ where return Err(ParsingError::LogPartNotFound("mean_id".to_owned())); } let mean_id = mean_id_text[..mean_id_text.len().saturating_sub(1)].parse::()?; - total_kills.push(MeanDeath::from(mean_id)); + let mean_death = MeanDeath::from(mean_id); + *total_kills = total_kills + .checked_add(1) + .ok_or_else(|| ParsingError::UnexpectedError("Total kills overflow".to_owned()))?; + + match kills_by_means_death.get_mut(&mean_death) { + Some(count) => { + *count = count.checked_add(1).ok_or_else(|| { + ParsingError::UnexpectedError("Mean of death count overflow".to_owned()) + })?; + } + None => { + kills_by_means_death.insert(mean_death, 1); + } + } if killer_id == WORLD_ID { let data = players_data .get_mut(&victim_id) .ok_or_else(|| ParsingError::UnexpectedError("Victim not found".to_owned()))?; - data.kills = data.kills.saturating_sub(1); + data.kills = data.kills.checked_sub(1).ok_or_else(|| { + ParsingError::UnexpectedError("Player score has underflowed".to_owned()) + })?; } else { let data = players_data .get_mut(&killer_id) .ok_or_else(|| ParsingError::UnexpectedError("Killer not found".to_owned()))?; - data.kills = data.kills.saturating_add(1); + data.kills = data.kills.checked_add(1).ok_or_else(|| { + ParsingError::UnexpectedError("Player score has overflowed".to_owned()) + })?; } Ok(()) @@ -131,14 +158,13 @@ where /// the `players_data` hashmap contains the player id as key and the player data as value pub fn scan_file(log_content: &str) -> Result, ParsingError> { let mut games: Vec = Vec::new(); - let mut total_kills: Vec = Vec::new(); + let mut total_kills: u32 = 0; + let mut kills_by_means_death: HashMap = HashMap::new(); let mut players_data: HashMap = HashMap::new(); for line in log_content.lines() { let mut parts = line.split_whitespace(); - let time = if let Some(timestamp) = parts.next() { - timestamp - } else { + let Some(time) = parts.next() else { // skip empty lines continue; }; @@ -152,12 +178,22 @@ pub fn scan_file(log_content: &str) -> Result, ParsingError> { match event { "InitGame:" => { - if !total_kills.is_empty() { - finish_game_and_set_new_game(&mut games, &mut total_kills, &mut players_data); + if !kills_by_means_death.is_empty() { + finish_game_and_set_new_game( + &mut games, + &mut total_kills, + &mut kills_by_means_death, + &mut players_data, + ); } } "ShutdownGame:" => { - finish_game_and_set_new_game(&mut games, &mut total_kills, &mut players_data); + finish_game_and_set_new_game( + &mut games, + &mut total_kills, + &mut kills_by_means_death, + &mut players_data, + ); } "ClientConnect:" => { parse_client_connect(&mut parts, &mut players_data)?; @@ -166,7 +202,12 @@ pub fn scan_file(log_content: &str) -> Result, ParsingError> { parse_user_info(&mut parts, &mut players_data)?; } "Kill:" => { - parse_kill(&mut parts, &mut players_data, &mut total_kills)?; + parse_kill( + &mut parts, + &mut total_kills, + &mut kills_by_means_death, + &mut players_data, + )?; } _ => {} } @@ -181,7 +222,7 @@ mod tests { use proptest::prelude::*; prop_compose! { - fn arb_player_data()(name in "[a-z]*", kills in any::()) -> PlayerData { + fn arb_player_data()(name in "[a-z]*", kills in any::()) -> PlayerData { PlayerData { name, kills } } } @@ -347,8 +388,12 @@ mod tests { mean_id in 0..28u32, rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 1), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 1), ) { + prop_assume!(killer_id != victim_id); + + let initial_total_kills: Vec = kills_by_means_death.values().cloned().collect(); + let mut total_kills: u32 = initial_total_kills[0]; let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); players_data.insert(killer_id, PlayerData { name: "unknown".to_owned(), kills: 0 }); players_data.insert(victim_id, PlayerData { name: "unknown".to_owned(), kills: 1 }); @@ -360,7 +405,7 @@ mod tests { // remove the last character (that is a colon) from the mean_text let mean_id = mean_text[..mean_text.len().saturating_sub(1)].parse::().unwrap(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); prop_assert!(result.is_ok()); if killer_id == WORLD_ID { @@ -370,8 +415,9 @@ mod tests { prop_assert_eq!(players_data.get(&killer_id).unwrap().kills, 1); } - prop_assert_eq!(total_kills.len(), 2); - prop_assert_eq!(total_kills.last().unwrap(), &MeanDeath::from(mean_id)); + prop_assert_eq!(total_kills, initial_total_kills[0] + 1); + prop_assert!(kills_by_means_death.contains_key(&MeanDeath::from(mean_id))); + prop_assert_eq!(total_kills, kills_by_means_death.values().sum()); } } @@ -383,12 +429,13 @@ mod tests { mean_id in "\\s*", rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::LogPartNotFound(_)) => {}, _ => prop_assert!(false), @@ -404,12 +451,13 @@ mod tests { mean_id in 0..28u32, rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::ParseIntError(_)) => {}, _ => prop_assert!(false), @@ -425,12 +473,13 @@ mod tests { mean_id in 0..28u32, rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::ParseIntError(_)) => {}, _ => prop_assert!(false), @@ -446,12 +495,13 @@ mod tests { mean_id in "[^\\d\\s]+", // match everything that is not a digit or a whitespace rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::ParseIntError(_)) => {}, _ => prop_assert!(false), @@ -467,12 +517,13 @@ mod tests { mean_id in 0..28u32, rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::ParseIntError(_)) => {}, _ => prop_assert!(false), @@ -488,12 +539,13 @@ mod tests { mean_id in 0..28u32, rest in "\\PC*", mut players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::ParseIntError(_)) => {}, _ => prop_assert!(false), @@ -508,7 +560,8 @@ mod tests { victim_id in any::(), mean_id in 0..28u32, rest in "\\PC*", - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { prop_assume!(killer_id != victim_id); @@ -517,7 +570,7 @@ mod tests { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::UnexpectedError(_)) => {}, _ => prop_assert!(false), @@ -531,7 +584,8 @@ mod tests { victim_id in any::(), mean_id in 0..28u32, rest in "\\PC*", - mut total_kills in prop::collection::vec(a_random_mean_death(), 0..10), + mut kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + mut total_kills in any::(), ) { let killer_id = WORLD_ID; prop_assume!(killer_id != victim_id); @@ -541,7 +595,7 @@ mod tests { let kill_line = format!("{} {} {}: {}", killer_id, victim_id, mean_id, rest); let mut parts = kill_line.split_whitespace(); - let result = parse_kill(&mut parts, &mut players_data, &mut total_kills); + let result = parse_kill(&mut parts, &mut total_kills, &mut kills_by_means_death, &mut players_data); match result { Err(ParsingError::UnexpectedError(_)) => {}, _ => prop_assert!(false), @@ -576,7 +630,15 @@ mod tests { assert_eq!(games.len(), 2); let game0 = &games[0]; - assert_eq!(game0.total_kills.len(), 2); + assert_eq!(game0.total_kills, 2); + assert_eq!(game0.kills_by_means_death.len(), 1); + assert_eq!( + game0 + .kills_by_means_death + .get(&MeanDeath::RocketSplash) + .unwrap(), + &2 + ); assert_eq!(game0.players_data.len(), 2); assert_eq!(game0.players_data.get(&2).unwrap().name, "Isgalamido"); assert_eq!(game0.players_data.get(&2).unwrap().kills, 1); @@ -584,7 +646,15 @@ mod tests { assert_eq!(game0.players_data.get(&3).unwrap().kills, 1); let game1 = &games[1]; - assert_eq!(game1.total_kills.len(), 2); + assert_eq!(game1.total_kills, 2); + assert_eq!(game1.kills_by_means_death.len(), 1); + assert_eq!( + game1 + .kills_by_means_death + .get(&MeanDeath::TriggerHurt) + .unwrap(), + &2 + ); assert_eq!(game1.players_data.len(), 1); assert_eq!(game1.players_data.get(&2).unwrap().name, "Isgalamido"); assert_eq!(game1.players_data.get(&2).unwrap().kills, 0); @@ -617,7 +687,9 @@ mod tests { assert_eq!(games.len(), 1); let game0 = &games[0]; - assert_eq!(game0.total_kills.len(), 2); + assert_eq!(game0.total_kills, 2); + assert_eq!(game0.kills_by_means_death.len(), 1); + assert_eq!(game0.kills_by_means_death.get(&MeanDeath::from(mean_id)).unwrap(), &2); assert_eq!(game0.players_data.len(), 2); assert_eq!(game0.players_data.get(&player1_id).unwrap().name, "Isgalamido"); assert_eq!(game0.players_data.get(&player1_id).unwrap().kills, 1); @@ -650,7 +722,15 @@ mod tests { assert_eq!(games.len(), 2); let game0 = &games[0]; - assert_eq!(game0.total_kills.len(), 2); + assert_eq!(game0.total_kills, 2); + assert_eq!(game0.kills_by_means_death.len(), 1); + assert_eq!( + game0 + .kills_by_means_death + .get(&MeanDeath::RocketSplash) + .unwrap(), + &2 + ); assert_eq!(game0.players_data.len(), 2); assert_eq!(game0.players_data.get(&2).unwrap().name, "Dono da bola"); assert_eq!(game0.players_data.get(&2).unwrap().kills, 1); @@ -658,7 +738,15 @@ mod tests { assert_eq!(game0.players_data.get(&3).unwrap().kills, 1); let game1 = &games[1]; - assert_eq!(game1.total_kills.len(), 1); + assert_eq!(game1.total_kills, 1); + assert_eq!(game1.kills_by_means_death.len(), 1); + assert_eq!( + game1 + .kills_by_means_death + .get(&MeanDeath::TriggerHurt) + .unwrap(), + &1 + ); assert_eq!(game1.players_data.len(), 1); assert_eq!(game1.players_data.get(&2).unwrap().name, "Isgalamido"); assert_eq!(game1.players_data.get(&2).unwrap().kills, 1); diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..19cac2a --- /dev/null +++ b/src/report.rs @@ -0,0 +1,572 @@ +use table_to_html::HtmlTable; +use tabled::{ + builder::Builder, + settings::{object::Segment, Alignment, Settings, Style}, + Table, +}; + +use crate::{ + cli::{ReportFormat, ReportType}, + quake3_data::{MeanDeath, PlayerData}, + quake3_parser::parser::Game, +}; +use std::fmt::Display; + +#[allow(clippy::large_enum_variant)] +// I think size difference isn't actually that big +// even though Table seem to be bigger +// and Boxing Table seems excessive +// let's keep it for now +#[derive(Debug, Clone)] +/// The report type +/// Can be a text table or an html table +pub enum Report { + /// Text table report, via the `tabled` crate + Text(Table), + /// Html table report, via the `table_to_html` crate + Html(HtmlTable), +} + +impl Display for Report { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text(table) => write!(f, "{table}"), + Self::Html(html_table) => write!(f, "{html_table}"), + } + } +} + +/// Populates the table content rows for the terminal report +/// with the game data, player data and means of death data +fn populate_table_content( + builder: &mut Builder, + game: &Game, + players_data: &[&PlayerData], + report_type: &ReportType, + game_number: usize, +) { + let mut m_data = String::new(); + let mut kills_by_means_death: Vec<(&MeanDeath, &u32)> = + game.kills_by_means_death.iter().collect(); + kills_by_means_death.sort_unstable_by(|a, b| b.1.cmp(a.1)); + for (mean, count) in &kills_by_means_death { + m_data.push_str(&format!("\n{mean}: {count}\n")); + } + + let mut p_data = String::new(); + for player in players_data { + p_data.push_str(&format!("\n{}: {}\n", player.name, player.kills)); + } + + let mut game_data = vec![ + format!("Game {}", game_number), + format!("{}", game.total_kills), + ]; + match report_type { + ReportType::All => { + game_data.push(p_data); + game_data.push(m_data); + } + ReportType::PlayerRank => { + game_data.push(p_data); + } + ReportType::MeanDeath => { + game_data.push(m_data); + } + } + builder.insert_record(0, game_data); +} + +/// Populates the table headers for the terminal report +/// with the columns for the report type +fn populate_table_headers(builder: &mut Builder, report_type: &ReportType) { + let mut columns = vec!["\n\n", "\nTotal game kills\n"]; + match report_type { + ReportType::All => { + columns.push("\nKill Rank\n(Player: Score)\n"); + columns.push("\nDeath Causes\n(Cause: Count)\n"); + } + ReportType::PlayerRank => { + columns.push("\nKill Rank\n(Player: Score)\n"); + } + ReportType::MeanDeath => { + columns.push("\nDeath Causes\n(Cause: Count)\n"); + } + } + + builder.insert_record(0, columns); +} + +/// Returns report with the game data, player data and means of death data +/// in a table format +/// +/// The report can be either text or html +/// +/// And can include or exclude the player ranking and the mean of death ranking +/// +/// The report format is as follows: +/// Game N | Total kills in game: X | Player with most kills: Y +/// ... +/// Player with less kills: Z +/// Game N+1 | Total kills in game: X | Player with most kills: Y +/// ... +/// Player with less kills: Z +pub fn get_report( + games: &[Game], + report_type: &ReportType, + report_format: &ReportFormat, +) -> Result { + let mut builder = Builder::default(); + let mut game_number = games.len(); + + for game in games.iter().rev() { + let players_data: &mut Vec<&PlayerData> = &mut game.players_data.values().collect(); + players_data.sort_unstable(); + + populate_table_content(&mut builder, game, players_data, report_type, game_number); + + game_number = game_number.checked_sub(1).ok_or("Game number is zero")?; + } + populate_table_headers(&mut builder, report_type); + + match report_format { + ReportFormat::Text => { + let mut table = builder.build(); + table.with(Style::modern_rounded()); + table.modify( + Segment::all(), + Settings::new(Alignment::center(), Alignment::center_vertical()), + ); + Ok(Report::Text(table)) + } + ReportFormat::Html => { + let mut html_table = HtmlTable::with_header(Vec::>::from(builder)); + html_table.set_alignment( + table_to_html::Entity::Global, + table_to_html::Alignment::center(), + ); + html_table.set_border(1); + Ok(Report::Html(html_table)) + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use proptest::prelude::*; + + #[test] + fn test_display_empty_text_report() { + let report = Report::Text(Table::default()); + let report_str = report.to_string(); + assert!(report_str.is_empty()); + } + + #[test] + fn test_display_empty_html_report() { + let report = Report::Html(HtmlTable::new(vec![vec![""]])); + let report_str = report.to_string(); + let expected = r#" + + + + + +
+
+

+ +

+
+
"#; + assert_eq!(report_str, expected); + } + + /// Since the display of Report only passed to the formatter of each variant + /// let's test the `tabled` directly with: https://github.com/zhiburt/tabled/tree/master?tab=readme-ov-file#usage + #[test] + fn test_display_report_text_table() { + struct Language { + name: &'static str, + designed_by: &'static str, + invented_year: usize, + } + + let languages = vec![ + Language { + name: "C", + designed_by: "Dennis Ritchie", + invented_year: 1972, + }, + Language { + name: "Go", + designed_by: "Rob Pike", + invented_year: 2009, + }, + Language { + name: "Rust", + designed_by: "Graydon Hoare", + invented_year: 2010, + }, + ]; + + let mut builder = Builder::new(); + for language in languages.iter().rev() { + let record = vec![ + language.name.to_string(), + language.designed_by.to_string(), + language.invented_year.to_string(), + ]; + builder.insert_record(0, record); + } + let columns = vec!["name", "designed_by", "invented_year"]; + builder.insert_record(0, columns); + let table = builder.build(); + let report = Report::Text(table); + + let expected = "+------+----------------+---------------+\n\ + | name | designed_by | invented_year |\n\ + +------+----------------+---------------+\n\ + | C | Dennis Ritchie | 1972 |\n\ + +------+----------------+---------------+\n\ + | Go | Rob Pike | 2009 |\n\ + +------+----------------+---------------+\n\ + | Rust | Graydon Hoare | 2010 |\n\ + +------+----------------+---------------+"; + + assert_eq!(report.to_string(), expected); + } + + /// Since the display of Report only passed to the formatter of each variant + /// let's test the `table_to_html` directly with: https://docs.rs/table_to_html/latest/table_to_html/#example-building-a-table-from-iterator + #[test] + fn test_display_report_html_table() { + let data = vec![ + vec!["Debian", "", "0"], + vec!["Arch", "", "0"], + vec!["Manjaro", "Arch", "0"], + ]; + + let html_table = HtmlTable::new(data); + let report = Report::Html(html_table); + + assert_eq!( + report.to_string(), + concat!( + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
\n", + "
\n", + "

\n", + " Debian\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " \n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " 0\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " Arch\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " \n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " 0\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " Manjaro\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " Arch\n", + "

\n", + "
\n", + "
\n", + "
\n", + "

\n", + " 0\n", + "

\n", + "
\n", + "
" + ), + ) + } + + fn a_random_mean_death() -> impl Strategy { + prop_oneof![ + Just(MeanDeath::Unknown), + Just(MeanDeath::Shotgun), + Just(MeanDeath::Gauntlet), + Just(MeanDeath::Machinegun), + Just(MeanDeath::Grenade), + Just(MeanDeath::GrenadeSplash), + Just(MeanDeath::Rocket), + Just(MeanDeath::RocketSplash), + Just(MeanDeath::Plasma), + Just(MeanDeath::PlasmaSplash), + Just(MeanDeath::Railgun), + Just(MeanDeath::Lightning), + Just(MeanDeath::Bfg), + Just(MeanDeath::BfgSplash), + Just(MeanDeath::Water), + Just(MeanDeath::Slime), + Just(MeanDeath::Lava), + Just(MeanDeath::Crush), + Just(MeanDeath::Telefrag), + Just(MeanDeath::Falling), + Just(MeanDeath::Suicide), + Just(MeanDeath::TargetLaser), + Just(MeanDeath::TriggerHurt), + Just(MeanDeath::Nail), + Just(MeanDeath::Chaingun), + Just(MeanDeath::ProximityMine), + Just(MeanDeath::Kamikaze), + Just(MeanDeath::Juiced), + Just(MeanDeath::Grapple), + ] + } + + prop_compose! { + fn arb_player_data()(name in "[a-z]*", kills in any::()) -> PlayerData { + PlayerData { name, kills } + } + } + + prop_compose! { + fn arb_game()( + total_kills in any::(), + kills_by_means_death in prop::collection::hash_map(a_random_mean_death(), any::(), 0..10), + players_data in prop::collection::hash_map(any::(), arb_player_data(), 0..10), + ) -> Game { + Game { + total_kills, + kills_by_means_death, + players_data + } + } + } + + fn report_type() -> impl Strategy { + prop_oneof![ + Just(ReportType::All), + Just(ReportType::PlayerRank), + Just(ReportType::MeanDeath), + ] + } + + fn report_format() -> impl Strategy { + prop_oneof![Just(ReportFormat::Html), Just(ReportFormat::Text)] + } + + proptest! { + #[test] + fn test_get_report( + games in prop::collection::vec(arb_game(), 1..5), + report_type in report_type(), + report_format in report_format(), + ) { + let result = get_report(&games, &report_type, &report_format); + assert!(result.is_ok()); + + match result { + Ok(Report::Text(table)) => { + let table_str = table.to_string(); + assert!(!table_str.is_empty()); + } + Ok(Report::Html(html_table)) => { + let html_table_str = html_table.to_string(); + assert!(!html_table_str.is_empty()); + } + _ => panic!("Unexpected result"), + } + } + } + + #[test] + fn test_get_simple_report() { + let mut kills_by_means_death: HashMap = HashMap::new(); + kills_by_means_death.insert(MeanDeath::TriggerHurt, 1); + let mut players_data: HashMap = HashMap::new(); + players_data.insert( + 2, + PlayerData { + name: "Player1".to_string(), + kills: -1, + }, + ); + + let games = vec![ + Game { + total_kills: 1, + kills_by_means_death: kills_by_means_death.clone(), + players_data: players_data.clone(), + }, + Game { + total_kills: 1, + kills_by_means_death, + players_data, + }, + ]; + + let report_type = ReportType::All; + let report_format = ReportFormat::Text; + let result = get_report(&games, &report_type, &report_format); + assert!(result.is_ok()); + + let expected = concat!( + "╭────────┬──────────────────┬─────────────────┬────────────────╮\n", + "│ │ │ │ │\n", + "│ │ Total game kills │ Kill Rank │ Death Causes │\n", + "│ │ │ (Player: Score) │ (Cause: Count) │\n", + "│ │ │ │ │\n", + "├────────┼──────────────────┼─────────────────┼────────────────┤\n", + "│ │ │ │ │\n", + "│ Game 1 │ 1 │ Player1: -1 │ TriggerHurt: 1 │\n", + "│ │ │ │ │\n", + "├────────┼──────────────────┼─────────────────┼────────────────┤\n", + "│ │ │ │ │\n", + "│ Game 2 │ 1 │ Player1: -1 │ TriggerHurt: 1 │\n", + "│ │ │ │ │\n", + "╰────────┴──────────────────┴─────────────────┴────────────────╯", + ); + + let table_str = result.unwrap().to_string(); + assert!(!table_str.is_empty()); + assert_eq!(table_str, expected); + } + + proptest! { + #[test] + fn test_populate_table_content( + game in arb_game(), + report_type in report_type(), + game_number in any::(), + ) { + let mut builder = Builder::default(); + let players_data: &mut Vec<&PlayerData> = &mut game.players_data.values().collect(); + players_data.sort_unstable(); + populate_table_content(&mut builder, &game, players_data, &report_type, game_number); + let table = builder.build(); + let table_str = table.to_string(); + assert!(!table_str.is_empty()); + } + } + + #[test] + fn test_simple_populate_table_content() { + let mut kills_by_means_death: HashMap = HashMap::new(); + kills_by_means_death.insert(MeanDeath::TriggerHurt, 1); + let mut players_data: HashMap = HashMap::new(); + players_data.insert( + 2, + PlayerData { + name: "Player1".to_string(), + kills: -1, + }, + ); + + let game = Game { + total_kills: 1, + kills_by_means_death, + players_data, + }; + + let report_type = ReportType::All; + let game_number = 1; + let mut builder = Builder::default(); + let players_data: &mut Vec<&PlayerData> = &mut game.players_data.values().collect(); + players_data.sort_unstable(); + populate_table_content(&mut builder, &game, players_data, &report_type, game_number); + let mut table = builder.build(); + table.with(Style::modern_rounded()); + let table_str = table.to_string(); + assert!(!table_str.is_empty()); + + // formatting seems weird because we didn't set the alignment + let expected = concat!( + "╭────────┬───┬─────────────┬────────────────╮\n", + "│ Game 1 │ 1 │ │ │\n", + "│ │ │ Player1: -1 │ TriggerHurt: 1 │\n", + "│ │ │ │ │\n", + "╰────────┴───┴─────────────┴────────────────╯", + ); + + assert_eq!(table_str, expected); + } + + proptest! { + #[test] + fn test_populate_table_headers( + report_type in report_type(), + ) { + let mut builder = Builder::default(); + populate_table_headers(&mut builder, &report_type); + let table = builder.build(); + let table_str = table.to_string(); + assert!(!table_str.is_empty()); + } + } + + #[test] + fn test_simple_populate_table_headers() { + let report_type = ReportType::All; + let mut builder = Builder::default(); + populate_table_headers(&mut builder, &report_type); + let mut table = builder.build(); + table.with(Style::modern_rounded()); + let table_str = table.to_string(); + assert!(!table_str.is_empty()); + + let expected = concat!( + "╭──┬──────────────────┬─────────────────┬────────────────╮\n", + "│ │ │ │ │\n", + "│ │ Total game kills │ Kill Rank │ Death Causes │\n", + "│ │ │ (Player: Score) │ (Cause: Count) │\n", + "│ │ │ │ │\n", + "╰──┴──────────────────┴─────────────────┴────────────────╯", + ); + + assert_eq!(table_str, expected); + } +}