diff --git a/frontend/cypress/e2e/audit-log-advanced.cy.js b/frontend/cypress/e2e/audit-log-advanced.cy.js index 5663323..5a37549 100644 --- a/frontend/cypress/e2e/audit-log-advanced.cy.js +++ b/frontend/cypress/e2e/audit-log-advanced.cy.js @@ -41,7 +41,7 @@ describe('Audit Log Advanced', () => { const recipeId = createRes.body.id // Check audit log cy.request({ url: '/api/audit-log?limit=5&offset=0', headers: authHeaders }).then(res => { - const entry = res.body.find(e => e.action === 'create_recipe' && e.target_name === 'Cypress审计测试') + const entry = res.body.find(e => e.action === 'share_recipe' && e.target_name === 'Cypress审计测试') expect(entry).to.exist }) // Cleanup diff --git a/frontend/src/__tests__/smartPaste.test.js b/frontend/src/__tests__/smartPaste.test.js index 7e88cdc..a969325 100644 --- a/frontend/src/__tests__/smartPaste.test.js +++ b/frontend/src/__tests__/smartPaste.test.js @@ -6,6 +6,7 @@ import { parseOilChunk, parseSingleBlock, splitRawIntoBlocks, + parseMultiRecipes, OIL_HOMOPHONES, } from '../composables/useSmartPaste' import prodData from './fixtures/production-data.json' @@ -202,22 +203,22 @@ describe('parseOilChunk', () => { expect(result[1]).toEqual({ oil: '永久花', drops: 10 }) }) - it('parses "薰衣草3ml" → [{薰衣草, drops: 60}] (3ml * 20)', () => { + it('parses "薰衣草3ml" → [{薰衣草, drops: 60, _ml: 3}] (3ml * 20)', () => { const result = parseOilChunk('薰衣草3ml', oilNames) expect(result).toHaveLength(1) - expect(result[0]).toEqual({ oil: '薰衣草', drops: 60 }) + expect(result[0]).toMatchObject({ oil: '薰衣草', drops: 60, _ml: 3 }) }) - it('parses "薰衣草5毫升" → [{薰衣草, drops: 100}] (5 * 20)', () => { + it('parses "薰衣草5毫升" → [{薰衣草, drops: 100, _ml: 5}] (5 * 20)', () => { const result = parseOilChunk('薰衣草5毫升', oilNames) expect(result).toHaveLength(1) - expect(result[0]).toEqual({ oil: '薰衣草', drops: 100 }) + expect(result[0]).toMatchObject({ oil: '薰衣草', drops: 100, _ml: 5 }) }) it('parses "薰衣草3ML" → case-insensitive ml', () => { const result = parseOilChunk('薰衣草3ML', oilNames) expect(result).toHaveLength(1) - expect(result[0]).toEqual({ oil: '薰衣草', drops: 60 }) + expect(result[0]).toMatchObject({ oil: '薰衣草', drops: 60, _ml: 3 }) }) it('handles decimal drops "乳香1.5"', () => { @@ -233,10 +234,52 @@ describe('parseOilChunk', () => { expect(result[0]).toEqual({ oil: '薰衣草', drops: 5 }) }) - it('returns empty array for text with no numbers', () => { - // The regex requires a number, so pure text yields nothing + it('parses oil name without number → default 1 drop', () => { const result = parseOilChunk('薰衣草', oilNames) - expect(result).toHaveLength(0) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ oil: '薰衣草', drops: 1 }) + }) + + it('parses multiple oil names without numbers', () => { + const result = parseOilChunk('薰衣草 茶树 乳香', oilNames) + expect(result).toHaveLength(3) + expect(result.map(r => r.oil)).toEqual(['薰衣草', '茶树', '乳香']) + expect(result.every(r => r.drops === 1)).toBe(true) + }) + + it('parses mixed: some with numbers, some without', () => { + const result = parseOilChunk('薰衣草3茶树乳香2', oilNames) + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ oil: '薰衣草', drops: 3 }) + expect(result[1]).toEqual({ oil: '茶树', drops: 1 }) + expect(result[2]).toEqual({ oil: '乳香', drops: 2 }) + }) + + it('parses trailing oil after last number', () => { + const result = parseOilChunk('薰衣草3茶树2乳香', oilNames) + expect(result).toHaveLength(3) + expect(result[2]).toEqual({ oil: '乳香', drops: 1 }) + }) + + it('preserves _ml for ml unit (coconut oil)', () => { + const result = parseOilChunk('椰子油15ml', oilNames) + expect(result).toHaveLength(1) + expect(result[0]._ml).toBe(15) + expect(result[0].drops).toBe(300) + }) + + it('no _ml for drops unit', () => { + const result = parseOilChunk('椰子油15滴', oilNames) + expect(result).toHaveLength(1) + expect(result[0]._ml).toBeUndefined() + expect(result[0].drops).toBe(15) + }) + + it('no _ml for no unit', () => { + const result = parseOilChunk('椰子油15', oilNames) + expect(result).toHaveLength(1) + expect(result[0]._ml).toBeUndefined() + expect(result[0].drops).toBe(15) }) }) @@ -263,7 +306,7 @@ describe('parseSingleBlock', () => { it('handles recipe with no name (all parts have oils)', () => { const result = parseSingleBlock('薰衣草10,茶树5', oilNames) - expect(result.name).toBe('未命名配方') + expect(result.name).toBe('') expect(result.ingredients).toHaveLength(2) }) @@ -370,3 +413,114 @@ describe('OIL_HOMOPHONES', () => { expect(OIL_HOMOPHONES['薰衣草油']).toBe('薰衣草') }) }) + +// --------------------------------------------------------------------------- +// findOil — short string fuzzy match restriction +// --------------------------------------------------------------------------- +describe('findOil — short string protection', () => { + it('does not fuzzy-match 2-char non-oil text', () => { + // 美容 should NOT match any oil via edit distance + expect(findOil('美容', oilNames)).toBeNull() + }) + + it('still matches 2-char exact oil names', () => { + expect(findOil('乳香', oilNames)).toBe('乳香') + expect(findOil('茶树', oilNames)).toBe('茶树') + }) + + it('still matches 2-char homophones', () => { + expect(findOil('如香', oilNames)).toBe('乳香') + }) + + it('still matches 2-char substrings', () => { + // 薄荷 is a substring of 椒样薄荷 etc. + const result = findOil('薄荷', oilNames) + expect(result).not.toBeNull() + }) + + it('fuzzy matches 3+ char inputs via edit distance', () => { + // 永久化 → 永久花 (1 edit) + expect(findOil('永久化', oilNames)).toBe('永久花') + }) +}) + +// --------------------------------------------------------------------------- +// parseMultiRecipes +// --------------------------------------------------------------------------- +describe('parseMultiRecipes', () => { + it('parses single recipe with name', () => { + const results = parseMultiRecipes('助眠,薰衣草3,茶树2', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('助眠') + expect(results[0].ingredients).toHaveLength(2) + }) + + it('splits two recipes by name detection', () => { + const results = parseMultiRecipes('助眠 薰衣草3 茶树2 头疗 柠檬5 椒样薄荷3', oilNames) + expect(results).toHaveLength(2) + expect(results[0].name).toBe('助眠') + expect(results[1].name).toBe('头疗') + }) + + it('splits by blank lines', () => { + const results = parseMultiRecipes('助眠\n薰衣草3\n\n头疗\n柠檬5', oilNames) + expect(results).toHaveLength(2) + expect(results[0].name).toBe('助眠') + expect(results[1].name).toBe('头疗') + }) + + it('splits by semicolons when both sides have oils', () => { + const results = parseMultiRecipes('助眠,薰衣草3,茶树2;头疗,柠檬5', oilNames) + expect(results).toHaveLength(2) + }) + + it('does NOT split by semicolons when one side has no oil', () => { + const results = parseMultiRecipes('助眠;薰衣草3,茶树2', oilNames) + expect(results).toHaveLength(1) + expect(results[0].ingredients.length).toBeGreaterThanOrEqual(2) + }) + + it('handles oils without numbers (default 1 drop)', () => { + const results = parseMultiRecipes('头疗,薰衣草,茶树,乳香', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('头疗') + expect(results[0].ingredients).toHaveLength(3) + expect(results[0].ingredients.every(i => i.drops === 1)).toBe(true) + }) + + it('recognizes non-oil text with number as recipe name (first part)', () => { + // 美容1 is not an oil, should be treated as name "美容" + const results = parseMultiRecipes('美容1 牛至2 乳香3', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('美容') + expect(results[0].ingredients).toHaveLength(2) + }) + + it('returns empty name when no name detected', () => { + const results = parseMultiRecipes('薰衣草3,茶树2', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('') + }) + + it('deduplicates ingredients within a recipe', () => { + const results = parseMultiRecipes('测试 薰衣草3 薰衣草2', oilNames) + expect(results[0].ingredients).toHaveLength(1) + expect(results[0].ingredients[0].drops).toBe(5) + }) + + it('handles coconut oil with ml unit', () => { + const results = parseMultiRecipes('测试 薰衣草3 椰子油15ml', oilNames) + const coco = results[0].ingredients.find(i => i.oil === '椰子油') + expect(coco).toBeTruthy() + expect(coco._ml).toBe(15) + }) + + it('handles complex real-world multi-recipe input', () => { + const input = '美容 牛至2 迷迭香3 乳香4 椰子油15 头疗七八九 檀香3 乳香4 薰衣草3' + const results = parseMultiRecipes(input, oilNames) + expect(results).toHaveLength(2) + expect(results[0].name).toBe('美容') + expect(results[0].ingredients.find(i => i.oil === '椰子油')).toBeTruthy() + expect(results[1].name).toBe('头疗七八九') + }) +})