diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..2f45e7fa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cash-register" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cash-register" +path = "src/main.rs" + +[dependencies] +thiserror = "2" +rand = "0.8" + +[dev-dependencies] +proptest = "1" diff --git a/README.md b/README.md index fccbe0ad..49238b71 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,124 @@ # Cash Register +A command-line tool that calculates change denominations for cashiers. Written in Rust. + +## Quick Start + +```bash +cargo build +cargo run -- sample_input.txt +cargo test +``` + +## Usage + +``` +cash-register [--divisor N] [--seed N] [--currency USD|EUR] [--verbose] +``` + +**Input file**: Each line contains `owed,paid` as dollar amounts (e.g., `2.13,3.00`). Blank lines are skipped. + +**Output**: One line per transaction showing the change denominations. + +```bash +$ cargo run -- sample_input.txt --verbose +Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies +Owed $1.97, Paid $2.00 -> 3 pennies +Owed $3.33, Paid $5.00 -> 5 quarters,5 nickels,17 pennies (random) + +$ cargo run -- sample_edge_cases.txt --divisor 0 --verbose +Owed $5.00, Paid $5.00 -> no change +Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies +Owed $100.00, Paid $200.00 -> 100 dollars +Owed $1.97, Paid $2.00 -> 3 pennies +Owed $3.00, Paid $5.00 -> 2 dollars +Owed $0.75, Paid $1.00 -> 1 quarter + +$ cargo run -- sample_eur.txt --currency EUR --verbose +Owed €1.50, Paid €2.00 -> 1 20 cent coin,2 10 cent coins,2 5 cent coins (random) +Owed €3.33, Paid €5.00 -> 1 50 cent coin,1 10 cent coin,7 5 cent coins (random) +Owed €0.37, Paid €1.00 -> 1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin +Owed €7.77, Paid €10.00 -> 1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin (random) +``` + +Without `--verbose`, output matches the spec format exactly (`3 quarters,1 dime,3 pennies`). + +### Flags + +- `--divisor N` — Change which transactions get randomized denominations (default: 3). If `owed` in cents is divisible by N, the change is randomized. Use `--divisor 0` to disable randomization entirely. +- `--seed N` — Seed the random number generator for reproducible output. Useful for testing. +- `--currency USD|EUR` — Select the currency denomination set (default: USD). +- `--verbose` — Show transaction context alongside the change output. Labels random lines. + ## The Problem -Creative Cash Draw Solutions is a client who wants to provide something different for the cashiers who use their system. The function of the application is to tell the cashier how much change is owed, and what denominations should be used. In most cases the app should return the minimum amount of physical change, but the client would like to add a twist. If the "owed" amount is divisible by 3, the app should randomly generate the change denominations (but the math still needs to be right :)) -Please write a program which accomplishes the clients goals. The program should: +[Original problem statement from TrueFit](https://github.com/TrueFit/CashRegister): given a flat file of `owed,paid` pairs, output change denominations. When the owed amount is divisible by 3, randomize the denominations instead of minimizing them. + +## Design Decisions + +### Integer cents everywhere + +All money is represented as `u32` cents. The string `"2.13"` is parsed via string manipulation into `213u32` — no floating-point arithmetic is ever used. This eliminates an entire class of rounding bugs (e.g., `0.1 + 0.2 != 0.3` in IEEE 754). + +### Strategy trait with concrete types + +A `ChangeStrategy` trait defines the contract. `GreedyStrategy` minimizes denomination count; `RandomStrategy` randomizes it. The `rules` module decides which strategy to use based on transaction properties. Each piece has a single responsibility: + +- Algorithms don't know about business rules +- Rules don't know about algorithm internals +- Adding either doesn't require changing the other + +### Injectable randomness + +`RandomStrategy` is generic over its RNG source. Tests inject `StdRng::seed_from_u64()` for deterministic assertions. Production uses `StdRng::from_entropy()`. Zero-cost abstraction via monomorphization — no `Box`. + +### No heavy dependencies + +CLI argument parsing is a 5-line generic function, not a 50KB dependency. The only runtime dependencies are `thiserror` (structured errors) and `rand` (randomization) — both are well-established, minimal crates. -1. Accept a flat file as input - 1. Each line will contain the amount owed and the amount paid separated by a comma (for example: 2.13,3.00) - 2. Expect that there will be multiple lines -2. Output the change the cashier should return to the customer - 1. The return string should look like: 1 dollar,2 quarters,1 nickel, etc ... - 2. Each new line in the input file should be a new line in the output file +### Property-based testing -## Sample Input -2.12,3.00 +Unit tests verify specific cases. Property tests (`proptest`) verify invariants across thousands of random inputs: the random algorithm always sums to the target amount, only uses valid denominations, and never includes zero-count entries. -1.97,2.00 +## Architecture -3.33,5.00 +``` +src/ + main.rs CLI wiring: arg parsing, file I/O, exit codes + lib.rs Module re-exports + error.rs Error types with line numbers (thiserror) + currency.rs Denomination definitions — USD, EUR configs + parse.rs String → cents conversion, line → Transaction + strategy/ + mod.rs ChangeStrategy trait, Breakdown type alias + greedy.rs Minimum denomination count algorithm + random.rs Randomized denomination algorithm + rules.rs Strategy dispatch: divisor check → greedy or random + format.rs Breakdown → output string (pluralization, joining) +tests/ + integration.rs End-to-end binary tests + proptest.rs Property-based correctness tests +``` -## Sample Output -3 quarters,1 dime,3 pennies +## Things to Consider -3 pennies +> What might happen if the client needs to change the random divisor? -1 dollar,1 quarter,6 nickels,12 pennies +Pass `--divisor N` at the command line. The divisor flows through `rules::make_change_for` as a parameter — no code changes needed. Setting `--divisor 0` disables randomization entirely. -*Remember the last one is random +> What might happen if the client needs to add another special case (like the random twist)? -## The Fine Print -Please use whatever technology and techniques you feel are applicable to solve the problem. We suggest that you approach this exercise as if this code was part of a larger system. The end result should be representative of your abilities and style. +Add a new strategy struct implementing `ChangeStrategy` (one file), then add a branch in `rules.rs`. Existing strategies and tests are untouched. For example, a "round up to nearest quarter" strategy would be ~20 lines of code and one new match arm. -Please fork this repository. When you have completed your solution, please issue a pull request to notify us that you are ready. +> What might happen if sales closes a new client in France? -Have fun. +Pass `--currency EUR`. The EUR denomination table is already defined and wired into the CLI. Try it: `cargo run -- sample_eur.txt --currency EUR`. The denomination table drives all formatting — singular/plural names, values, everything. One caveat: France uses commas as decimal separators (`2,13` not `2.13`), which conflicts with the comma-delimited input format. A real deployment would need a configurable delimiter or a different input format (e.g., TSV, JSON). -## Things To Consider -Here are a couple of thoughts about the domain that could influence your response: +## Testing -* What might happen if the client needs to change the random divisor? -* What might happen if the client needs to add another special case (like the random twist)? -* What might happen if sales closes a new client in France? \ No newline at end of file +```bash +cargo test # All 74 tests: unit + integration + property-based +cargo test --lib # Unit tests only (48 tests) +cargo test --test integration # Integration tests only (18 tests) +cargo test --test proptest # Property-based tests only (8 tests) +``` diff --git a/sample_edge_cases.txt b/sample_edge_cases.txt new file mode 100644 index 00000000..811bdd60 --- /dev/null +++ b/sample_edge_cases.txt @@ -0,0 +1,6 @@ +5.00,5.00 +0.01,1.00 +100.00,200.00 +1.97,2.00 +3.00,5.00 +0.75,1.00 diff --git a/sample_eur.txt b/sample_eur.txt new file mode 100644 index 00000000..636f665f --- /dev/null +++ b/sample_eur.txt @@ -0,0 +1,4 @@ +1.50,2.00 +3.33,5.00 +0.37,1.00 +7.77,10.00 diff --git a/sample_input.txt b/sample_input.txt new file mode 100644 index 00000000..63a50779 --- /dev/null +++ b/sample_input.txt @@ -0,0 +1,3 @@ +2.12,3.00 +1.97,2.00 +3.33,5.00 diff --git a/src/currency.rs b/src/currency.rs new file mode 100644 index 00000000..272a9999 --- /dev/null +++ b/src/currency.rs @@ -0,0 +1,129 @@ +/// A single denomination: its value in cents and display names. +#[derive(Debug, Clone, Copy)] +pub struct Denomination { + pub cents: u32, + pub singular: &'static str, + pub plural: &'static str, +} + +/// A currency configuration: a name, symbol, and denominations (largest first). +#[derive(Debug, Clone)] +pub struct Currency { + pub name: &'static str, + pub symbol: &'static str, + pub denominations: &'static [Denomination], +} + +pub static USD: Currency = Currency { + name: "USD", + symbol: "$", + denominations: &[ + Denomination { + cents: 100, + singular: "dollar", + plural: "dollars", + }, + Denomination { + cents: 25, + singular: "quarter", + plural: "quarters", + }, + Denomination { + cents: 10, + singular: "dime", + plural: "dimes", + }, + Denomination { + cents: 5, + singular: "nickel", + plural: "nickels", + }, + Denomination { + cents: 1, + singular: "penny", + plural: "pennies", + }, + ], +}; + +pub static EUR: Currency = Currency { + name: "EUR", + symbol: "€", + denominations: &[ + Denomination { + cents: 200, + singular: "2 euro coin", + plural: "2 euro coins", + }, + Denomination { + cents: 100, + singular: "1 euro coin", + plural: "1 euro coins", + }, + Denomination { + cents: 50, + singular: "50 cent coin", + plural: "50 cent coins", + }, + Denomination { + cents: 20, + singular: "20 cent coin", + plural: "20 cent coins", + }, + Denomination { + cents: 10, + singular: "10 cent coin", + plural: "10 cent coins", + }, + Denomination { + cents: 5, + singular: "5 cent coin", + plural: "5 cent coins", + }, + Denomination { + cents: 2, + singular: "2 cent coin", + plural: "2 cent coins", + }, + Denomination { + cents: 1, + singular: "1 cent coin", + plural: "1 cent coins", + }, + ], +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usd_denominations_are_sorted_descending() { + let denoms = USD.denominations; + for window in denoms.windows(2) { + assert!( + window[0].cents > window[1].cents, + "{} ({}) should be larger than {} ({})", + window[0].singular, + window[0].cents, + window[1].singular, + window[1].cents, + ); + } + } + + #[test] + fn usd_smallest_denomination_is_one_cent() { + let last = USD.denominations.last().unwrap(); + assert_eq!( + last.cents, 1, + "smallest denomination must be 1 cent for exact change" + ); + } + + #[test] + fn eur_smallest_denomination_is_one_cent() { + let last = EUR.denominations.last().unwrap(); + assert_eq!(last.cents, 1); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..ce3efab4 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CashRegisterError { + #[error("line {line}: invalid dollar amount \"{input}\"")] + InvalidAmount { line: usize, input: String }, + + #[error("line {line}: paid ({paid}) is less than owed ({owed})")] + Underpayment { + line: usize, + owed: String, + paid: String, + }, + + #[error("line {line}: {detail}")] + MalformedLine { line: usize, detail: String }, + + #[error("{0}")] + Io(#[from] std::io::Error), +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 00000000..7c36dbaa --- /dev/null +++ b/src/format.rs @@ -0,0 +1,204 @@ +use crate::currency::Currency; +use crate::parse::Transaction; +use crate::strategy::Breakdown; + +/// Format a breakdown into the output string. +/// +/// Examples: +/// - `[(quarter, 3), (dime, 1), (penny, 3)]` -> `"3 quarters,1 dime,3 pennies"` +/// - `[]` -> `"no change"` +/// +/// Uses singular/plural from the denomination and joins with commas. +pub fn format_breakdown(breakdown: &Breakdown) -> String { + if breakdown.is_empty() { + return "no change".to_string(); + } + + breakdown + .iter() + .map(|(denom, count)| { + let name = if *count == 1 { + denom.singular + } else { + denom.plural + }; + format!("{count} {name}") + }) + .collect::>() + .join(",") +} + +/// Format cents with a currency symbol: 213, "$" -> "$2.13". +fn format_amount(cents: u32, symbol: &str) -> String { + format!("{symbol}{}.{:02}", cents / 100, cents % 100) +} + +/// Format a transaction with its breakdown for verbose output. +/// +/// Example: "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies" +/// With randomization: "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters (random)" +pub fn format_verbose( + transaction: &Transaction, + breakdown: &Breakdown, + currency: &Currency, + is_random: bool, +) -> String { + let change = format_breakdown(breakdown); + let label = if is_random { " (random)" } else { "" }; + let sym = currency.symbol; + format!( + "Owed {}, Paid {} -> {change}{label}", + format_amount(transaction.owed_cents, sym), + format_amount(transaction.paid_cents, sym), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::Denomination; + + fn penny() -> Denomination { + Denomination { + cents: 1, + singular: "penny", + plural: "pennies", + } + } + + fn quarter() -> Denomination { + Denomination { + cents: 25, + singular: "quarter", + plural: "quarters", + } + } + + fn dime() -> Denomination { + Denomination { + cents: 10, + singular: "dime", + plural: "dimes", + } + } + + fn dollar() -> Denomination { + Denomination { + cents: 100, + singular: "dollar", + plural: "dollars", + } + } + + #[test] + fn sample_output_format() { + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + assert_eq!(format_breakdown(&breakdown), "3 quarters,1 dime,3 pennies"); + } + + #[test] + fn single_denomination_singular() { + let breakdown = vec![(dollar(), 1)]; + assert_eq!(format_breakdown(&breakdown), "1 dollar"); + } + + #[test] + fn single_denomination_plural() { + let breakdown = vec![(penny(), 5)]; + assert_eq!(format_breakdown(&breakdown), "5 pennies"); + } + + #[test] + fn empty_breakdown() { + assert_eq!(format_breakdown(&Vec::new()), "no change"); + } + + #[test] + fn matches_exact_sample_output() { + // "3 quarters,1 dime,3 pennies" — note: no spaces after commas + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + let output = format_breakdown(&breakdown); + assert!( + !output.contains(", "), + "output should not have spaces after commas" + ); + assert_eq!(output, "3 quarters,1 dime,3 pennies"); + } + + #[test] + fn format_amount_usd() { + assert_eq!(format_amount(213, "$"), "$2.13"); + assert_eq!(format_amount(5, "$"), "$0.05"); + assert_eq!(format_amount(300, "$"), "$3.00"); + assert_eq!(format_amount(0, "$"), "$0.00"); + assert_eq!(format_amount(10000, "$"), "$100.00"); + } + + #[test] + fn format_amount_eur() { + assert_eq!(format_amount(150, "€"), "€1.50"); + assert_eq!(format_amount(1, "€"), "€0.01"); + } + + #[test] + fn verbose_greedy() { + let tx = Transaction { + owed_cents: 212, + paid_cents: 300, + change_cents: 88, + }; + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + assert_eq!( + format_verbose(&tx, &breakdown, &crate::currency::USD, false), + "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies", + ); + } + + #[test] + fn verbose_random() { + let tx = Transaction { + owed_cents: 333, + paid_cents: 500, + change_cents: 167, + }; + let breakdown = vec![(dollar(), 1), (quarter(), 2), (penny(), 17)]; + assert_eq!( + format_verbose(&tx, &breakdown, &crate::currency::USD, true), + "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,17 pennies (random)", + ); + } + + #[test] + fn verbose_no_change() { + let tx = Transaction { + owed_cents: 500, + paid_cents: 500, + change_cents: 0, + }; + assert_eq!( + format_verbose(&tx, &Vec::new(), &crate::currency::USD, false), + "Owed $5.00, Paid $5.00 -> no change", + ); + } + + #[test] + fn verbose_eur_uses_euro_symbol() { + let tx = Transaction { + owed_cents: 150, + paid_cents: 200, + change_cents: 50, + }; + let breakdown = vec![( + Denomination { + cents: 50, + singular: "50 cent coin", + plural: "50 cent coins", + }, + 1, + )]; + assert_eq!( + format_verbose(&tx, &breakdown, &crate::currency::EUR, false), + "Owed €1.50, Paid €2.00 -> 1 50 cent coin", + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..a5d7d5e0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod currency; +pub mod error; +pub mod format; +pub mod parse; +pub mod rules; +pub mod strategy; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..c2acde4b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,85 @@ +use std::env; +use std::fs; +use std::process; + +use rand::rngs::StdRng; +use rand::SeedableRng; + +use cash_register::currency::{EUR, USD}; +use cash_register::format::{format_breakdown, format_verbose}; +use cash_register::parse::parse_input; +use cash_register::rules::make_change_for; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: cash-register [--divisor N] [--seed N] [--currency USD|EUR] [--verbose]"); + process::exit(1); + } + + let file_path = &args[1]; + let divisor: u32 = parse_flag(&args, "--divisor").unwrap_or(3); + let seed: Option = parse_flag(&args, "--seed"); + let currency_name: String = parse_flag(&args, "--currency").unwrap_or("USD".to_string()); + let verbose = args.iter().any(|a| a == "--verbose"); + + let currency = match currency_name.to_uppercase().as_str() { + "USD" => &USD, + "EUR" => &EUR, + other => { + eprintln!("Unknown currency: {other}. Supported: USD, EUR"); + process::exit(1); + } + }; + + let input = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(e) => { + eprintln!("Error reading {file_path}: {e}"); + process::exit(1); + } + }; + + let mut had_error = false; + + // Use a concrete StdRng regardless — seeded or from entropy. + // This avoids Box and keeps everything monomorphized. + let mut rng = match seed { + Some(s) => StdRng::seed_from_u64(s), + None => StdRng::from_entropy(), + }; + + for result in parse_input(&input) { + match result { + Ok(transaction) => { + let breakdown = make_change_for(&transaction, currency, divisor, &mut rng); + if verbose { + let is_random = divisor > 0 && transaction.owed_cents.is_multiple_of(divisor); + println!( + "{}", + format_verbose(&transaction, &breakdown, currency, is_random) + ); + } else { + println!("{}", format_breakdown(&breakdown)); + } + } + Err(e) => { + eprintln!("{e}"); + had_error = true; + } + } + } + + if had_error { + process::exit(2); + } +} + +/// Parse a `--flag value` pair from command-line args. +fn parse_flag(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .and_then(|v| v.parse().ok()) +} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 00000000..31b649e3 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,206 @@ +use crate::error::CashRegisterError; + +/// A validated transaction: how much was owed and how much was paid, in cents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + pub owed_cents: u32, + pub paid_cents: u32, + pub change_cents: u32, +} + +/// Parse a dollar-amount string like "2.13" into cents (213). +/// +/// Uses string manipulation to avoid floating-point imprecision. +/// Accepts whole numbers ("3") and decimal numbers with 1-2 decimal places. +pub fn parse_dollars_to_cents(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty string".to_string()); + } + + match s.split_once('.') { + None => { + // Whole number: "3" -> 300 + let dollars: u32 = s + .parse() + .map_err(|_| format!("not a valid number: \"{s}\""))?; + Ok(dollars * 100) + } + Some((dollars_str, cents_str)) => { + if cents_str.len() > 2 { + return Err(format!("too many decimal places: \"{s}\"")); + } + + let dollars: u32 = dollars_str + .parse() + .map_err(|_| format!("invalid dollar part: \"{s}\""))?; + + // Pad single digit: "3.1" means 10 cents, not 1 cent + let padded = if cents_str.len() == 1 { + format!("{cents_str}0") + } else { + cents_str.to_string() + }; + + let cents: u32 = padded + .parse() + .map_err(|_| format!("invalid cents part: \"{s}\""))?; + + Ok(dollars * 100 + cents) + } + } +} + +/// Parse a single line like "2.13,3.00" into a Transaction. +pub fn parse_line(line: &str, line_number: usize) -> Result { + let line = line.trim(); + + let (owed_str, paid_str) = + line.split_once(',') + .ok_or_else(|| CashRegisterError::MalformedLine { + line: line_number, + detail: format!("expected \"owed,paid\" but got \"{line}\""), + })?; + + let owed_cents = + parse_dollars_to_cents(owed_str).map_err(|_| CashRegisterError::InvalidAmount { + line: line_number, + input: owed_str.trim().to_string(), + })?; + + let paid_cents = + parse_dollars_to_cents(paid_str).map_err(|_| CashRegisterError::InvalidAmount { + line: line_number, + input: paid_str.trim().to_string(), + })?; + + if paid_cents < owed_cents { + return Err(CashRegisterError::Underpayment { + line: line_number, + owed: owed_str.trim().to_string(), + paid: paid_str.trim().to_string(), + }); + } + + Ok(Transaction { + owed_cents, + paid_cents, + change_cents: paid_cents - owed_cents, + }) +} + +/// Parse all lines from input text, skipping blank lines. +/// Returns a Vec of Results so one bad line doesn't prevent processing others. +pub fn parse_input(input: &str) -> Vec> { + input + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(i, line)| parse_line(line, i + 1)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_whole_dollars() { + assert_eq!(parse_dollars_to_cents("3"), Ok(300)); + assert_eq!(parse_dollars_to_cents("0"), Ok(0)); + assert_eq!(parse_dollars_to_cents("100"), Ok(10_000)); + } + + #[test] + fn parse_dollars_and_cents() { + assert_eq!(parse_dollars_to_cents("2.13"), Ok(213)); + assert_eq!(parse_dollars_to_cents("3.00"), Ok(300)); + assert_eq!(parse_dollars_to_cents("0.01"), Ok(1)); + assert_eq!(parse_dollars_to_cents("0.50"), Ok(50)); + } + + #[test] + fn parse_single_decimal_digit() { + // "3.1" means $3.10 + assert_eq!(parse_dollars_to_cents("3.1"), Ok(310)); + } + + #[test] + fn parse_with_whitespace() { + assert_eq!(parse_dollars_to_cents(" 2.13 "), Ok(213)); + } + + #[test] + fn parse_rejects_too_many_decimals() { + assert!(parse_dollars_to_cents("2.123").is_err()); + } + + #[test] + fn parse_rejects_empty() { + assert!(parse_dollars_to_cents("").is_err()); + } + + #[test] + fn parse_rejects_non_numeric() { + assert!(parse_dollars_to_cents("abc").is_err()); + assert!(parse_dollars_to_cents("1.ab").is_err()); + } + + #[test] + fn parse_line_valid() { + let tx = parse_line("2.12,3.00", 1).unwrap(); + assert_eq!(tx.owed_cents, 212); + assert_eq!(tx.paid_cents, 300); + assert_eq!(tx.change_cents, 88); + } + + #[test] + fn parse_line_with_whitespace() { + let tx = parse_line(" 2.12 , 3.00 ", 1).unwrap(); + assert_eq!(tx.change_cents, 88); + } + + #[test] + fn parse_line_exact_payment() { + let tx = parse_line("5.00,5.00", 1).unwrap(); + assert_eq!(tx.change_cents, 0); + } + + #[test] + fn parse_line_underpayment() { + let result = parse_line("5.00,3.00", 1); + assert!(matches!( + result, + Err(CashRegisterError::Underpayment { .. }) + )); + } + + #[test] + fn parse_line_missing_comma() { + let result = parse_line("5.00", 1); + assert!(matches!( + result, + Err(CashRegisterError::MalformedLine { .. }) + )); + } + + #[test] + fn parse_input_skips_blank_lines() { + let input = "2.12,3.00\n\n1.97,2.00\n"; + let results = parse_input(input); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.is_ok())); + } + + #[test] + fn parse_input_preserves_line_numbers() { + let input = "\nbad\n2.12,3.00\n"; + let results = parse_input(input); + assert_eq!(results.len(), 2); + // The "bad" line is line 2 (1-indexed) + match &results[0] { + Err(CashRegisterError::MalformedLine { line, .. }) => assert_eq!(*line, 2), + other => panic!("expected MalformedLine, got {:?}", other), + } + } +} diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 00000000..ba4eded1 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,91 @@ +use rand::Rng; + +use crate::currency::Currency; +use crate::parse::Transaction; +use crate::strategy::greedy::GreedyStrategy; +use crate::strategy::random::RandomStrategy; +use crate::strategy::{Breakdown, ChangeStrategy}; + +/// Determine change for a transaction, dispatching to the appropriate strategy. +/// +/// If `owed_cents` is divisible by `divisor`, uses randomized denominations. +/// Otherwise, uses the greedy (minimum count) algorithm. +pub fn make_change_for( + transaction: &Transaction, + currency: &Currency, + divisor: u32, + rng: &mut R, +) -> Breakdown { + if transaction.change_cents == 0 { + return Vec::new(); + } + + if divisor > 0 && transaction.owed_cents.is_multiple_of(divisor) { + RandomStrategy::new(rng).make_change(transaction.change_cents, currency) + } else { + GreedyStrategy.make_change(transaction.change_cents, currency) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::USD; + use rand::rngs::StdRng; + use rand::SeedableRng; + + fn tx(owed: u32, paid: u32) -> Transaction { + Transaction { + owed_cents: owed, + paid_cents: paid, + change_cents: paid - owed, + } + } + + #[test] + fn divisible_by_3_uses_random() { + let mut rng = StdRng::seed_from_u64(42); + + // 333 is divisible by 3 + let random_result = make_change_for(&tx(333, 500), &USD, 3, &mut rng); + let total: u32 = random_result.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 167); + } + + #[test] + fn not_divisible_by_3_uses_greedy() { + let mut rng = StdRng::seed_from_u64(42); + + // 212 is not divisible by 3 + let result = make_change_for(&tx(212, 300), &USD, 3, &mut rng); + let named: Vec<(&str, u32)> = result.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("quarter", 3), ("dime", 1), ("penny", 3)]); + } + + #[test] + fn zero_divisor_always_uses_greedy() { + let mut rng = StdRng::seed_from_u64(42); + + // Even though 300 is divisible by 3, divisor is 0 so greedy is used + let result = make_change_for(&tx(300, 500), &USD, 0, &mut rng); + let named: Vec<(&str, u32)> = result.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("dollar", 2)]); + } + + #[test] + fn custom_divisor() { + let mut rng = StdRng::seed_from_u64(42); + + // 500 is divisible by 5 -> random + let result = make_change_for(&tx(500, 700), &USD, 5, &mut rng); + let total: u32 = result.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 200); + } + + #[test] + fn exact_payment_returns_empty() { + let mut rng = StdRng::seed_from_u64(42); + let result = make_change_for(&tx(300, 300), &USD, 3, &mut rng); + assert!(result.is_empty()); + } +} diff --git a/src/strategy/greedy.rs b/src/strategy/greedy.rs new file mode 100644 index 00000000..920fc43a --- /dev/null +++ b/src/strategy/greedy.rs @@ -0,0 +1,151 @@ +use super::{Breakdown, ChangeStrategy}; +use crate::currency::Currency; + +/// Greedy algorithm: use the fewest coins/bills possible. +/// +/// Iterates denominations largest-to-smallest, taking as many of each as +/// possible before moving to the next. Produces minimum denomination count +/// when the denomination set has the greedy property (true for USD and EUR). +pub struct GreedyStrategy; + +impl ChangeStrategy for GreedyStrategy { + fn make_change(&mut self, mut cents: u32, currency: &Currency) -> Breakdown { + let mut result = Vec::new(); + + for &denom in currency.denominations { + if cents == 0 { + break; + } + let count = cents / denom.cents; + if count > 0 { + result.push((denom, count)); + cents -= count * denom.cents; + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::{EUR, USD}; + + #[test] + fn sample_output_88_cents() { + // 3.00 - 2.12 = 0.88 -> 3 quarters, 1 dime, 3 pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(88, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("quarter", 3), ("dime", 1), ("penny", 3)],); + } + + #[test] + fn sample_output_3_cents() { + // 2.00 - 1.97 = 0.03 -> 3 pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(3, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("penny", 3)]); + } + + #[test] + fn exact_dollar_amount() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(300, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("dollar", 3)]); + } + + #[test] + fn zero_change() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(0, &USD); + assert!(breakdown.is_empty()); + } + + #[test] + fn uses_all_denominations() { + // 141 = 100 + 25 + 10 + 5 + 1 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(141, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("dollar", 1), + ("quarter", 1), + ("dime", 1), + ("nickel", 1), + ("penny", 1), + ], + ); + } + + #[test] + fn single_penny() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(1, &USD); + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("penny", 1)]); + } + + #[test] + fn large_amount() { + // $99.99 = 9999 cents + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(9999, &USD); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 9999); + // Should be 99 dollars, 3 quarters, 2 dimes, 4 pennies + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![("dollar", 99), ("quarter", 3), ("dime", 2), ("penny", 4)] + ); + } + + #[test] + fn eur_greedy_63_cents() { + // 63 = 50 + 10 + 2 + 1 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(63, &EUR); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("50 cent coin", 1), + ("10 cent coin", 1), + ("2 cent coin", 1), + ("1 cent coin", 1), + ], + ); + } + + #[test] + fn eur_greedy_387_cents() { + // 387 = 200 + 100 + 50 + 20 + 10 + 5 + 2 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(387, &EUR); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("2 euro coin", 1), + ("1 euro coin", 1), + ("50 cent coin", 1), + ("20 cent coin", 1), + ("10 cent coin", 1), + ("5 cent coin", 1), + ("2 cent coin", 1), + ], + ); + } +} diff --git a/src/strategy/mod.rs b/src/strategy/mod.rs new file mode 100644 index 00000000..c1844198 --- /dev/null +++ b/src/strategy/mod.rs @@ -0,0 +1,13 @@ +pub mod greedy; +pub mod random; + +use crate::currency::{Currency, Denomination}; + +/// A breakdown of change: pairs of (denomination, count). +/// Only includes denominations with count > 0. +pub type Breakdown = Vec<(Denomination, u32)>; + +/// A strategy for making change. +pub trait ChangeStrategy { + fn make_change(&mut self, cents: u32, currency: &Currency) -> Breakdown; +} diff --git a/src/strategy/random.rs b/src/strategy/random.rs new file mode 100644 index 00000000..cbe8d8ce --- /dev/null +++ b/src/strategy/random.rs @@ -0,0 +1,147 @@ +use rand::Rng; + +use super::{Breakdown, ChangeStrategy}; +use crate::currency::Currency; + +/// Randomized change algorithm: pick random counts for each denomination. +/// +/// For each denomination (largest to smallest), picks a random count between +/// 0 and the maximum possible. The final denomination (1 cent) absorbs the +/// remainder, guaranteeing the total is always exact. +/// +/// Generic over `R: Rng` so tests can inject a seeded RNG for determinism. +pub struct RandomStrategy { + rng: R, +} + +impl RandomStrategy { + pub fn new(rng: R) -> Self { + Self { rng } + } +} + +impl ChangeStrategy for RandomStrategy { + fn make_change(&mut self, mut cents: u32, currency: &Currency) -> Breakdown { + let mut result = Vec::new(); + let denoms = currency.denominations; + + for (i, &denom) in denoms.iter().enumerate() { + if cents == 0 { + break; + } + + let max_count = cents / denom.cents; + if max_count == 0 { + continue; + } + + let is_last = i == denoms.len() - 1; + let count = if is_last { + // Last denomination absorbs the remainder — guarantees exact change + max_count + } else { + self.rng.gen_range(0..=max_count) + }; + + if count > 0 { + result.push((denom, count)); + cents -= count * denom.cents; + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::{EUR, USD}; + use rand::rngs::StdRng; + use rand::SeedableRng; + + fn seeded_strategy(seed: u64) -> RandomStrategy { + RandomStrategy::new(StdRng::seed_from_u64(seed)) + } + + #[test] + fn random_change_sums_correctly() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let target = 167u32; // $1.67 + + let breakdown = strategy.make_change(target, &USD); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + + assert_eq!( + total, target, + "seed {seed}: breakdown sums to {total}, expected {target}" + ); + } + } + + #[test] + fn random_change_all_counts_positive() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let breakdown = strategy.make_change(250, &USD); + + for (denom, count) in &breakdown { + assert!( + *count > 0, + "seed {seed}: {} has count 0 but appeared in breakdown", + denom.singular + ); + } + } + } + + #[test] + fn random_change_zero() { + let mut strategy = seeded_strategy(42); + let breakdown = strategy.make_change(0, &USD); + assert!(breakdown.is_empty()); + } + + #[test] + fn deterministic_with_same_seed() { + let b1 = seeded_strategy(42).make_change(167, &USD); + let b2 = seeded_strategy(42).make_change(167, &USD); + + let counts1: Vec = b1.iter().map(|(_, c)| *c).collect(); + let counts2: Vec = b2.iter().map(|(_, c)| *c).collect(); + assert_eq!(counts1, counts2); + } + + #[test] + fn different_seeds_can_produce_different_results() { + // Not guaranteed, but with enough seeds at least one should differ + let baseline = seeded_strategy(0).make_change(500, &USD); + let baseline_counts: Vec = baseline.iter().map(|(_, c)| *c).collect(); + + let any_different = (1..50).any(|seed| { + let b = seeded_strategy(seed).make_change(500, &USD); + let counts: Vec = b.iter().map(|(_, c)| *c).collect(); + counts != baseline_counts + }); + + assert!( + any_different, + "random strategy should produce varied results across seeds" + ); + } + + #[test] + fn eur_random_sums_correctly() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let target = 263u32; // EUR has 2-cent coins and 20-cent coins — different structure + let breakdown = strategy.make_change(target, &EUR); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!( + total, target, + "seed {seed}: EUR breakdown sums to {total}, expected {target}" + ); + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 00000000..52df06b2 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,440 @@ +use std::process::Command; + +fn cargo_bin() -> Command { + let mut cmd = Command::new("cargo"); + cmd.args(["run", "--quiet", "--"]); + cmd.current_dir(env!("CARGO_MANIFEST_DIR")); + cmd +} + +// ─── Sample input tests ───────────────────────────────────────────── + +#[test] +fn sample_input_greedy_lines() { + // Lines where owed is NOT divisible by 3 should produce deterministic output. + // 2.12 -> 212, 212 % 3 != 0 -> greedy: 88 cents = 3 quarters, 1 dime, 3 pennies + // 1.97 -> 197, 197 % 3 != 0 -> greedy: 3 cents = 3 pennies + // 3.33 -> 333, 333 % 3 == 0 -> random (skip checking this line) + let output = cargo_bin() + .arg("sample_input.txt") + .arg("--seed") + .arg("42") + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3, "expected 3 output lines, got: {stdout:?}"); + assert_eq!(lines[0], "3 quarters,1 dime,3 pennies"); + assert_eq!(lines[1], "3 pennies"); + // Line 3 is random — just verify it's non-empty + assert!(!lines[2].is_empty(), "random line should not be empty"); +} + +#[test] +fn seed_produces_deterministic_output() { + let run = |seed: &str| -> String { + let output = cargo_bin() + .args(["sample_input.txt", "--seed", seed]) + .output() + .expect("failed to run binary"); + String::from_utf8_lossy(&output.stdout).to_string() + }; + + let first = run("99"); + let second = run("99"); + assert_eq!(first, second, "same seed should produce identical output"); +} + +#[test] +fn custom_divisor() { + // With --divisor 0, no lines are random, so all output is greedy + let output = cargo_bin() + .args(["sample_input.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "3 quarters,1 dime,3 pennies"); + assert_eq!(lines[1], "3 pennies"); + // With divisor 0, line 3 should also be greedy: 167 = 1 dollar,2 quarters,1 dime,1 nickel,2 pennies + assert_eq!(lines[2], "1 dollar,2 quarters,1 dime,1 nickel,2 pennies"); +} + +#[test] +fn random_line_sums_correctly() { + // 3.33,5.00 -> 167 cents of change, owed 333 which is divisible by 3 + let output = cargo_bin() + .args(["sample_input.txt", "--seed", "42"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let random_line = stdout.lines().nth(2).expect("expected 3 lines"); + + let total = parse_output_cents(random_line); + assert_eq!( + total, 167, + "random change should sum to 167 cents (got {total})" + ); +} + +// ─── Edge case tests ──────────────────────────────────────────────── + +#[test] +fn exact_payment_outputs_no_change() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + // 5.00,5.00 -> exact payment + assert_eq!(lines[0], "no change"); +} + +#[test] +fn edge_cases_greedy_output() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 6); + assert_eq!(lines[0], "no change"); // 5.00,5.00 + assert_eq!(lines[1], "3 quarters,2 dimes,4 pennies"); // 0.01,1.00 = 99c + assert_eq!(lines[2], "100 dollars"); // 100.00,200.00 = $100 + assert_eq!(lines[3], "3 pennies"); // 1.97,2.00 + assert_eq!(lines[4], "2 dollars"); // 3.00,5.00 + assert_eq!(lines[5], "1 quarter"); // 0.75,1.00 +} + +// ─── EUR end-to-end tests ─────────────────────────────────────────── + +#[test] +fn eur_greedy_output() { + let output = cargo_bin() + .args(["sample_eur.txt", "--currency", "EUR", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 4); + assert_eq!(lines[0], "1 50 cent coin"); // 1.50,2.00 = 50c + assert_eq!( + lines[1], + "1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin" + ); // 3.33,5.00 = 167c + assert_eq!( + lines[2], + "1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin" + ); // 0.37,1.00 = 63c + assert_eq!( + lines[3], + "1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin" + ); // 7.77,10.00 = 223c +} + +#[test] +fn eur_random_sums_correctly() { + // 3.33 -> 333, divisible by 3, so random with EUR + let output = cargo_bin() + .args(["sample_eur.txt", "--currency", "EUR", "--seed", "42"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let random_line = stdout.lines().nth(1).expect("expected 4 lines"); // line 2 is 3.33,5.00 + + let total = parse_eur_output_cents(random_line); + assert_eq!( + total, 167, + "EUR random change should sum to 167 cents (got {total})" + ); +} + +#[test] +fn unknown_currency_fails() { + let output = cargo_bin() + .args(["sample_input.txt", "--currency", "GBP"]) + .output() + .expect("failed to run binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Unknown currency"), + "expected currency error, got: {stderr}" + ); +} + +// ─── Error handling tests ─────────────────────────────────────────── + +#[test] +fn missing_file_returns_nonzero_exit() { + let output = cargo_bin() + .arg("nonexistent.txt") + .output() + .expect("failed to run binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Error reading"), + "expected file error message, got: {stderr}" + ); +} + +#[test] +fn no_args_shows_usage() { + let output = Command::new("cargo") + .args(["run", "--quiet"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("failed to run binary"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage"), "should show usage message"); + assert!(!output.status.success()); +} + +#[test] +fn underpayment_reports_error_to_stderr() { + // Write a temp file with an underpayment line + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_underpayment.txt"); + std::fs::write(&path, "5.00,3.00\n").unwrap(); + + let output = cargo_bin() + .arg(&path) + .output() + .expect("failed to run binary"); + + std::fs::remove_file(&path).ok(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("paid"), + "expected underpayment error, got: {stderr}" + ); + assert!( + stderr.contains("less than"), + "expected underpayment message, got: {stderr}" + ); +} + +#[test] +fn malformed_line_reports_error_with_line_number() { + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_malformed.txt"); + std::fs::write(&path, "2.12,3.00\nbad_line\n1.97,2.00\n").unwrap(); + + let output = cargo_bin() + .arg(&path) + .output() + .expect("failed to run binary"); + + std::fs::remove_file(&path).ok(); + + // Should still process valid lines + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.lines().count(), 2, "should output 2 valid lines"); + + // Should report error for line 2 + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("line 2"), + "expected line number in error, got: {stderr}" + ); + + // Should exit with error code + assert!(!output.status.success()); +} + +#[test] +fn mixed_valid_and_invalid_processes_all() { + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_mixed.txt"); + std::fs::write(&path, "1.00,2.00\nabc,def\n0.50,1.00\n").unwrap(); + + let output = cargo_bin() + .args([&path, "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + std::fs::remove_file(&path).ok(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Valid lines still produce output + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "1 dollar"); + assert_eq!(lines[1], "2 quarters"); + + // Bad line reported to stderr + assert!(stderr.contains("line 2"), "error should reference line 2"); + assert!(!output.status.success()); +} + +// ─── Verbose mode tests ───────────────────────────────────────────── + +#[test] +fn verbose_shows_transaction_context() { + let output = cargo_bin() + .args(["sample_input.txt", "--divisor", "0", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3); + assert_eq!( + lines[0], + "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies" + ); + assert_eq!(lines[1], "Owed $1.97, Paid $2.00 -> 3 pennies"); + assert_eq!( + lines[2], + "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,1 dime,1 nickel,2 pennies" + ); +} + +#[test] +fn verbose_labels_random_lines() { + let output = cargo_bin() + .args(["sample_input.txt", "--seed", "42", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + // Lines 1 and 2 are greedy — no "(random)" label + assert!( + !lines[0].contains("(random)"), + "greedy line should not be labeled random" + ); + assert!( + !lines[1].contains("(random)"), + "greedy line should not be labeled random" + ); + // Line 3 (owed $3.33, divisible by 3) should be labeled random + assert!( + lines[2].contains("(random)"), + "random line should be labeled: {}", + lines[2] + ); + assert!(lines[2].starts_with("Owed $3.33, Paid $5.00 -> ")); +} + +#[test] +fn verbose_edge_cases() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines[0], "Owed $5.00, Paid $5.00 -> no change"); + assert_eq!( + lines[1], + "Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies" + ); + assert_eq!(lines[2], "Owed $100.00, Paid $200.00 -> 100 dollars"); +} + +#[test] +fn verbose_eur() { + let output = cargo_bin() + .args([ + "sample_eur.txt", + "--currency", + "EUR", + "--divisor", + "0", + "--verbose", + ]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines[0], "Owed €1.50, Paid €2.00 -> 1 50 cent coin"); + assert!(lines[1].starts_with("Owed €3.33, Paid €5.00 -> ")); +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/// Parse a USD output line like "1 dollar,2 quarters,1 nickel,2 pennies" into total cents. +fn parse_output_cents(line: &str) -> u32 { + line.split(',') + .map(|part| { + let part = part.trim(); + let (count_str, name) = part.split_once(' ').expect("expected 'N name' format"); + let count: u32 = count_str.parse().expect("expected numeric count"); + let cents_per = match name { + "dollar" | "dollars" => 100, + "quarter" | "quarters" => 25, + "dime" | "dimes" => 10, + "nickel" | "nickels" => 5, + "penny" | "pennies" => 1, + other => panic!("unknown denomination: {other}"), + }; + count * cents_per + }) + .sum() +} + +/// Parse a EUR output line into total cents. +fn parse_eur_output_cents(line: &str) -> u32 { + // EUR format: "1 2 euro coin,2 50 cent coins,..." — split on comma, + // then the first token is the count, the rest is the denomination name. + line.split(',') + .map(|part| { + let part = part.trim(); + let (count_str, name) = part.split_once(' ').expect("expected 'N name' format"); + let count: u32 = count_str.parse().expect("expected numeric count"); + let cents_per = match name { + "2 euro coin" | "2 euro coins" => 200, + "1 euro coin" | "1 euro coins" => 100, + "50 cent coin" | "50 cent coins" => 50, + "20 cent coin" | "20 cent coins" => 20, + "10 cent coin" | "10 cent coins" => 10, + "5 cent coin" | "5 cent coins" => 5, + "2 cent coin" | "2 cent coins" => 2, + "1 cent coin" | "1 cent coins" => 1, + other => panic!("unknown EUR denomination: {other}"), + }; + count * cents_per + }) + .sum() +} diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 00000000..15a7ba2c --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,104 @@ +use proptest::prelude::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +use cash_register::currency::{EUR, USD}; +use cash_register::strategy::greedy::GreedyStrategy; +use cash_register::strategy::random::RandomStrategy; +use cash_register::strategy::ChangeStrategy; + +proptest! { + #[test] + fn random_always_sums_to_target(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "random breakdown must sum to target"); + } + + #[test] + fn random_uses_only_valid_denominations(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + let valid_values: Vec = USD.denominations.iter().map(|d| d.cents).collect(); + for (denom, _) in &breakdown { + prop_assert!( + valid_values.contains(&denom.cents), + "denomination {} is not in USD", + denom.cents + ); + } + } + + #[test] + fn random_all_counts_positive(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + for (denom, count) in &breakdown { + prop_assert!(*count > 0, "{} has count 0", denom.singular); + } + } + + #[test] + fn greedy_always_sums_to_target(cents in 0u32..10_000) { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &USD); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "greedy breakdown must sum to target"); + } + + #[test] + fn greedy_uses_minimum_coins(cents in 1u32..100) { + // For USD denominations, greedy should never use more coins than + // just using all pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &USD); + + let total_coins: u32 = breakdown.iter().map(|(_, c)| c).sum(); + prop_assert!(total_coins <= cents, "greedy should use at most {cents} coins, used {total_coins}"); + } + + // --- EUR property tests --- + + #[test] + fn eur_random_always_sums_to_target(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &EUR); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "EUR random breakdown must sum to target"); + } + + #[test] + fn eur_greedy_always_sums_to_target(cents in 0u32..10_000) { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &EUR); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "EUR greedy breakdown must sum to target"); + } + + #[test] + fn eur_random_uses_only_valid_denominations(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &EUR); + + let valid_values: Vec = EUR.denominations.iter().map(|d| d.cents).collect(); + for (denom, _) in &breakdown { + prop_assert!( + valid_values.contains(&denom.cents), + "denomination {} is not in EUR", + denom.cents + ); + } + } +}