diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 988264e..3482f11 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -12,7 +12,7 @@ jobs: e2e-test: runs-on: test needs: unit-test - timeout-minutes: 8 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -62,23 +62,37 @@ jobs: exit 1 fi - # Run all specs except demo-walkthrough (too slow for CI) + # Run all specs in 3 batches to avoid Electron memory crashes cd frontend - timeout 420 npx cypress run \ - --spec "cypress/e2e/!(demo-walkthrough).cy.js" \ - --config "video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0" \ - --env "ADMIN_TOKEN=$ADMIN_TOKEN" - EXIT_CODE=$? + CYPRESS_CFG="video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0" + + echo "=== Batch 1: API & data tests ===" + timeout 300 npx cypress run \ + --spec "cypress/e2e/api-crud.cy.js,cypress/e2e/api-health.cy.js,cypress/e2e/oil-data-integrity.cy.js,cypress/e2e/recipe-cost-parity.cy.js,cypress/e2e/endpoint-parity.cy.js,cypress/e2e/registration-flow.cy.js,cypress/e2e/pr27-features.cy.js,cypress/e2e/kit-export.cy.js" \ + --config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN" + B1=$? + + echo "=== Batch 2: UI flow tests ===" + timeout 300 npx cypress run \ + --spec "cypress/e2e/auth-flow.cy.js,cypress/e2e/admin-flow.cy.js,cypress/e2e/navigation.cy.js,cypress/e2e/recipe-detail.cy.js,cypress/e2e/recipe-search.cy.js,cypress/e2e/manage-recipes.cy.js,cypress/e2e/diary-flow.cy.js,cypress/e2e/favorites.cy.js,cypress/e2e/inventory-flow.cy.js" \ + --config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN" + B2=$? + + echo "=== Batch 3: Remaining tests ===" + timeout 300 npx cypress run \ + --spec "cypress/e2e/app-load.cy.js,cypress/e2e/account-settings.cy.js,cypress/e2e/audit-log-advanced.cy.js,cypress/e2e/batch-operations.cy.js,cypress/e2e/bug-tracker-flow.cy.js,cypress/e2e/category-modules.cy.js,cypress/e2e/notification-flow.cy.js,cypress/e2e/oil-reference.cy.js,cypress/e2e/performance.cy.js,cypress/e2e/price-display.cy.js,cypress/e2e/projects-flow.cy.js,cypress/e2e/responsive.cy.js,cypress/e2e/search-advanced.cy.js,cypress/e2e/user-management-flow.cy.js,cypress/e2e/visual-check.cy.js,cypress/e2e/demo-walkthrough.cy.js" \ + --config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN" + B3=$? # Cleanup kill $BE_PID $FE_PID 2>/dev/null pkill -f "Cypress" 2>/dev/null || true rm -f "$DB_FILE" - if [ $EXIT_CODE -eq 124 ]; then - echo "ERROR: Cypress timed out after 7 minutes" + + echo "Results: Batch1=$B1 Batch2=$B2 Batch3=$B3" + if [ $B1 -ne 0 ] || [ $B2 -ne 0 ] || [ $B3 -ne 0 ]; then exit 1 fi - exit $EXIT_CODE build-check: runs-on: test diff --git a/frontend/cypress/e2e/demo-walkthrough.cy.js b/frontend/cypress/e2e/demo-walkthrough.cy.js index ad9c2c2..8bc3a2d 100644 --- a/frontend/cypress/e2e/demo-walkthrough.cy.js +++ b/frontend/cypress/e2e/demo-walkthrough.cy.js @@ -5,82 +5,26 @@ describe('doTERRA 精油配方计算器 - 功能演示', () => { cy.getAdminToken().then(token => { adminToken = token }) }) - it('完整功能演示', { defaultCommandTimeout: 20000 }, () => { - // ===== 开场:首页加载 ===== - cy.visit('/', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', adminToken) - } - }) - cy.get('.app-header').should('be.visible') - cy.wait(1000) - - // ===== 配方卡片列表 ===== - cy.get('.recipe-card', { timeout: 15000 }).should('have.length.gte', 1) - cy.wait(500) - - // ===== 搜索框输入 ===== - cy.get('input[placeholder*="搜索"]').should('be.visible').click() - cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 100 }) - cy.wait(500) - cy.get('input[placeholder*="搜索"]').clear() - cy.wait(500) - - // ===== 点击配方卡片 ===== - cy.get('.recipe-card', { timeout: 10000 }).first().click() - cy.wait(1000) - - // ===== 查看详情 ===== - cy.get('[class*="overlay"], [class*="detail"]', { timeout: 10000 }).should('be.visible') - cy.get('.detail-close-btn').first().click({ force: true }) - cy.wait(500) - - // ===== 切换精油价目 ===== - cy.get('.nav-tab').contains('精油价目').click() - cy.wait(1000) - - // ===== 精油页面 ===== - cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1) - - // ===== 管理配方 ===== - cy.get('.nav-tab').contains('管理配方').click() - cy.wait(1000) - - // ===== 个人库存 ===== - cy.get('.nav-tab').contains('个人库存').click() - cy.wait(1000) - - // ===== Admin pages via direct URL ===== - cy.visit('/audit', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', adminToken) - } - }) - cy.contains('操作日志', { timeout: 10000 }).should('be.visible') - cy.wait(500) - - cy.visit('/bugs', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', adminToken) - } - }) - cy.contains('Bug', { timeout: 10000 }).should('be.visible') - cy.wait(500) - - cy.visit('/users', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', adminToken) - } - }) - cy.contains('用户管理', { timeout: 10000 }).should('be.visible') - cy.wait(500) - - // ===== 回到首页 ===== - cy.visit('/', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', adminToken) - } - }) + it('首页和搜索', { defaultCommandTimeout: 10000 }, () => { + cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) + cy.get('input[placeholder*="搜索"]').type('薰衣草') + cy.get('input[placeholder*="搜索"]').clear() + cy.get('.recipe-card').should('have.length.gte', 1) + }) + + it('页面导航', { defaultCommandTimeout: 10000 }, () => { + cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) + cy.get('.nav-tab').contains('精油价目').click() + cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1) + cy.get('.nav-tab').contains('管理配方').click() + cy.get('.nav-tab').contains('个人库存').click() + }) + + it('管理页面可访问', { defaultCommandTimeout: 10000 }, () => { + cy.visit('/audit', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) + cy.contains('操作日志', { timeout: 10000 }).should('be.visible') + cy.visit('/users', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) + cy.contains('用户管理', { timeout: 10000 }).should('be.visible') }) }) diff --git a/frontend/cypress/e2e/visual-check.cy.js b/frontend/cypress/e2e/visual-check.cy.js index 74aaa1c..e605cff 100644 --- a/frontend/cypress/e2e/visual-check.cy.js +++ b/frontend/cypress/e2e/visual-check.cy.js @@ -1,57 +1,44 @@ -describe('Visual Check - Screenshots', () => { +describe('Visual Check', () => { let adminToken before(() => { cy.getAdminToken().then(token => { adminToken = token }) }) - it('homepage with recipes', () => { + it('homepage loads with recipes', () => { cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) - cy.wait(1000) - cy.screenshot('01-homepage') }) - it('recipe detail overlay', () => { - cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) - cy.get('.recipe-card', { timeout: 10000 }).first().click() - cy.wait(1000) - cy.screenshot('02-recipe-detail') - }) - - it('oil reference page', () => { + it('oil reference loads with chips', () => { cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1) - cy.wait(500) - cy.screenshot('03-oil-reference') }) - it('manage recipes page', () => { + it('manage recipes page loads', () => { cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) - cy.wait(2000) - cy.screenshot('04-manage-recipes') + cy.get('.recipe-manager', { timeout: 10000 }).should('exist') }) - it('inventory page', () => { + it('inventory page loads', () => { cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) - cy.wait(1500) - cy.screenshot('05-inventory') + cy.get('.inventory-page', { timeout: 10000 }).should('exist') }) - it('check if recipe cards show price > 0', () => { + it('recipe cards show price > 0', () => { cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) - // Check if any card shows a non-zero price cy.get('.recipe-card').first().invoke('text').then(text => { - cy.log('First card text: ' + text) const priceMatch = text.match(/¥\s*(\d+\.?\d*)/) if (priceMatch) { - cy.log('Price found: ¥' + priceMatch[1]) - const price = parseFloat(priceMatch[1]) - expect(price, 'Recipe card should show price > 0').to.be.gt(0) - } else { - cy.log('WARNING: No price found on recipe card') + expect(parseFloat(priceMatch[1])).to.be.gt(0) } }) }) + + it('recipe detail overlay opens', () => { + cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) + cy.get('.recipe-card', { timeout: 10000 }).first().click() + cy.get('.detail-overlay', { timeout: 10000 }).should('exist') + }) }) diff --git a/frontend/src/__tests__/pr27Features.test.js b/frontend/src/__tests__/pr27Features.test.js index 02ab2b7..7d8ded3 100644 --- a/frontend/src/__tests__/pr27Features.test.js +++ b/frontend/src/__tests__/pr27Features.test.js @@ -499,3 +499,46 @@ describe('volume field in recipe mapping — PR31', () => { 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('颗') + }) +}) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index 2532a54..50bba81 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -829,7 +829,7 @@ onMounted(() => { else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) } loadBrand() - nextTick(() => generateCardImage()) + // Don't auto-generate card image on mount — generate on demand when saving }) function addIngredient() { diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index 5fbb068..239f38a 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -181,9 +181,16 @@