import { describe, it, expect } from 'vitest' import prodData from './fixtures/production-data.json' import { KITS } from '../config/kits' const oils = prodData.oils // --------------------------------------------------------------------------- // Replicate kit cost calculation logic from useKitCost.js (pure functions) // --------------------------------------------------------------------------- function resolveOilName(kitOilName) { if (oils[kitOilName]) return kitOilName const match = Object.keys(oils).find(n => n.endsWith(kitOilName) && n !== kitOilName) return match || kitOilName } function calcKitPerDrop(kit) { const resolved = kit.oils.map(resolveOilName) const bc = kit.bottleCount || {} let totalBottlePrice = 0 const oilBottlePrices = {} for (const name of resolved) { const meta = oils[name] const count = bc[name] || 1 const bp = meta ? meta.bottlePrice * count : 0 oilBottlePrices[name] = bp totalBottlePrice += bp } if (totalBottlePrice === 0) return {} const totalValue = totalBottlePrice + (kit.accessoryValue || 0) const discountRate = Math.min(kit.price / totalValue, 1) const perDrop = {} for (const name of resolved) { const meta = oils[name] const count = bc[name] || 1 const bp = oilBottlePrices[name] const kitCostForOil = bp * discountRate const totalDrops = meta ? meta.dropCount * count : 1 perDrop[name] = totalDrops > 0 ? kitCostForOil / totalDrops : 0 } return perDrop } function canMakeRecipe(kit, recipe) { const resolvedSet = new Set(kit.oils.map(resolveOilName)) return recipe.ingredients.every(ing => resolvedSet.has(ing.oil)) } function calcRecipeCostWithKit(kitPerDrop, recipe) { return recipe.ingredients.reduce((sum, ing) => { return sum + (kitPerDrop[ing.oil] || 0) * ing.drops }, 0) } function calcOriginalCost(recipe) { return recipe.ingredients.reduce((sum, ing) => { const meta = oils[ing.oil] if (!meta || !meta.dropCount) return sum return sum + (meta.bottlePrice / meta.dropCount) * ing.drops }, 0) } // --------------------------------------------------------------------------- // Kit Configuration // --------------------------------------------------------------------------- describe('Kit Configuration', () => { it('has 4 kits defined', () => { expect(KITS).toHaveLength(4) }) it('each kit has required fields', () => { for (const kit of KITS) { expect(kit).toHaveProperty('id') expect(kit).toHaveProperty('name') expect(kit).toHaveProperty('price') expect(kit).toHaveProperty('oils') expect(kit.price).toBeGreaterThan(0) expect(kit.oils.length).toBeGreaterThan(0) } }) it('all kits include coconut oil', () => { for (const kit of KITS) { expect(kit.oils).toContain('椰子油') } }) it('kit ids are unique', () => { const ids = KITS.map(k => k.id) expect(new Set(ids).size).toBe(ids.length) }) it('芳香调理 has 9 oils at ¥1575', () => { const kit = KITS.find(k => k.id === 'aroma') expect(kit.price).toBe(1575) expect(kit.oils).toHaveLength(9) }) it('家庭医生 has 11 oils at ¥2250', () => { const kit = KITS.find(k => k.id === 'family') expect(kit.price).toBe(2250) expect(kit.oils).toHaveLength(11) }) it('居家呵护 has 23 oils at ¥3988', () => { const kit = KITS.find(k => k.id === 'home3988') expect(kit.price).toBe(3988) expect(kit.oils).toHaveLength(23) }) it('全精油 has 80 oils at ¥17700 with bottleCount for coconut oil', () => { const kit = KITS.find(k => k.id === 'full') expect(kit.price).toBe(17700) expect(kit.oils.length).toBe(80) expect(kit.bottleCount).toBeDefined() expect(kit.bottleCount['椰子油']).toBeCloseTo(2.57, 2) }) }) // --------------------------------------------------------------------------- // Oil Name Resolution // --------------------------------------------------------------------------- describe('Oil Name Resolution', () => { it('resolves exact match', () => { expect(resolveOilName('薰衣草')).toBe('薰衣草') expect(resolveOilName('乳香')).toBe('乳香') }) it('resolves 牛至 to 西班牙牛至 via endsWith', () => { expect(resolveOilName('牛至')).toBe('西班牙牛至') }) it('does NOT resolve 牛至 to 牛至呵护', () => { expect(resolveOilName('牛至')).not.toBe('牛至呵护') }) it('returns original name for unknown oil', () => { expect(resolveOilName('不存在的油')).toBe('不存在的油') }) }) // --------------------------------------------------------------------------- // Kit Cost Calculation // --------------------------------------------------------------------------- describe('Kit Cost Calculation', () => { it('discount rate is always <= 1 (kit never more expensive than buying individually)', () => { for (const kit of KITS) { const perDrop = calcKitPerDrop(kit) for (const [name, ppd] of Object.entries(perDrop)) { const meta = oils[name] if (!meta || !meta.dropCount) continue const originalPpd = meta.bottlePrice / meta.dropCount expect(ppd).toBeLessThanOrEqual(originalPpd + 0.001) // tiny float tolerance } } }) it('家庭医生 discount is ~32-33%', () => { const kit = KITS.find(k => k.id === 'family') const bc = kit.bottleCount || {} let totalBp = 0 for (const name of kit.oils) { const resolved = resolveOilName(name) const meta = oils[resolved] totalBp += meta ? meta.bottlePrice * (bc[resolved] || 1) : 0 } const totalValue = totalBp + (kit.accessoryValue || 0) const discount = 1 - kit.price / totalValue expect(discount).toBeGreaterThan(0.30) expect(discount).toBeLessThan(0.40) }) it('kit cost for a recipe is less than original cost', () => { const kit = KITS.find(k => k.id === 'family') const perDrop = calcKitPerDrop(kit) // 灰指甲: 西班牙牛至 + 椰子油 const recipe = prodData.recipes.find(r => r.name === '灰指甲') if (recipe && canMakeRecipe(kit, recipe)) { const kitCost = calcRecipeCostWithKit(perDrop, recipe) const origCost = calcOriginalCost(recipe) expect(kitCost).toBeLessThan(origCost) expect(kitCost).toBeGreaterThan(0) } }) it('全精油 bottleCount 2.57 makes coconut oil cheaper per drop than without multiplier', () => { const fullKit = KITS.find(k => k.id === 'full') const perDrop = calcKitPerDrop(fullKit) // With 2.57 bottles, per-drop cost should be roughly 1/2.57 of single-bottle kit cost // at the same discount rate. Just verify it's significantly less than original per-drop. const origPpd = oils['椰子油'].bottlePrice / oils['椰子油'].dropCount expect(perDrop['椰子油']).toBeLessThan(origPpd) expect(perDrop['椰子油']).toBeGreaterThan(0) }) it('accessoryValue reduces effective oil cost', () => { const kit = KITS.find(k => k.id === 'family') // Without accessory: rate = price / totalBp // With accessory: rate = price / (totalBp + accessoryValue) < previous rate expect(kit.accessoryValue).toBeGreaterThan(0) const bc = kit.bottleCount || {} let totalBp = 0 for (const name of kit.oils) { const resolved = resolveOilName(name) const meta = oils[resolved] totalBp += meta ? meta.bottlePrice * (bc[resolved] || 1) : 0 } const rateWithAcc = kit.price / (totalBp + kit.accessoryValue) const rateWithoutAcc = kit.price / totalBp expect(rateWithAcc).toBeLessThan(rateWithoutAcc) }) }) // --------------------------------------------------------------------------- // Recipe Matching // --------------------------------------------------------------------------- describe('Recipe Matching', () => { it('larger kits can make at least as many recipes as smaller ones', () => { const counts = KITS.map(kit => { const matched = prodData.recipes.filter(r => canMakeRecipe(kit, r)) return { id: kit.id, count: matched.length, oilCount: kit.oils.length } }).sort((a, b) => a.oilCount - b.oilCount) for (let i = 1; i < counts.length; i++) { expect(counts[i].count).toBeGreaterThanOrEqual(counts[i - 1].count) } }) it('全精油 can make the most recipes', () => { const fullKit = KITS.find(k => k.id === 'full') const fullCount = prodData.recipes.filter(r => canMakeRecipe(fullKit, r)).length for (const kit of KITS) { if (kit.id === 'full') continue const count = prodData.recipes.filter(r => canMakeRecipe(kit, r)).length expect(fullCount).toBeGreaterThanOrEqual(count) } }) it('灰指甲 (牛至+椰子油) can be made by 家庭医生', () => { const kit = KITS.find(k => k.id === 'family') const recipe = prodData.recipes.find(r => r.name === '灰指甲') expect(canMakeRecipe(kit, recipe)).toBe(true) }) it('recipe requiring 永久花 cannot be made by 芳香调理', () => { const kit = KITS.find(k => k.id === 'aroma') const recipe = prodData.recipes.find(r => r.name === '小v脸') // has 永久花 expect(canMakeRecipe(kit, recipe)).toBe(false) }) }) // --------------------------------------------------------------------------- // Accessory Values — PR32 // --------------------------------------------------------------------------- describe('Accessory Values', () => { it('芳香调理 has no accessories', () => { const kit = KITS.find(k => k.id === 'aroma') expect(kit.accessoryValue).toBeUndefined() }) it('家庭医生 accessories = 475 (香薰机375 + 木盒100)', () => { const kit = KITS.find(k => k.id === 'family') expect(kit.accessoryValue).toBe(475) }) it('居家呵护 accessories = 585 (香薰机375 + 竹木架210)', () => { const kit = KITS.find(k => k.id === 'home3988') expect(kit.accessoryValue).toBe(585) }) it('全精油 accessories = 795 (香薰机375 + 竹木架210×2)', () => { const kit = KITS.find(k => k.id === 'full') expect(kit.accessoryValue).toBe(795) }) }) // --------------------------------------------------------------------------- // Discount Rate Calculation — PR32 // --------------------------------------------------------------------------- describe('Discount Rate', () => { function calcDiscountRate(kit) { const resolved = kit.oils.map(resolveOilName) const bc = kit.bottleCount || {} let totalBP = 0 for (const name of resolved) { const meta = oils[name] totalBP += meta ? meta.bottlePrice * (bc[name] || 1) : 0 } const totalValue = totalBP + (kit.accessoryValue || 0) return totalValue > 0 ? Math.min(kit.price / totalValue, 1) : 1 } it('all kits have discount rate < 1 (套装比单买便宜)', () => { for (const kit of KITS) { const rate = calcDiscountRate(kit) expect(rate).toBeLessThan(1) expect(rate).toBeGreaterThan(0) } }) it('全精油 discount rate ≈ 0.69', () => { const kit = KITS.find(k => k.id === 'full') const rate = calcDiscountRate(kit) expect(rate).toBeGreaterThan(0.65) expect(rate).toBeLessThan(0.75) }) it('larger kits have better discounts', () => { const rates = KITS.map(k => ({ id: k.id, rate: calcDiscountRate(k), count: k.oils.length })) rates.sort((a, b) => a.count - b.count) // Generally larger kits should have lower discount rate (better deal) // At minimum, the largest kit should have a lower rate than the smallest expect(rates[rates.length - 1].rate).toBeLessThanOrEqual(rates[0].rate) }) it('kit cost per recipe should be less than original cost', () => { for (const kit of KITS) { const perDrop = calcKitPerDrop(kit) const recipes = prodData.recipes.filter(r => canMakeRecipe(kit, r)) for (const r of recipes.slice(0, 5)) { const kitCost = calcRecipeCostWithKit(perDrop, r) const originalCost = calcOriginalCost(r) expect(kitCost).toBeLessThanOrEqual(originalCost + 0.01) } } }) })