import { describe, it, expect } from 'vitest' import { DROPS_PER_ML, VOLUME_DROPS } from '../stores/oils' // --------------------------------------------------------------------------- // Replicate the volume / dilution calculation logic locally for unit testing // --------------------------------------------------------------------------- function getTotalDropsForMode(mode, customVal = 0, customUnit = 'drops') { if (mode === 'single') return null if (mode === 'custom') { return customUnit === 'ml' ? Math.round(customVal * 20) : Math.round(customVal) } const presets = { '5ml': 100, '10ml': 200, '30ml': 600 } return presets[mode] || 100 } function applyVolume(ingredients, mode, ratio, customVal, customUnit) { let targetEO, targetCoconut if (mode === 'single') { targetCoconut = 10 targetEO = Math.round(targetCoconut / ratio) } else { const totalDrops = getTotalDropsForMode(mode, customVal, customUnit) if (!totalDrops || totalDrops <= 0) return null targetEO = Math.round(totalDrops / (1 + ratio)) targetCoconut = totalDrops - targetEO } const eos = ingredients.filter(i => i.oil !== '椰子油') const currentTotalEO = eos.reduce((s, i) => s + i.drops, 0) if (currentTotalEO === 0) return null const factor = targetEO / currentTotalEO const scaled = eos.map(ing => ({ oil: ing.oil, drops: Math.max(0.5, Math.round(ing.drops * factor * 2) / 2), })) scaled.push({ oil: '椰子油', drops: targetCoconut }) return scaled } function detectVolumeMode(ingredients) { const eos = ingredients.filter(i => i.oil !== '椰子油') const coconut = ingredients.find(i => i.oil === '椰子油') const totalEO = eos.reduce((s, i) => s + i.drops, 0) const cDrops = coconut ? coconut.drops : 0 const totalAll = totalEO + cDrops if (totalAll === 100) return '5ml' if (totalAll === 200) return '10ml' if (totalAll === 600) return '30ml' if (cDrops > 0 && cDrops <= 20 && totalAll <= 40) return 'single' if (cDrops > 0) return 'custom' return 'single' } function getDilutionRatio(ingredients) { const eos = ingredients.filter(i => i.oil !== '椰子油') const coconut = ingredients.find(i => i.oil === '椰子油') const totalEO = eos.reduce((s, i) => s + i.drops, 0) const cDrops = coconut ? coconut.drops : 0 if (totalEO > 0 && cDrops > 0) return Math.round(cDrops / totalEO) return 0 } // --------------------------------------------------------------------------- // Helper: sum EO drops from a result set // --------------------------------------------------------------------------- function sumEO(result) { return result.filter(i => i.oil !== '椰子油').reduce((s, i) => s + i.drops, 0) } function coconutDrops(result) { const c = result.find(i => i.oil === '椰子油') return c ? c.drops : 0 } // =========================================================================== // Tests // =========================================================================== describe('Volume Constants', () => { it('DROPS_PER_ML equals 18.6', () => { expect(DROPS_PER_ML).toBe(18.6) }) it('VOLUME_DROPS has standard doTERRA sizes', () => { expect(VOLUME_DROPS).toHaveProperty('2.5') expect(VOLUME_DROPS).toHaveProperty('5') expect(VOLUME_DROPS).toHaveProperty('10') expect(VOLUME_DROPS).toHaveProperty('15') expect(VOLUME_DROPS).toHaveProperty('115') }) it('5ml bottle = 93 drops (factory standard)', () => { expect(VOLUME_DROPS['5']).toBe(93) }) it('15ml bottle = 280 drops', () => { expect(VOLUME_DROPS['15']).toBe(280) }) it('2.5ml bottle = 46 drops', () => { expect(VOLUME_DROPS['2.5']).toBe(46) }) it('10ml bottle = 186 drops', () => { expect(VOLUME_DROPS['10']).toBe(186) }) it('115ml bottle = 2146 drops', () => { expect(VOLUME_DROPS['115']).toBe(2146) }) }) describe('getTotalDropsForMode', () => { it("'single' returns null", () => { expect(getTotalDropsForMode('single')).toBeNull() }) it("'5ml' returns 100", () => { expect(getTotalDropsForMode('5ml')).toBe(100) }) it("'10ml' returns 200", () => { expect(getTotalDropsForMode('10ml')).toBe(200) }) it("'30ml' returns 600", () => { expect(getTotalDropsForMode('30ml')).toBe(600) }) it("'custom' with 20ml returns 400", () => { expect(getTotalDropsForMode('custom', 20, 'ml')).toBe(400) }) it("'custom' with 15 drops returns 15", () => { expect(getTotalDropsForMode('custom', 15, 'drops')).toBe(15) }) it("'custom' with 0 ml returns 0", () => { expect(getTotalDropsForMode('custom', 0, 'ml')).toBe(0) }) it("'custom' rounds fractional ml values", () => { expect(getTotalDropsForMode('custom', 7.5, 'ml')).toBe(150) }) it('unknown mode falls back to 100', () => { expect(getTotalDropsForMode('unknown')).toBe(100) }) }) describe('applyVolume - single dose', () => { const baseRecipe = [ { oil: '薰衣草', drops: 5 }, { oil: '椰子油', drops: 10 }, ] it('with ratio 10, coconut=10, EO=1', () => { const result = applyVolume(baseRecipe, 'single', 10) expect(coconutDrops(result)).toBe(10) expect(sumEO(result)).toBe(1) }) it('with ratio 5, coconut=10, EO=2', () => { const result = applyVolume(baseRecipe, 'single', 5) expect(coconutDrops(result)).toBe(10) expect(sumEO(result)).toBe(2) }) it('scales 3 oils proportionally', () => { const threeOils = [ { oil: '薰衣草', drops: 6 }, { oil: '乳香', drops: 3 }, { oil: '薄荷', drops: 3 }, { oil: '椰子油', drops: 10 }, ] const result = applyVolume(threeOils, 'single', 5) // targetEO = round(10/5) = 2 // factor = 2/12 const lavender = result.find(i => i.oil === '薰衣草') const frank = result.find(i => i.oil === '乳香') const mint = result.find(i => i.oil === '薄荷') // Lavender should get ~half of the EO, frank and mint ~quarter each expect(lavender.drops).toBeGreaterThanOrEqual(frank.drops) expect(frank.drops).toBe(mint.drops) }) it('minimum 0.5 drops per oil', () => { const tinyOil = [ { oil: '薰衣草', drops: 1 }, { oil: '乳香', drops: 1 }, { oil: '椰子油', drops: 10 }, ] // ratio 20 → targetEO = round(10/20) = 1, factor = 0.5 // each oil: max(0.5, round(1*0.5*2)/2) = max(0.5, 0.5) = 0.5 const result = applyVolume(tinyOil, 'single', 20) result.filter(i => i.oil !== '椰子油').forEach(i => { expect(i.drops).toBeGreaterThanOrEqual(0.5) }) }) }) describe('applyVolume - 5ml bottle', () => { it('100 total drops with ratio 10: EO~9, coconut~91', () => { const recipe = [ { oil: '薰衣草', drops: 3 }, { oil: '椰子油', drops: 10 }, ] const result = applyVolume(recipe, '5ml', 10) const totalEO = sumEO(result) const coco = coconutDrops(result) // targetEO = round(100/11) = 9, coconut = 91 expect(totalEO).toBe(9) expect(coco).toBe(91) expect(totalEO + coco).toBe(100) }) it('scales existing recipe proportionally', () => { const recipe = [ { oil: '薰衣草', drops: 6 }, { oil: '乳香', drops: 3 }, { oil: '椰子油', drops: 10 }, ] const result = applyVolume(recipe, '5ml', 10) const lav = result.find(i => i.oil === '薰衣草') const frank = result.find(i => i.oil === '乳香') // Original ratio is 2:1, scaled should preserve ~2:1 expect(lav.drops).toBeGreaterThan(frank.drops) }) it('preserves oil ratios approximately', () => { const recipe = [ { oil: '薰衣草', drops: 10 }, { oil: '乳香', drops: 5 }, { oil: '椰子油', drops: 20 }, ] const result = applyVolume(recipe, '5ml', 10) const lav = result.find(i => i.oil === '薰衣草') const frank = result.find(i => i.oil === '乳香') // ratio should be close to 2:1 expect(lav.drops / frank.drops).toBeCloseTo(2, 0) }) }) describe('applyVolume - 10ml bottle', () => { const recipe = [ { oil: '薰衣草', drops: 5 }, { oil: '椰子油', drops: 10 }, ] it('produces 200 total drops', () => { const result = applyVolume(recipe, '10ml', 10) const total = sumEO(result) + coconutDrops(result) expect(total).toBe(200) }) it('ratio 5 gives ~33 EO drops', () => { const result = applyVolume(recipe, '10ml', 5) // targetEO = round(200/6) = 33 expect(sumEO(result)).toBe(33) }) it('ratio 10 gives ~18 EO drops', () => { const result = applyVolume(recipe, '10ml', 10) // targetEO = round(200/11) = 18 expect(sumEO(result)).toBe(18) }) it('ratio 15 gives ~13 EO drops', () => { const result = applyVolume(recipe, '10ml', 15) // targetEO = round(200/16) = 13 (12.5 rounds to 13) expect(sumEO(result)).toBe(13) }) }) describe('applyVolume - 30ml bottle', () => { const recipe = [ { oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }, { oil: '椰子油', drops: 20 }, ] it('produces 600 total drops', () => { const result = applyVolume(recipe, '30ml', 10) const total = sumEO(result) + coconutDrops(result) expect(total).toBe(600) }) it('large recipe scaling preserves ratios', () => { const result = applyVolume(recipe, '30ml', 10) const lav = result.find(i => i.oil === '薰衣草') const frank = result.find(i => i.oil === '乳香') // Original ratio 5:3 ≈ 1.67 expect(lav.drops / frank.drops).toBeCloseTo(5 / 3, 0) }) it('ratio 10 gives ~55 EO drops', () => { const result = applyVolume(recipe, '30ml', 10) // targetEO = round(600/11) = 55 expect(sumEO(result)).toBe(55) }) }) describe('applyVolume - custom', () => { const recipe = [ { oil: '薰衣草', drops: 5 }, { oil: '椰子油', drops: 10 }, ] it('custom 20ml = 400 total drops', () => { const result = applyVolume(recipe, 'custom', 10, 20, 'ml') const total = sumEO(result) + coconutDrops(result) expect(total).toBe(400) }) it('custom 50 drops total', () => { const result = applyVolume(recipe, 'custom', 10, 50, 'drops') const total = sumEO(result) + coconutDrops(result) expect(total).toBe(50) }) it('custom 0 ml returns null', () => { const result = applyVolume(recipe, 'custom', 10, 0, 'ml') expect(result).toBeNull() }) }) describe('applyVolume - edge cases', () => { it('empty ingredients returns null', () => { const result = applyVolume([], '5ml', 10) expect(result).toBeNull() }) it('only coconut oil (no EO) returns null', () => { const result = applyVolume([{ oil: '椰子油', drops: 10 }], '5ml', 10) expect(result).toBeNull() }) it('single oil scales correctly', () => { const recipe = [ { oil: '薰衣草', drops: 5 }, { oil: '椰子油', drops: 10 }, ] const result = applyVolume(recipe, '5ml', 10) expect(result).not.toBeNull() expect(result.filter(i => i.oil !== '椰子油')).toHaveLength(1) }) it('very small drops round to 0.5 minimum', () => { const recipe = [ { oil: '薰衣草', drops: 100 }, { oil: '乳香', drops: 1 }, { oil: '椰子油', drops: 10 }, ] // Single mode ratio 50 → targetEO = round(10/50) = 0 → but round gives 0 // Actually ratio 10 → targetEO = 1, factor = 1/101 // 乳香: max(0.5, round(1 * (1/101) * 2)/2) = max(0.5, 0) = 0.5 const result = applyVolume(recipe, 'single', 10) const frank = result.find(i => i.oil === '乳香') expect(frank.drops).toBe(0.5) }) it('coconut oil is always the last element', () => { const recipe = [ { oil: '椰子油', drops: 10 }, { oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }, ] const result = applyVolume(recipe, '5ml', 10) expect(result[result.length - 1].oil).toBe('椰子油') }) it('no coconut in input still adds coconut to output', () => { const recipe = [ { oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }, ] const result = applyVolume(recipe, '5ml', 10) expect(result.find(i => i.oil === '椰子油')).toBeDefined() }) }) describe('detectVolumeMode', () => { it('100 total drops → 5ml', () => { const ing = [ { oil: '薰衣草', drops: 10 }, { oil: '椰子油', drops: 90 }, ] expect(detectVolumeMode(ing)).toBe('5ml') }) it('200 total drops → 10ml', () => { const ing = [ { oil: '薰衣草', drops: 20 }, { oil: '椰子油', drops: 180 }, ] expect(detectVolumeMode(ing)).toBe('10ml') }) it('600 total drops → 30ml', () => { const ing = [ { oil: '薰衣草', drops: 50 }, { oil: '椰子油', drops: 550 }, ] expect(detectVolumeMode(ing)).toBe('30ml') }) it('small recipe with coconut → single', () => { const ing = [ { oil: '薰衣草', drops: 2 }, { oil: '椰子油', drops: 10 }, ] expect(detectVolumeMode(ing)).toBe('single') }) it('coconut <= 20 and total <= 40 → single', () => { const ing = [ { oil: '薰衣草', drops: 20 }, { oil: '椰子油', drops: 20 }, ] expect(detectVolumeMode(ing)).toBe('single') }) it('coconut > 20 but not a preset → custom', () => { const ing = [ { oil: '薰衣草', drops: 10 }, { oil: '椰子油', drops: 40 }, ] expect(detectVolumeMode(ing)).toBe('custom') }) it('total > 40 but not a preset → custom', () => { const ing = [ { oil: '薰衣草', drops: 30 }, { oil: '椰子油', drops: 20 }, ] expect(detectVolumeMode(ing)).toBe('custom') }) it('no coconut at all → single', () => { const ing = [{ oil: '薰衣草', drops: 5 }] expect(detectVolumeMode(ing)).toBe('single') }) it('only EO totalling 100 still detects 5ml', () => { const ing = [{ oil: '薰衣草', drops: 100 }] expect(detectVolumeMode(ing)).toBe('5ml') }) }) describe('getDilutionRatio', () => { it('standard 1:10 ratio', () => { const ing = [ { oil: '薰衣草', drops: 10 }, { oil: '椰子油', drops: 100 }, ] expect(getDilutionRatio(ing)).toBe(10) }) it('no coconut returns 0', () => { const ing = [{ oil: '薰衣草', drops: 5 }] expect(getDilutionRatio(ing)).toBe(0) }) it('no EO returns 0', () => { const ing = [{ oil: '椰子油', drops: 50 }] expect(getDilutionRatio(ing)).toBe(0) }) it('1:5 ratio', () => { const ing = [ { oil: '薰衣草', drops: 10 }, { oil: '椰子油', drops: 50 }, ] expect(getDilutionRatio(ing)).toBe(5) }) it('1:1 ratio', () => { const ing = [ { oil: '薰衣草', drops: 10 }, { oil: '椰子油', drops: 10 }, ] expect(getDilutionRatio(ing)).toBe(1) }) it('rounds to nearest integer', () => { const ing = [ { oil: '薰衣草', drops: 3 }, { oil: '椰子油', drops: 20 }, ] // 20/3 = 6.67 → rounds to 7 expect(getDilutionRatio(ing)).toBe(7) }) it('multiple EO oils summed for ratio', () => { const ing = [ { oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 5 }, { oil: '椰子油', drops: 100 }, ] // 100/10 = 10 expect(getDilutionRatio(ing)).toBe(10) }) }) describe('Real recipe scaling', () => { const baseRecipe = [ { oil: '薰衣草', drops: 6 }, { oil: '乳香', drops: 3 }, { oil: '薄荷', drops: 3 }, { oil: '椰子油', drops: 20 }, ] it('scale to 5ml preserves approximate proportions', () => { const result = applyVolume(baseRecipe, '5ml', 10) const lav = result.find(i => i.oil === '薰衣草').drops const frank = result.find(i => i.oil === '乳香').drops const mint = result.find(i => i.oil === '薄荷').drops // Original: lav is 2x frank and 2x mint; frank == mint expect(frank).toBe(mint) expect(lav).toBeGreaterThanOrEqual(frank) }) it('scale to 10ml preserves approximate proportions', () => { const result = applyVolume(baseRecipe, '10ml', 10) const lav = result.find(i => i.oil === '薰衣草').drops const frank = result.find(i => i.oil === '乳香').drops const mint = result.find(i => i.oil === '薄荷').drops expect(frank).toBe(mint) expect(lav).toBeGreaterThanOrEqual(frank) }) it('10ml has approximately 2x the EO drops of 5ml', () => { const result5 = applyVolume(baseRecipe, '5ml', 10) const result10 = applyVolume(baseRecipe, '10ml', 10) const eo5 = sumEO(result5) const eo10 = sumEO(result10) // 10ml target = round(200/11) = 18, 5ml target = round(100/11) = 9 expect(eo10 / eo5).toBeCloseTo(2, 0) }) it('30ml has approximately 3x the EO drops of 10ml', () => { const result10 = applyVolume(baseRecipe, '10ml', 10) const result30 = applyVolume(baseRecipe, '30ml', 10) const eo10 = sumEO(result10) const eo30 = sumEO(result30) expect(eo30 / eo10).toBeCloseTo(3, 0) }) it('scale up then scale down gives close to original EO count', () => { // Scale to 30ml const scaled30 = applyVolume(baseRecipe, '30ml', 10) // Now scale the 30ml result back to single const scaledBack = applyVolume(scaled30, 'single', 10) // Single: targetEO = round(10/10) = 1 const totalEOBack = sumEO(scaledBack) expect(totalEOBack).toBeGreaterThanOrEqual(1) expect(totalEOBack).toBeLessThanOrEqual(3) // small due to rounding }) it('all EO drops are multiples of 0.5', () => { const result = applyVolume(baseRecipe, '5ml', 10) result.filter(i => i.oil !== '椰子油').forEach(i => { expect(i.drops * 2).toBe(Math.round(i.drops * 2)) }) }) it('coconut drops are always a whole number', () => { const result = applyVolume(baseRecipe, '10ml', 10) const coco = coconutDrops(result) expect(coco).toBe(Math.round(coco)) }) it('total drops are within 1 drop of the volume preset (0.5 rounding)', () => { ;['5ml', '10ml', '30ml'].forEach(mode => { const presets = { '5ml': 100, '10ml': 200, '30ml': 600 } const result = applyVolume(baseRecipe, mode, 10) const total = sumEO(result) + coconutDrops(result) // EO drops are rounded to nearest 0.5, so total may differ slightly expect(Math.abs(total - presets[mode])).toBeLessThanOrEqual(1.5) }) }) })