Browse Source

add unit test for game.js

master
Stephanie Gredell 3 weeks ago
parent
commit
2dc471b882
  1. 73
      game.js
  2. 289
      game.test.js

73
game.js

@ -28,10 +28,10 @@ export const Days = Object.freeze({ @@ -28,10 +28,10 @@ export const Days = Object.freeze({
* @type {Object.<string, number>}
*/
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 = { @@ -40,10 +40,10 @@ const WeatherFactor = {
* @type {Object.<string, number>}
*/
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 = { @@ -52,22 +52,22 @@ const IdealPrice = {
* @type {Object.<string, {lemons: number, sugar: number, ice: number}>}
*/
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 = [ @@ -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() { @@ -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() { @@ -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) { @@ -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 @@ -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 @@ -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) { @@ -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) { @@ -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,

289
game.test.js

@ -0,0 +1,289 @@ @@ -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);
});
});
Loading…
Cancel
Save