import { describe, it, expect } from 'vitest' import { recipeNameEn, oilEn } from '../composables/useOilTranslation' import { matchesPinyinInitials, getPinyinInitials, pinyinMatchScore } from '../composables/usePinyinMatch' // --------------------------------------------------------------------------- // EDITOR_ONLY_TAGS includes '已下架' // --------------------------------------------------------------------------- describe('EDITOR_ONLY_TAGS', () => { it('includes 已审核', async () => { const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') expect(EDITOR_ONLY_TAGS).toContain('已审核') }) it('includes 已下架', async () => { const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') expect(EDITOR_ONLY_TAGS).toContain('已下架') }) it('is an array with at least 2 entries', async () => { const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') expect(Array.isArray(EDITOR_ONLY_TAGS)).toBe(true) expect(EDITOR_ONLY_TAGS.length).toBeGreaterThanOrEqual(2) }) }) // --------------------------------------------------------------------------- // English drop/drops pluralization logic // --------------------------------------------------------------------------- describe('drop/drops pluralization', () => { const pluralize = (n) => (n === 1 ? 'drop' : 'drops') it('singular: 1 drop', () => { expect(pluralize(1)).toBe('drop') }) it('plural: 0 drops', () => { expect(pluralize(0)).toBe('drops') }) it('plural: 2 drops', () => { expect(pluralize(2)).toBe('drops') }) it('plural: 5 drops', () => { expect(pluralize(5)).toBe('drops') }) }) // --------------------------------------------------------------------------- // 已下架 tag filtering logic (pure function extraction) // --------------------------------------------------------------------------- describe('已下架 tag filtering', () => { const recipes = [ { name: 'Active Recipe', tags: ['头疗'] }, { name: 'Delisted Recipe', tags: ['已下架'] }, { name: 'No Tags Recipe', tags: [] }, { name: 'Multi Tag', tags: ['热门', '已下架'] }, { name: 'Null Tags', tags: null }, ] const filterDelisted = (list) => list.filter((r) => !r.tags || !r.tags.includes('已下架')) it('removes recipes with 已下架 tag', () => { const result = filterDelisted(recipes) expect(result.map((r) => r.name)).not.toContain('Delisted Recipe') expect(result.map((r) => r.name)).not.toContain('Multi Tag') }) it('keeps recipes without 已下架 tag', () => { const result = filterDelisted(recipes) expect(result.map((r) => r.name)).toContain('Active Recipe') expect(result.map((r) => r.name)).toContain('No Tags Recipe') }) it('handles null tags gracefully', () => { const result = filterDelisted(recipes) expect(result.map((r) => r.name)).toContain('Null Tags') }) it('returns empty array for all-delisted list', () => { const all = [ { name: 'A', tags: ['已下架'] }, { name: 'B', tags: ['已下架', '其他'] }, ] expect(filterDelisted(all)).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // recipeNameEn — front-end keyword translation // --------------------------------------------------------------------------- describe('recipeNameEn', () => { it('translates 酸痛包 → Pain Relief Blend', () => { expect(recipeNameEn('酸痛包')).toBe('Pain Relief Blend') }) it('translates 助眠配方 → Sleep Aid Blend', () => { expect(recipeNameEn('助眠配方')).toBe('Sleep Aid Blend') }) it('translates 头痛 → Headache', () => { expect(recipeNameEn('头痛')).toBe('Headache') }) it('translates 肩颈按摩 → Neck & Shoulder Massage', () => { expect(recipeNameEn('肩颈按摩')).toBe('Neck & Shoulder Massage') }) it('translates 湿疹舒缓 → Eczema Soothing', () => { expect(recipeNameEn('湿疹舒缓')).toBe('Eczema Soothing') }) it('translates 淋巴排毒 → Lymph Detox', () => { expect(recipeNameEn('淋巴排毒')).toBe('Lymph Detox') }) it('translates 灰指甲 → Nail Fungus', () => { expect(recipeNameEn('灰指甲')).toBe('Nail Fungus') }) it('translates 缓解焦虑 → Relief Anxiety', () => { expect(recipeNameEn('缓解焦虑')).toBe('Relief Anxiety') }) it('returns original name for unknown text', () => { expect(recipeNameEn('XYZXYZ')).toBe('XYZXYZ') }) it('returns empty/null for empty/null input', () => { expect(recipeNameEn('')).toBe('') expect(recipeNameEn(null)).toBeNull() }) it('does not duplicate keywords', () => { // 酸痛 maps to Pain Relief; should not appear twice const result = recipeNameEn('酸痛酸痛') expect(result).toBe('Pain Relief') }) }) // --------------------------------------------------------------------------- // Duplicate oil prevention logic // --------------------------------------------------------------------------- describe('duplicate oil prevention', () => { it('detects duplicate oil in ingredient list', () => { const ings = [ { oil: '薰衣草', drops: 3 }, { oil: '茶树', drops: 2 }, ] const newOil = '薰衣草' const isDup = ings.some(i => i.oil === newOil) expect(isDup).toBe(true) }) it('allows non-duplicate oil', () => { const ings = [ { oil: '薰衣草', drops: 3 }, { oil: '茶树', drops: 2 }, ] const newOil = '乳香' const isDup = ings.some(i => i.oil === newOil) expect(isDup).toBe(false) }) it('allows same oil for the same row (editing current)', () => { const ing = { oil: '薰衣草', drops: 3 } const ings = [ing, { oil: '茶树', drops: 2 }] // When selecting for the same row, exclude self const isDup = ings.some(i => i !== ing && i.oil === '薰衣草') expect(isDup).toBe(false) }) it('handles empty ingredient list (no duplicates)', () => { const ings = [] const isDup = ings.some(i => i.oil === '薰衣草') expect(isDup).toBe(false) }) }) // --------------------------------------------------------------------------- // recipeNameEn — additional edge cases for PR28 // --------------------------------------------------------------------------- describe('recipeNameEn — PR28 additional cases', () => { it('translates 排毒配方 → Detox Blend', () => { expect(recipeNameEn('排毒配方')).toBe('Detox Blend') }) it('translates 呼吸系统护理 → Respiratory System Care', () => { expect(recipeNameEn('呼吸系统护理')).toBe('Respiratory System Care') }) it('translates 儿童助眠 → Children\'s Sleep Aid', () => { expect(recipeNameEn('儿童助眠')).toBe("Children's Sleep Aid") }) it('translates 美容按摩 → Beauty Massage', () => { expect(recipeNameEn('美容按摩')).toBe('Beauty Massage') }) it('handles mixed Chinese and ASCII text', () => { // Unknown Chinese chars are skipped; if ASCII appears, it's kept const result = recipeNameEn('testBlend') // No Chinese keyword matches, falls back to original expect(result).toBe('testBlend') }) it('handles single-keyword name', () => { expect(recipeNameEn('免疫')).toBe('Immunity') }) it('translates compound: 肩颈按摩配方 → Neck & Shoulder Massage Blend', () => { expect(recipeNameEn('肩颈按摩配方')).toBe('Neck & Shoulder Massage Blend') }) }) // --------------------------------------------------------------------------- // oilEn — English oil name translation // --------------------------------------------------------------------------- describe('oilEn', () => { it('translates known oils', () => { expect(oilEn('薰衣草')).toBe('Lavender') expect(oilEn('茶树')).toBe('Tea Tree') expect(oilEn('乳香')).toBe('Frankincense') }) it('handles 复方 suffix removal', () => { expect(oilEn('舒缓复方')).toBe('Past Tense') }) it('handles 复方 suffix addition', () => { // '呼吸' maps via '呼吸复方' → 'Breathe' expect(oilEn('呼吸')).toBe('Breathe') }) it('returns empty string for unknown oil', () => { expect(oilEn('不存在的油')).toBe('') }) }) // --------------------------------------------------------------------------- // Case-insensitive username logic (pure function) // --------------------------------------------------------------------------- describe('case-insensitive username matching', () => { const matchCaseInsensitive = (input, existing) => existing.some(u => u.toLowerCase() === input.toLowerCase()) it('detects duplicate usernames case-insensitively', () => { const existing = ['TestUser', 'Alice', 'Bob'] expect(matchCaseInsensitive('testuser', existing)).toBe(true) expect(matchCaseInsensitive('TESTUSER', existing)).toBe(true) expect(matchCaseInsensitive('TestUser', existing)).toBe(true) }) it('allows unique username', () => { const existing = ['TestUser', 'Alice'] expect(matchCaseInsensitive('Charlie', existing)).toBe(false) }) it('is case-insensitive for mixed-case inputs', () => { const existing = ['alice'] expect(matchCaseInsensitive('Alice', existing)).toBe(true) expect(matchCaseInsensitive('ALICE', existing)).toBe(true) expect(matchCaseInsensitive('aLiCe', existing)).toBe(true) }) }) // --------------------------------------------------------------------------- // One-time username change logic // --------------------------------------------------------------------------- describe('one-time username change guard', () => { it('blocks rename when username_changed is truthy', () => { const user = { username_changed: 1 } expect(!!user.username_changed).toBe(true) }) it('allows rename when username_changed is falsy', () => { const user = { username_changed: 0 } expect(!!user.username_changed).toBe(false) }) it('allows rename when username_changed is undefined', () => { const user = {} expect(!!user.username_changed).toBe(false) }) }) // --------------------------------------------------------------------------- // Pinyin matching — PR29 extended coverage // --------------------------------------------------------------------------- describe('pinyin matching — extended oil names', () => { it('matches mlk → 麦卢卡', () => { expect(matchesPinyinInitials('麦卢卡', 'mlk')).toBe(true) }) it('matches tx → 檀香', () => { expect(matchesPinyinInitials('檀香', 'tx')).toBe(true) }) it('matches xm → 香茅', () => { expect(matchesPinyinInitials('香茅', 'xm')).toBe(true) }) it('matches gbxz → 古巴香脂', () => { expect(matchesPinyinInitials('古巴香脂', 'gbxz')).toBe(true) }) it('matches my → 没药', () => { expect(matchesPinyinInitials('没药', 'my')).toBe(true) }) it('matches xhx → 小茴香', () => { expect(matchesPinyinInitials('小茴香', 'xhx')).toBe(true) }) it('matches jybh → 椒样薄荷', () => { expect(matchesPinyinInitials('椒样薄荷', 'jybh')).toBe(true) }) it('matches xbynz → 西班牙牛至', () => { expect(matchesPinyinInitials('西班牙牛至', 'xbynz')).toBe(true) }) it('matches sc → 顺畅呼吸 prefix', () => { expect(matchesPinyinInitials('顺畅呼吸', 'sc')).toBe(true) }) it('does not match wrong initials', () => { expect(matchesPinyinInitials('麦卢卡', 'abc')).toBe(false) }) it('getPinyinInitials returns correct string', () => { expect(getPinyinInitials('麦卢卡')).toBe('mlk') expect(getPinyinInitials('檀香')).toBe('tx') expect(getPinyinInitials('没药')).toBe('my') }) }) // --------------------------------------------------------------------------- // Viewer tag visibility — PR29 // --------------------------------------------------------------------------- describe('viewer tag visibility logic', () => { const EDITOR_ONLY_TAGS_VAL = ['已审核', '已下架'] it('editor sees all tags', () => { const allTags = ['美容', '儿童', '已审核', '已下架'] const canEdit = true const visible = canEdit ? allTags : [] expect(visible).toEqual(allTags) }) it('viewer sees no public tags', () => { const canEdit = false const myDiary = [ { tags: ['我的标签'] }, { tags: ['我的标签', '另一个'] }, ] // Viewer: collect tags from own diary only const myTags = new Set() for (const d of myDiary) { for (const t of (d.tags || [])) myTags.add(t) } const visible = canEdit ? ['美容', '已审核'] : [...myTags] expect(visible).toContain('我的标签') expect(visible).toContain('另一个') expect(visible).not.toContain('美容') expect(visible).not.toContain('已审核') }) it('viewer with no diary tags sees empty', () => { const myDiary = [] const myTags = new Set() for (const d of myDiary) { for (const t of (d.tags || [])) myTags.add(t) } expect([...myTags]).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // PR30: Pinyin subsequence matching + pinyinMatchScore // --------------------------------------------------------------------------- describe('pinyin subsequence matching — PR30', () => { it('js matches 紧致霜 via subsequence', () => { expect(matchesPinyinInitials('新瑞活力身体紧致霜', 'js')).toBe(true) }) it('prefix match scores 0', () => { expect(pinyinMatchScore('麦卢卡', 'mlk')).toBe(0) }) it('substring match scores 1', () => { expect(pinyinMatchScore('椒样薄荷', 'ybh')).toBe(1) }) it('subsequence match scores 2', () => { expect(pinyinMatchScore('新瑞活力身体紧致霜', 'js')).toBe(2) }) it('no match scores -1', () => { expect(pinyinMatchScore('薰衣草', 'zz')).toBe(-1) }) it('product names have pinyin', () => { expect(getPinyinInitials('身体紧致霜')).toBe('stjzs') expect(getPinyinInitials('深层净肤面膜')).toBe('scjfmm') expect(getPinyinInitials('青春无龄保湿霜')).toBe('qcwlbss') }) }) // --------------------------------------------------------------------------- // PR30: Unit system (drop/ml/g/capsule) // --------------------------------------------------------------------------- describe('unit system — PR30', () => { const UNIT_LABELS = { drop: { zh: '滴' }, ml: { zh: 'ml' }, g: { zh: 'g' }, capsule: { zh: '颗' }, } it('maps unit to correct label', () => { expect(UNIT_LABELS['drop'].zh).toBe('滴') expect(UNIT_LABELS['ml'].zh).toBe('ml') expect(UNIT_LABELS['g'].zh).toBe('g') expect(UNIT_LABELS['capsule'].zh).toBe('颗') }) it('volume display priority: stored > calculated > product sum', () => { // Stored volume takes priority const recipe1 = { volume: 'single', ingredients: [{ oil: '椰子油', drops: 96 }] } const vol1 = recipe1.volume === 'single' ? '单次' : '' expect(vol1).toBe('单次') // No stored volume, has coconut oil → calculate const recipe2 = { volume: '', ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '椰子油', drops: 90 }] } const total = recipe2.ingredients.reduce((s, i) => s + i.drops, 0) const ml = Math.round(total / 18.6) expect(ml).toBe(5) // No coconut oil, has product → show product volume const recipe3 = { volume: '', ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '玫瑰护手霜', drops: 30 }] } const hasProduct = recipe3.ingredients.some(i => i.oil === '玫瑰护手霜') expect(hasProduct).toBe(true) }) }) // --------------------------------------------------------------------------- // PR31: Retail price column alignment logic // --------------------------------------------------------------------------- describe('retail price column alignment — PR31', () => { function hasAnyRetail(ingredients, retailMap) { return ingredients.some(ing => retailMap[ing.oil] && retailMap[ing.oil] > 0) } it('shows retail column when at least one ingredient has retail price', () => { const ings = [{ oil: '薰衣草', drops: 3 }, { oil: '无香乳液', drops: 30 }] const retailMap = { '薰衣草': 0.94, '无香乳液': 0 } expect(hasAnyRetail(ings, retailMap)).toBe(true) }) it('hides retail column when no ingredient has retail price', () => { const ings = [{ oil: '无香乳液', drops: 30 }, { oil: '玫瑰护手霜', drops: 20 }] const retailMap = { '无香乳液': 0, '玫瑰护手霜': 0 } expect(hasAnyRetail(ings, retailMap)).toBe(false) }) it('all rows render when column is shown (empty string for missing retail)', () => { const ings = [{ oil: '薰衣草', drops: 3 }, { oil: '无香乳液', drops: 30 }] const retailMap = { '薰衣草': 0.94, '无香乳液': 0 } const showColumn = hasAnyRetail(ings, retailMap) expect(showColumn).toBe(true) const values = ings.map(i => retailMap[i.oil] > 0 ? `¥${(retailMap[i.oil] * i.drops).toFixed(2)}` : '') expect(values[0]).toBe('¥2.82') expect(values[1]).toBe('') }) }) // --------------------------------------------------------------------------- // PR31: Volume field in recipe store mapping // --------------------------------------------------------------------------- describe('volume field in recipe mapping — PR31', () => { it('maps volume from API response', () => { const apiRecipe = { id: 1, name: 'test', volume: 'single', ingredients: [], tags: [] } const mapped = { volume: apiRecipe.volume || '' } expect(mapped.volume).toBe('single') }) it('defaults to empty string when volume is null', () => { const apiRecipe = { id: 1, name: 'test', volume: null, ingredients: [], tags: [] } const mapped = { volume: apiRecipe.volume || '' } expect(mapped.volume).toBe('') }) it('volume values map to correct display labels', () => { const labels = { 'single': '单次', '5': '5ml', '10': '10ml', '15': '15ml', '': '' } expect(labels['single']).toBe('单次') expect(labels['5']).toBe('5ml') expect(labels['']).toBe('') }) }) // --------------------------------------------------------------------------- // PR33: Oil card branding logic // --------------------------------------------------------------------------- describe('oil card branding — PR33', () => { it('brand data determines card display elements', () => { const brand = { qr_code: 'data:image/png;base64,abc', brand_bg: 'data:image/png;base64,bg', brand_logo: null, brand_name: '测试品牌', brand_align: 'center' } expect(!!brand.qr_code).toBe(true) expect(!!brand.brand_bg).toBe(true) expect(!!brand.brand_logo).toBe(false) expect(!!brand.brand_name).toBe(true) }) it('empty brand shows plain card', () => { const brand = {} expect(!!brand.qr_code).toBe(false) expect(!!brand.brand_bg).toBe(false) expect(!!brand.brand_logo).toBe(false) }) it('volumeLabel with name parameter works for drops and ml', () => { // Simulates the fix: volumeLabel(dropCount, name) needs both params const DROPS_TO_VOLUME = { 93: '5ml', 280: '15ml' } function volumeLabel(dropCount, name) { if (name === '无香乳液') return dropCount + 'ml' // ml unit return DROPS_TO_VOLUME[dropCount] || (dropCount + '滴') } expect(volumeLabel(280, '薰衣草')).toBe('15ml') expect(volumeLabel(200, '无香乳液')).toBe('200ml') expect(volumeLabel(93, '茶树')).toBe('5ml') }) it('PDF export price unit adapts to product type', () => { function oilPriceUnit(name) { if (name === '无香乳液') return 'ml' if (name === '植物空胶囊') return '颗' return '滴' } expect(oilPriceUnit('薰衣草')).toBe('滴') expect(oilPriceUnit('无香乳液')).toBe('ml') expect(oilPriceUnit('植物空胶囊')).toBe('颗') }) }) // --------------------------------------------------------------------------- // PR34: Product edit UI — unit-based form switching // --------------------------------------------------------------------------- describe('product edit UI logic — PR34', () => { it('drop unit shows standard volume selector', () => { const unit = 'drop' expect(unit === 'drop').toBe(true) }) it('non-drop unit shows amount + unit selector', () => { for (const u of ['ml', 'g', 'capsule']) { expect(u !== 'drop').toBe(true) } }) it('edit form initializes correct unit from meta', () => { const meta = { unit: 'g', dropCount: 80 } const editUnit = meta.unit || 'drop' const editProductAmount = editUnit !== 'drop' ? meta.dropCount : null const editProductUnit = editUnit !== 'drop' ? editUnit : 'ml' expect(editUnit).toBe('g') expect(editProductAmount).toBe(80) expect(editProductUnit).toBe('g') }) it('edit form defaults to drop for oils', () => { const meta = { unit: 'drop', dropCount: 280 } const editUnit = meta.unit || 'drop' expect(editUnit).toBe('drop') }) it('edit form defaults to drop when unit is undefined', () => { const meta = { dropCount: 280 } const editUnit = meta.unit || 'drop' expect(editUnit).toBe('drop') }) it('save uses product amount and unit for non-drop', () => { const editUnit = 'ml' const editProductAmount = 200 const editProductUnit = 'ml' const dropCount = 280 // from standard volume selector const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount const finalUnit = editUnit !== 'drop' ? editProductUnit : null expect(finalDropCount).toBe(200) expect(finalUnit).toBe('ml') }) it('save uses standard drop count for oils', () => { const editUnit = 'drop' const editProductAmount = null const dropCount = 280 const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount const finalUnit = editUnit !== 'drop' ? 'ml' : null expect(finalDropCount).toBe(280) expect(finalUnit).toBeNull() }) it('label adapts: 精油名称 for oils, 产品名称 for products', () => { const labelForDrop = 'drop' === 'drop' ? '精油名称' : '产品名称' const labelForMl = 'ml' === 'drop' ? '精油名称' : '产品名称' expect(labelForDrop).toBe('精油名称') expect(labelForMl).toBe('产品名称') }) }) // --------------------------------------------------------------------------- // PR34: Share text and consumption analysis use dynamic unit // --------------------------------------------------------------------------- describe('share text and consumption use dynamic unit — PR34', () => { const UNIT_MAP = { drop: '滴', ml: 'ml', g: 'g', capsule: '颗' } function unitLabel(name, unitMap) { return UNIT_MAP[unitMap[name] || 'drop'] } it('share text uses unitLabel for each ingredient', () => { const units = { '薰衣草': 'drop', '无香乳液': 'ml', '植物空胶囊': 'capsule' } const ings = [ { oil: '薰衣草', drops: 3 }, { oil: '无香乳液', drops: 30 }, { oil: '植物空胶囊', drops: 2 }, ] const lines = ings.map(i => `${i.oil} ${i.drops}${unitLabel(i.oil, units)}`) expect(lines[0]).toBe('薰衣草 3滴') expect(lines[1]).toBe('无香乳液 30ml') expect(lines[2]).toBe('植物空胶囊 2颗') }) it('consumption analysis uses unitLabel per oil', () => { const units = { '薰衣草': 'drop', '活力磨砂膏': 'g' } const data = [ { oil: '薰衣草', drops: 15, bottleDrops: 280 }, { oil: '活力磨砂膏', drops: 30, bottleDrops: 70 }, ] const display = data.map(c => ({ usage: `${c.drops}${unitLabel(c.oil, units)}`, capacity: `${c.bottleDrops}${unitLabel(c.oil, units)}`, })) expect(display[0].usage).toBe('15滴') expect(display[0].capacity).toBe('280滴') expect(display[1].usage).toBe('30g') expect(display[1].capacity).toBe('70g') }) })