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
新增8个测试: - 4个配件价值验证(芳香无配件/家庭475/居家585/全精油795) - 折扣率<1、全精油≈0.69、大套装折扣更好 - 套装成本≤单买成本 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
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)
|
||
}
|
||
}
|
||
})
|
||
})
|