diff --git a/frontend/src/__tests__/inventoryCommercial.test.js b/frontend/src/__tests__/inventoryCommercial.test.js new file mode 100644 index 0000000..7688cde --- /dev/null +++ b/frontend/src/__tests__/inventoryCommercial.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' + +// --------------------------------------------------------------------------- +// Kit definitions +// --------------------------------------------------------------------------- +describe('Kit definitions', () => { + const KITS = { + family: ['乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '保卫', '牛至', '乐活', '顺畅呼吸', '舒缓'], + home3988: ['乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至', + '西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能'], + aroma: ['薰衣草', '舒缓', '安定情绪', '芳香调理', '野橘', '椒样薄荷', '保卫', '茶树'], + } + + it('family kit has 10 oils', () => { + expect(KITS.family).toHaveLength(10) + }) + + it('home3988 kit has 21 oils', () => { + expect(KITS.home3988).toHaveLength(21) + }) + + it('aroma kit has 8 oils', () => { + expect(KITS.aroma).toHaveLength(8) + expect(KITS.aroma).toContain('薰衣草') + expect(KITS.aroma).toContain('芳香调理') + expect(KITS.aroma).toContain('茶树') + }) +}) + +// --------------------------------------------------------------------------- +// Recipe matching (exclude coconut oil) +// --------------------------------------------------------------------------- +describe('Recipe matching', () => { + function matchRecipe(recipe, ownedSet) { + const needed = recipe.ingredients.filter(i => i.oil !== '椰子油').map(i => i.oil) + if (needed.length === 0) return false + const coverage = needed.filter(o => ownedSet.has(o)).length + return coverage >= 1 + } + + it('matches recipe when user has at least one oil', () => { + const recipe = { ingredients: [{ oil: '乳香', drops: 3 }, { oil: '茶树', drops: 2 }, { oil: '椰子油', drops: 100 }] } + expect(matchRecipe(recipe, new Set(['乳香']))).toBe(true) + }) + + it('does not match when user has none of the oils', () => { + const recipe = { ingredients: [{ oil: '乳香', drops: 3 }, { oil: '茶树', drops: 2 }] } + expect(matchRecipe(recipe, new Set(['薰衣草']))).toBe(false) + }) + + it('excludes coconut oil from matching', () => { + const recipe = { ingredients: [{ oil: '椰子油', drops: 100 }] } + expect(matchRecipe(recipe, new Set(['椰子油']))).toBe(false) + }) + + it('matches when user has all oils', () => { + const recipe = { ingredients: [{ oil: '乳香', drops: 3 }, { oil: '茶树', drops: 2 }] } + expect(matchRecipe(recipe, new Set(['乳香', '茶树']))).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Consumption analysis +// --------------------------------------------------------------------------- +describe('Consumption analysis', () => { + function calcConsumption(ingredients, oilsMeta) { + return ingredients + .filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0) + .map(i => { + const bottleDrops = oilsMeta[i.oil]?.dropCount || 0 + const sessions = bottleDrops > 0 ? Math.floor(bottleDrops / i.drops) : 0 + return { oil: i.oil, drops: i.drops, bottleDrops, sessions } + }) + } + + function findLimit(data) { + const valid = data.filter(c => c.sessions > 0) + if (!valid.length) return { oil: '', sessions: 0, allSame: true } + const min = Math.min(...valid.map(c => c.sessions)) + const allSame = valid.every(c => c.sessions === valid[0].sessions) + const limiting = valid.find(c => c.sessions === min) + return { oil: limiting.oil, sessions: min, allSame } + } + + const meta = { + '芳香调理': { dropCount: 280 }, + '薰衣草': { dropCount: 280 }, + '茶树': { dropCount: 280 }, + } + + it('calculates sessions correctly', () => { + const data = calcConsumption([{ oil: '芳香调理', drops: 10 }], meta) + expect(data[0].sessions).toBe(28) + }) + + it('finds limiting oil', () => { + const data = calcConsumption([ + { oil: '芳香调理', drops: 10 }, + { oil: '薰衣草', drops: 20 }, + ], meta) + const limit = findLimit(data) + expect(limit.oil).toBe('薰衣草') + expect(limit.sessions).toBe(14) + expect(limit.allSame).toBe(false) + }) + + it('detects all same sessions', () => { + const data = calcConsumption([ + { oil: '芳香调理', drops: 10 }, + { oil: '茶树', drops: 10 }, + ], meta) + const limit = findLimit(data) + expect(limit.allSame).toBe(true) + expect(limit.sessions).toBe(28) + }) + + it('excludes coconut oil', () => { + const data = calcConsumption([ + { oil: '芳香调理', drops: 10 }, + { oil: '椰子油', drops: 200 }, + ], meta) + expect(data).toHaveLength(1) + expect(data[0].oil).toBe('芳香调理') + }) +}) + +// --------------------------------------------------------------------------- +// Project pricing persistence +// --------------------------------------------------------------------------- +describe('Project pricing JSON', () => { + it('serializes extra fields to JSON', () => { + const extra = { selling_price: 299, packaging_cost: 5, labor_cost: 30, other_cost: 10, quantity: 1 } + const json = JSON.stringify(extra) + const parsed = JSON.parse(json) + expect(parsed.selling_price).toBe(299) + expect(parsed.quantity).toBe(1) + }) + + it('merges extra fields back into project', () => { + const project = { id: 1, name: 'test', ingredients: [], pricing: '{"selling_price":299,"quantity":1}' } + const extra = JSON.parse(project.pricing) + const merged = { ...project, ...extra } + expect(merged.selling_price).toBe(299) + expect(merged.quantity).toBe(1) + }) +})