import { describe, it, expect } from 'vitest'; import { ingredientSchema } from '../../src/lib/recipe'; import { positive, portionFactor, ingredientFactor, scaledAmount, formatAmount, convert, renderStep, mergeShopping, } from '../../src/lib/calculator'; import { pasta } from '../fixtures'; const chicken = ingredientSchema.parse({ id: 'chicken', name: 'Hühnerbrust', amount: 400, unit: 'g', }); describe('Precise scaling from immutable originals', () => { it('scales 2 to 8 portions', () => { const f = portionFactor(2, 8); expect(f).toBe('4'); expect(scaledAmount(chicken, f)).toBe('1600'); }); it('scales all ingredients from 400 g to 800 g', () => { const f = ingredientFactor(chicken, 800); expect(f).toBe('2'); expect(scaledAmount({ ...chicken, amount: 200 }, f)).toBe('400'); expect(scaledAmount({ ...chicken, amount: 50 }, f)).toBe('100'); }); it('supports downscaling and comma decimals', () => { expect(ingredientFactor(chicken, '0,2', 'kg')).toBe('0.5'); expect(portionFactor(2, '1,5')).toBe('0.75'); }); it('normalizes kg, liters and spoons without conflating mass with volume', () => { expect(convert(1.5, 'kg', 'g').toString()).toBe('1500'); expect(convert(250, 'ml', 'l').toString()).toBe('0.25'); expect(convert(2, 'EL', 'TL').toString()).toBe('6'); expect(() => convert(2, 'g', 'ml')).toThrow(); }); it('scales optional ingredients and leaves unquantified ingredients unchanged', () => { expect(scaledAmount({ ...chicken, optional: true }, 2)).toBe('800'); expect(scaledAmount({ ...chicken, amount: null }, 2)).toBeNull(); expect(() => ingredientFactor({ ...chicken, amount: null }, 3)).toThrow(); }); it.each([0, -1, 'NaN', 'Infinity', '', 'abc', 1000000001])('rejects invalid amounts: %s', (v) => { expect(() => positive(v)).toThrow(); }); it('prevents division by zero', () => { expect(() => ingredientFactor({ ...chicken, amount: 0 }, 1)).toThrow(); expect(() => portionFactor(0, 4)).toThrow(); }); it('rejects extreme scale factors before they reach the interface', () => { expect(() => ingredientFactor({ ...chicken, amount: 0.000001 }, 1000000)).toThrow(); expect(() => portionFactor(0.000001, 1000000)).toThrow(); }); it('keeps exact quantities despite repeated rescaling', () => { for (let n = 1; n <= 100; n++) { expect(scaledAmount(chicken, ingredientFactor(chicken, n))).toBe(String(n)); } expect(scaledAmount(chicken, '1')).toBe('400'); expect(scaledAmount({ ...chicken, amount: 0.1 }, 3)).toBe('0.3'); }); it('uses readable fractions and final display rounding', () => { expect(formatAmount(0.5)).toBe('½'); expect(formatAmount(1.25)).toBe('1¼'); expect(formatAmount(2.75)).toBe('2¾'); expect(formatAmount(10.6666)).toBe('10,67'); expect(formatAmount(null)).toBe(''); }); it('updates only structured references in instructions', () => { expect(renderStep('{{pasta}} für 10 Minuten kochen.', pasta, '2')).toBe( '400 g Pasta für 10 Minuten kochen.', ); }); it('merges compatible shopping quantities across recipes', () => { const list = mergeShopping([], [chicken], '2'); const result = mergeShopping(list, [{ ...chicken, amount: 0.5, unit: 'kg' }], '1'); expect(result).toHaveLength(1); expect(result[0].amount).toBe('1300'); expect(list[0].amount).toBe('800'); }); it('keeps incompatible shopping units and notes separate', () => { const a = mergeShopping([], [chicken], '1'); const b = mergeShopping( a, [ { ...chicken, unit: 'Stück' }, { ...chicken, note: 'gekocht' }, ], '1', ); expect(b).toHaveLength(3); }); });