diff --git a/frontend/cypress/e2e/kit-export.cy.js b/frontend/cypress/e2e/kit-export.cy.js new file mode 100644 index 0000000..03eac9b --- /dev/null +++ b/frontend/cypress/e2e/kit-export.cy.js @@ -0,0 +1,137 @@ +describe('Kit Export Feature', () => { + let adminToken + let authHeaders + + before(() => { + cy.getAdminToken().then(token => { + adminToken = token + authHeaders = { Authorization: `Bearer ${token}` } + }) + }) + + describe('API: recipes and oils available', () => { + it('loads oils list', () => { + cy.request({ url: '/api/oils', headers: authHeaders }).then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + expect(res.body.length).to.be.greaterThan(50) + }) + }) + + it('loads recipes list', () => { + cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + expect(res.body.length).to.be.greaterThan(10) + }) + }) + }) + + describe('UI: Projects page access', () => { + it('shows login prompt when not logged in', () => { + cy.visit('/projects') + cy.contains('登录', { timeout: 10000 }).should('be.visible') + }) + + it('shows kit compare button when logged in as admin', () => { + cy.visit('/projects', { + onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } + }) + cy.contains('套装方案对比', { timeout: 10000 }).should('be.visible') + }) + }) + + describe('UI: Kit Export page', () => { + beforeEach(() => { + cy.visit('/kit-export', { + onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } + }) + }) + + it('loads with 4 kit cards', () => { + cy.get('.kit-card', { timeout: 10000 }).should('have.length', 4) + }) + + it('shows kit names and prices', () => { + cy.contains('芳香调理套装').should('be.visible') + cy.contains('家庭医生套装').should('be.visible') + cy.contains('居家呵护套装').should('be.visible') + cy.contains('全精油套装').should('be.visible') + cy.contains('¥1575').should('be.visible') + cy.contains('¥2250').should('be.visible') + cy.contains('¥3988').should('be.visible') + cy.contains('¥17700').should('be.visible') + }) + + it('shows recipe count for each kit', () => { + cy.get('.kit-recipe-count').should('have.length', 4) + cy.get('.kit-recipe-count').each($el => { + expect($el.text()).to.match(/可做 \d+ 个配方/) + }) + }) + + it('clicking a kit card shows its recipes', () => { + cy.get('.kit-card').eq(1).click() + cy.get('.kit-detail', { timeout: 5000 }).should('be.visible') + cy.get('.recipe-table').should('exist') + }) + + it('recipe table has cost and profit columns', () => { + cy.get('.kit-card').first().click() + cy.get('.recipe-table', { timeout: 5000 }).within(() => { + cy.contains('th', '套装成本').should('exist') + cy.contains('th', '原价成本').should('exist') + cy.contains('th', '售价').should('exist') + cy.contains('th', '利润率').should('exist') + cy.contains('th', '可做次数').should('exist') + }) + }) + + it('shows cross comparison section', () => { + cy.get('.cross-section', { timeout: 10000 }).should('be.visible') + cy.get('.cross-table').should('exist') + }) + + it('cross comparison has single-buy column', () => { + cy.get('.cross-table', { timeout: 10000 }).within(() => { + cy.contains('th', '单买').should('exist') + }) + }) + + it('cross comparison shows staircase pattern (available cells have green bg)', () => { + cy.get('.td-kit-available', { timeout: 10000 }).should('have.length.greaterThan', 0) + cy.get('.td-kit-na').should('have.length.greaterThan', 0) + }) + + it('kit cost is always <= original cost in recipe table', () => { + cy.get('.kit-card').first().click() + cy.get('.recipe-table tbody tr', { timeout: 5000 }).each($row => { + const cells = $row.find('td') + // td[1] = 可做次数, td[2] = 套装成本, td[3] = 原价成本 + const kitCostText = cells.eq(2).text().replace(/[¥,\s]/g, '') + const origCostText = cells.eq(3).text().replace(/[¥,\s]/g, '') + const kitCost = parseFloat(kitCostText) + const origCost = parseFloat(origCostText) + if (!isNaN(kitCost) && !isNaN(origCost)) { + expect(kitCost).to.be.at.most(origCost + 0.01) + } + }) + }) + + it('export buttons exist', () => { + cy.contains('button', '导出完整版').should('be.visible') + cy.contains('button', '导出简版').should('be.visible') + }) + + it('shows volume info next to recipe name', () => { + cy.get('.td-volume', { timeout: 10000 }).should('have.length.greaterThan', 0) + }) + }) + + describe('UI: Kit Export access control', () => { + it('redirects to /projects when not logged in', () => { + cy.visit('/kit-export') + cy.url({ timeout: 10000 }).should('include', '/projects') + }) + }) +}) diff --git a/frontend/src/__tests__/kitCost.test.js b/frontend/src/__tests__/kitCost.test.js new file mode 100644 index 0000000..8d114a3 --- /dev/null +++ b/frontend/src/__tests__/kitCost.test.js @@ -0,0 +1,253 @@ +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 22 oils at ¥3988', () => { + const kit = KITS.find(k => k.id === 'home3988') + expect(kit.price).toBe(3988) + expect(kit.oils).toHaveLength(22) + }) + + 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) + }) +})