Expand test suite to 364 tests (168 unit + 196 E2E)

Unit tests:
- Volume/dilution calculation (63 tests): scaling, mode detection,
  ratio calculation, real recipe round-trip verification

E2E tests:
- Batch operations: create/tag/delete 3 recipes, adopt workflow
- Projects: CRUD, pricing, profit calculation vs oil costs
- Notifications: fetch, fields, mark-all-read
- Account settings: profile read/update, auth rejection
- Category modules: listing, tag reference
- Registration: register, login, duplicate rejection
- Audit log: pagination, field validation, action tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:59:22 +00:00
parent 2491479c2c
commit ad3af5bd56
9 changed files with 969 additions and 0 deletions

View File

@@ -0,0 +1,584 @@
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)
})
})
})