Files
oil-formula-calculator/frontend/src/__tests__/kitCost.test.js
Hera Zhao b1df23c04b
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Failing after 3m8s
test: PR32测试 — 配件价值、折扣率、套装成本低于单买
新增8个测试:
- 4个配件价值验证(芳香无配件/家庭475/居家585/全精油795)
- 折扣率<1、全精油≈0.69、大套装折扣更好
- 套装成本≤单买成本

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:16:48 +00:00

331 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
})
})