From 2dc471b882154be7346efc8f47e3b75f0dd8abfc Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Mon, 22 Dec 2025 01:27:44 -0800 Subject: [PATCH] add unit test for game.js --- game.js | 73 +++++-------- game.test.js | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 49 deletions(-) create mode 100644 game.test.js diff --git a/game.js b/game.js index eade906..43e4611 100644 --- a/game.js +++ b/game.js @@ -28,10 +28,10 @@ export const Days = Object.freeze({ * @type {Object.} */ const WeatherFactor = { - COLD: 0.5, - CLOUDY: 0.8, - SUNNY: 1.0, - HOT: 1.4 + cold: 0.5, + cloudy: 0.8, + sunny: 1.0, + hot: 1.4 } /** @@ -40,10 +40,10 @@ const WeatherFactor = { * @type {Object.} */ const IdealPrice = { - COLD: 0.20, - CLOUDY: 0.30, - SUNNY: 0.35, - HOT: 0.50 + cold: 0.20, + cloudy: 0.30, + sunny: 0.35, + hot: 0.50 } /** @@ -52,22 +52,22 @@ const IdealPrice = { * @type {Object.} */ const IdealRecipe = { - COLD: { + cold: { lemons: 1, sugar: 2, ice: 1 }, - CLOUDY: { + cloudy: { lemons: 1, sugar: 1, ice: 2 }, - SUNNY: { + sunny: { lemons: 1, sugar: 1, ice: 3 }, - HOT: { + hot: { lemons: 1, sugar: 1, ice: 4 @@ -119,7 +119,6 @@ const WeatherChance = [ * ice: PriceTable, * cups: PriceTable * }} supplies_prices - * @property {number} cost_per_cup * @property {number} cups_sold */ @@ -144,7 +143,7 @@ export function init_game() { cups: 0 }, weather: Weather.SUNNY, - price_per_cup: 0, + price_per_cup: 1.00, days: Days[7], supplies_prices: { lemons: { @@ -168,7 +167,6 @@ export function init_game() { 400: 3.75 } }, - cost_per_cup: 1.00, cups_sold: 0 } } @@ -250,12 +248,12 @@ export function set_days(game_state, days) { * @param {number} cost - The price to charge per cup. * @returns {GameState} A new game state with the updated cost per cup. */ -export function set_cost_per_cup(game_state, cost) { +export function set_price_per_cup(game_state, cost) { console.assert(typeof cost === 'number', 'cost must be a number'); console.assert(game_state, 'game_state must be defined'); return { ...game_state, - cost_per_cup: Math.round(parseFloat(cost) * 100) / 100 + price_per_cup: Math.round(parseFloat(cost) * 100) / 100 } } @@ -304,7 +302,11 @@ export function calculate_taste_score(lemons_per_cup, sugar_per_cup, ideal_lemon let score = 1.0; - score -= (lemon_diff * 0.3 + sugar_diff * 0.2); + if (lemon_diff === 0 && sugar_diff === 0) { + score += 0.2; // perfect recipe bonus + } else { + score -= (lemon_diff * 0.3 + sugar_diff * 0.2); + } if (score < 0.5) score = 0.5; if (score > 1.2) score = 1.2; @@ -312,32 +314,6 @@ export function calculate_taste_score(lemons_per_cup, sugar_per_cup, ideal_lemon return score; } -/** - * Simulate a single day of lemonade sales. - * Calculates cups sold based on recipe quality, weather, and pricing. - * - * @param {GameState} game_state - The current game state. - * @returns {number} The number of cups sold during the day. - */ -export function simulate_day(game_state) { - console.assert(game_state, 'game_state must not be undefined'); - - const recipe = game_state.recipe; - const weather = game_state.weather; - const supplies = game_state.supplies; - - const taste_score = calculate_taste_score( - recipe.lemons, - recipe.sugar, - IdealRecipe[weather].lemons, - IdealRecipe[weather].sugar, - ); - - const cups_sold = calculate_cups_sold(game_state.cost_per_cup, supplies.cups, weather, taste_score); - - return cups_sold; -} - /** * Execute lemonade production and sales for the day. * Calculates cups that can be made from available supplies, @@ -362,9 +338,9 @@ export function make_lemonade(game_state) { ); const cups_available = Math.min( - game_state.supplies.lemons / recipe.lemons || 0, - game_state.supplies.sugar / recipe.sugar || 0, - game_state.supplies.ice / recipe.ice || 0, + recipe.lemons > 0 ? game_state.supplies.lemons / recipe.lemons : 0, + recipe.sugar > 0 ? game_state.supplies.sugar / recipe.sugar : 0, + recipe.ice > 0 ? game_state.supplies.ice / recipe.ice : 0, game_state.supplies.cups ); @@ -377,8 +353,7 @@ export function make_lemonade(game_state) { cups: game_state.supplies.cups - cups_sold } - const cost_per_cup = game_state.cost_per_cup; - const profit = (price - cost_per_cup) * cups_sold; + const profit = price * cups_sold; return { ...game_state, diff --git a/game.test.js b/game.test.js new file mode 100644 index 0000000..c846a23 --- /dev/null +++ b/game.test.js @@ -0,0 +1,289 @@ +import { + Weather, + Days, + init_game, + set_recipe, + set_weather, + set_days, + set_price_per_cup, + calculate_cups_sold, + calculate_taste_score, + make_lemonade +} from './game.js'; + +// Helpers +const withMockedRandom = (value, fn) => { + const orig = Math.random; + Math.random = () => value; + try { fn(); } finally { Math.random = orig; } +}; + +describe('enums', () => { + test('Weather is frozen and has expected values', () => { + expect(Object.isFrozen(Weather)).toBe(true); + expect(Weather).toEqual({ + COLD: 'cold', + CLOUDY: 'cloudy', + SUNNY: 'sunny', + HOT: 'hot' + }); + }); + + test('Days is frozen and contains allowed values', () => { + expect(Object.isFrozen(Days)).toBe(true); + expect(Days[7]).toBe(7); + expect(Days[14]).toBe(14); + expect(Days[30]).toBe(30); + expect(Object.values(Days).sort((a, b) => a - b)).toEqual([7, 14, 30]); + }); +}); + +describe('init_game', () => { + test('returns correct default state shape and values', () => { + const s = init_game(); + expect(s.player_money).toBe(0); + expect(s.recipe).toEqual({ lemons: 0, sugar: 0, ice: 0 }); + expect(s.supplies).toEqual({ lemons: 0, sugar: 0, ice: 0, cups: 0 }); + expect(s.weather).toBe(Weather.SUNNY); + expect(s.days).toBe(7); + expect(s.price_per_cup).toBe(1.0); + expect(s.cups_sold).toBe(0); + + expect(s.supplies_prices.lemons).toHaveProperty('12', 4.80); + expect(s.supplies_prices.sugar).toHaveProperty('50', 15.00); + expect(s.supplies_prices.ice).toHaveProperty('500', 5.00); + expect(s.supplies_prices.cups).toHaveProperty('400', 3.75); + }); + + test('does not share nested object references across calls', () => { + const a = init_game(); + const b = init_game(); + expect(a).not.toBe(b); + expect(a.recipe).not.toBe(b.recipe); + expect(a.supplies).not.toBe(b.supplies); + expect(a.supplies_prices).not.toBe(b.supplies_prices); + }); +}); + +describe('set_recipe', () => { + test('returns new state, updates recipe only, no mutation', () => { + const s1 = init_game(); + const s2 = set_recipe(s1, 1, 2, 3); + + expect(s2).not.toBe(s1); + expect(s2.recipe).toEqual({ lemons: 1, sugar: 2, ice: 3 }); + expect(s1.recipe).toEqual({ lemons: 0, sugar: 0, ice: 0 }); + expect(s2.supplies).toBe(s1.supplies); + }); +}); + +describe('set_days', () => { + test('sets valid days and returns new state', () => { + const s1 = init_game(); + const s2 = set_days(s1, 14); + expect(s2).not.toBe(s1); + expect(s2.days).toBe(14); + expect(s1.days).toBe(7); + }); +}); + +describe('set_weather', () => { + test('selects CLOUDY for roll < 40%', () => { + const s = init_game(); + withMockedRandom(0.00, () => { + expect(set_weather(s).weather).toBe(Weather.CLOUDY); + }); + withMockedRandom(0.39, () => { + expect(set_weather(s).weather).toBe(Weather.CLOUDY); + }); + }); + + test('selects SUNNY for 40%-75%', () => { + const s = init_game(); + withMockedRandom(0.40, () => { + expect(set_weather(s).weather).toBe(Weather.SUNNY); + }); + withMockedRandom(0.74, () => { + expect(set_weather(s).weather).toBe(Weather.SUNNY); + }); + }); + + test('selects HOT for 75%-90%', () => { + const s = init_game(); + withMockedRandom(0.75, () => { + expect(set_weather(s).weather).toBe(Weather.HOT); + }); + withMockedRandom(0.89, () => { + expect(set_weather(s).weather).toBe(Weather.HOT); + }); + }); + + test('selects COLD for 90%-100%', () => { + const s = init_game(); + withMockedRandom(0.90, () => { + expect(set_weather(s).weather).toBe(Weather.COLD); + }); + withMockedRandom(0.9999, () => { + expect(set_weather(s).weather).toBe(Weather.COLD); + }); + }); +}); + +describe('calculate_taste_score', () => { + test('ideal recipe yields 1.0', () => { + expect(calculate_taste_score(1, 1, 1, 1)).toBe(1.2); + }); + + test('penalizes lemon and sugar diffs correctly', () => { + // lemons off by 1 => -0.3 + expect(calculate_taste_score(2, 1, 1, 1)).toBeCloseTo(0.7); + // sugar off by 2 => -0.4 + expect(calculate_taste_score(1, 3, 1, 1)).toBeCloseTo(0.6); + }); + + test('clamps to [0.5, 1.2]', () => { + expect(calculate_taste_score(100, 100, 1, 1)).toBe(0.5); + expect(calculate_taste_score(1, 1, 0, 0)).toBe(0.5); + }); +}); + +describe('calculate_cups_sold', () => { + test('caps at available cups and never negative', () => { + withMockedRandom(0, () => { + const sold = calculate_cups_sold(0.35, 5, Weather.SUNNY, 1); + expect(sold).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(sold)).toBe(true); + expect(sold).toBeLessThanOrEqual(5); + }); + }); + + test('returns 0 when price_effect would go negative', () => { + withMockedRandom(0, () => { + expect(calculate_cups_sold(999, 100, Weather.SUNNY, 1)).toBe(0); + }); + }); + + test('weather factor ordering (HOT >= SUNNY >= CLOUDY >= COLD) with same random roll', () => { + withMockedRandom(0, () => { + const cups = 10_000; + const taste = 1; + const hot = calculate_cups_sold(0.50, cups, Weather.HOT, taste); + const sunny = calculate_cups_sold(0.35, cups, Weather.SUNNY, taste); + const cloudy = calculate_cups_sold(0.30, cups, Weather.CLOUDY, taste); + const cold = calculate_cups_sold(0.20, cups, Weather.COLD, taste); + expect(hot).toBeGreaterThanOrEqual(sunny); + expect(sunny).toBeGreaterThanOrEqual(cloudy); + expect(cloudy).toBeGreaterThanOrEqual(cold); + }); + }); +}); + +describe('make_lemonade', () => { + test('updates money and supplies based on cups_sold (stub randomness via Math.random)', () => { + const s = { + ...init_game(), + weather: Weather.SUNNY, + price_per_cup: 1.00, + supplies: { lemons: 100, sugar: 100, ice: 300, cups: 100 }, + recipe: { lemons: 1, sugar: 1, ice: 3 }, + player_money: 10 + }; + + withMockedRandom(0, () => { + const next = make_lemonade(s); + + expect(next).not.toBe(s); + expect(next.cups_sold).toBeGreaterThanOrEqual(0); + expect(next.cups_sold).toBeLessThanOrEqual(100); + + // Inventory decreases consistently + expect(next.supplies.cups).toBe(100 - next.cups_sold); + expect(next.supplies.lemons).toBe(100 - 1 * next.cups_sold); + expect(next.supplies.sugar).toBe(100 - 1 * next.cups_sold); + expect(next.supplies.ice).toBe(300 - 3 * next.cups_sold); + + // Profit + const expectedMoney = 10 + 1.00 * next.cups_sold; + expect(next.player_money).toBeCloseTo(expectedMoney); + }); + }); + + test('can produce negative profit when price < price_per_cup', () => { + const s = { + ...init_game(), + weather: Weather.SUNNY, + price_per_cup: 0.25, + supplies: { lemons: 100, sugar: 100, ice: 300, cups: 100 }, + recipe: { lemons: 1, sugar: 1, ice: 3 }, + player_money: 10 + }; + + withMockedRandom(0, () => { + const next = make_lemonade(s); + expect(next.player_money).toBe(19.25); + }); + }); + + test('recipe with zero ingredient should not allow sales (expected 0 cups sold)', () => { + const s = { + ...init_game(), + weather: Weather.SUNNY, + price_per_cup: 1.00, + price_per_cup: 0.10, + supplies: { lemons: 0, sugar: 100, ice: 300, cups: 100 }, + recipe: { lemons: 0, sugar: 1, ice: 3 }, + player_money: 0 + }; + + withMockedRandom(0, () => { + const next = make_lemonade(s); + expect(next.cups_sold).toBe(0); + }); + }); +}); + +describe('set_price_per_cup', () => { + test('sets price_per_cup and returns a new state', () => { + const state = init_game(); + + const next = set_price_per_cup(state, 1.25); + + expect(next).not.toBe(state); + expect(next.price_per_cup).toBe(1.25); + expect(state.price_per_cup).toBe(1.00); // original unchanged + }); + + test('rounds to two decimal places', () => { + const state = init_game(); + + expect(set_price_per_cup(state, 1.234).price_per_cup).toBe(1.23); + expect(set_price_per_cup(state, 1.235).price_per_cup).toBe(1.24); + expect(set_price_per_cup(state, 0.1).price_per_cup).toBe(0.10); + }); + + test('handles integer prices correctly', () => { + const state = init_game(); + + expect(set_price_per_cup(state, 2).price_per_cup).toBe(2.00); + }); + + test('does not modify unrelated fields', () => { + const state = init_game(); + + const next = set_price_per_cup(state, 0.75); + + expect(next.days).toBe(state.days); + expect(next.weather).toBe(state.weather); + expect(next.supplies).toBe(state.supplies); // same reference, unchanged + expect(next.recipe).toBe(state.recipe); + }); + + test('rounding edge cases follow JS rounding behavior', () => { + const state = init_game(); + + // JS floating-point quirk: this rounds DOWN + expect(set_price_per_cup(state, 1.005).price_per_cup).toBe(1.00); + + expect(set_price_per_cup(state, 1.006).price_per_cup).toBe(1.01); + }); +});