Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E

- Replace single-file 8441-line HTML with Vue 3 SPA
- Pinia stores: auth, oils, recipes, diary, ui
- Composables: useApi, useDialog, useSmartPaste, useOilTranslation
- 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc.
- 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc.
- 14 Cypress E2E test specs (113 tests), all passing
- Multi-stage Dockerfile (Node build + Python runtime)
- Demo video generation scripts (TTS + subtitles + screen recording)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 18:35:00 +00:00
parent 0368e85abe
commit ee8ec23dc7
62 changed files with 15035 additions and 8448 deletions

View File

@@ -0,0 +1,50 @@
describe('Admin Flow', () => {
beforeEach(() => {
const token = Cypress.env('ADMIN_TOKEN')
if (!token) {
cy.log('ADMIN_TOKEN not set, skipping admin tests')
return
}
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', token)
}
})
// Wait for app to load with admin privileges
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
})
it('shows admin-only tabs', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('Bug').should('be.visible')
cy.get('.nav-tab').contains('用户管理').should('be.visible')
})
it('can access manage recipes page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('管理配方').click()
cy.url().should('include', '/manage')
})
it('can access audit log page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('操作日志').click()
cy.url().should('include', '/audit')
cy.contains('操作日志').should('be.visible')
})
it('can access user management page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('用户管理').click()
cy.url().should('include', '/users')
cy.contains('用户管理').should('be.visible')
})
it('can access bug tracker page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('Bug').click()
cy.url().should('include', '/bugs')
cy.contains('Bug').should('be.visible')
})
})

View File

@@ -0,0 +1,357 @@
describe('API CRUD Operations', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
describe('Oils API', () => {
it('creates a new oil', () => {
cy.request({
method: 'POST',
url: '/api/oils',
headers: authHeaders,
body: { name: 'cypress测试油', bottle_price: 100, drop_count: 200 }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists oils including the new one', () => {
cy.request('/api/oils').then(res => {
const found = res.body.find(o => o.name === 'cypress测试油')
expect(found).to.exist
expect(found.bottle_price).to.eq(100)
expect(found.drop_count).to.eq(200)
})
})
it('deletes the test oil', () => {
cy.request({
method: 'DELETE',
url: '/api/oils/' + encodeURIComponent('cypress测试油'),
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
it('verifies oil is deleted', () => {
cy.request('/api/oils').then(res => {
const found = res.body.find(o => o.name === 'cypress测试油')
expect(found).to.not.exist
})
})
})
describe('Recipes API', () => {
let testRecipeId
it('creates a new recipe', () => {
cy.request({
method: 'POST',
url: '/api/recipes',
headers: authHeaders,
body: {
name: 'Cypress测试配方',
note: 'E2E测试用',
ingredients: [
{ oil_name: '薰衣草', drops: 5 },
{ oil_name: '茶树', drops: 3 }
],
tags: []
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
testRecipeId = res.body.id
expect(testRecipeId).to.be.a('number')
})
})
it('reads the created recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方')
expect(found).to.exist
expect(found.note).to.eq('E2E测试用')
expect(found.ingredients).to.have.length(2)
testRecipeId = found.id
})
})
it('updates the recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方')
cy.request({
method: 'PUT',
url: `/api/recipes/${found.id}`,
headers: authHeaders,
body: {
name: 'Cypress更新配方',
note: '已更新',
ingredients: [
{ oil_name: '薰衣草', drops: 10 },
{ oil_name: '乳香', drops: 5 }
],
tags: []
}
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
it('verifies the update', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方')
expect(found).to.exist
expect(found.note).to.eq('已更新')
expect(found.ingredients).to.have.length(2)
testRecipeId = found.id
})
})
it('deletes the test recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方')
if (found) {
cy.request({
method: 'DELETE',
url: `/api/recipes/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Tags API', () => {
it('creates a new tag', () => {
cy.request({
method: 'POST',
url: '/api/tags',
headers: authHeaders,
body: { name: 'cypress-tag' }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists tags including the new one', () => {
cy.request('/api/tags').then(res => {
expect(res.body).to.include('cypress-tag')
})
})
it('deletes the test tag', () => {
cy.request({
method: 'DELETE',
url: '/api/tags/cypress-tag',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
describe('Diary API', () => {
let diaryId
it('creates a diary entry', () => {
cy.request({
method: 'POST',
url: '/api/diary',
headers: authHeaders,
body: {
name: 'Cypress日记配方',
ingredients: [{ oil: '薰衣草', drops: 3 }],
note: '测试备注'
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
diaryId = res.body.id
})
})
it('lists diary entries', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(d => d.name === 'Cypress日记配方')
expect(found).to.exist
diaryId = found.id
})
})
it('deletes the diary entry', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress日记配方')
if (found) {
cy.request({
method: 'DELETE',
url: `/api/diary/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Favorites API', () => {
it('adds a recipe to favorites', () => {
cy.request('/api/recipes').then(res => {
const recipe = res.body[0]
cy.request({
method: 'POST',
url: `/api/favorites/${recipe.id}`,
headers: authHeaders,
body: {}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
})
it('lists favorites', () => {
cy.request({
url: '/api/favorites',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
})
})
it('removes the favorite', () => {
cy.request('/api/recipes').then(res => {
const recipe = res.body[0]
cy.request({
method: 'DELETE',
url: `/api/favorites/${recipe.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
})
describe('Inventory API', () => {
it('adds oil to inventory', () => {
cy.request({
method: 'POST',
url: '/api/inventory',
headers: authHeaders,
body: { oil_name: '薰衣草' }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('reads inventory', () => {
cy.request({
url: '/api/inventory',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
})
describe('Bug Reports API', () => {
let bugId
it('submits a bug report', () => {
cy.request({
method: 'POST',
url: '/api/bug-report',
headers: authHeaders,
body: { content: 'Cypress E2E测试Bug', priority: 2 }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists bug reports', () => {
cy.request({
url: '/api/bug-reports',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(b => b.content.includes('Cypress E2E测试Bug'))
expect(found).to.exist
bugId = found.id
})
})
it('deletes the test bug', () => {
cy.request({
url: '/api/bug-reports',
headers: authHeaders
}).then(res => {
const found = res.body.find(b => b.content.includes('Cypress E2E测试Bug'))
if (found) {
cy.request({
method: 'DELETE',
url: `/api/bug-reports/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Users API (admin)', () => {
it('lists users', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
const admin = res.body.find(u => u.role === 'admin')
expect(admin).to.exist
})
})
it('cannot access users without auth', () => {
cy.request({
url: '/api/users',
failOnStatusCode: false
}).then(res => {
expect(res.status).to.eq(403)
})
})
})
describe('Audit Log API', () => {
it('fetches audit log', () => {
cy.request({
url: '/api/audit-log?limit=10&offset=0',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
})
describe('Notifications API', () => {
it('fetches notifications', () => {
cy.request({
url: '/api/notifications',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
})

View File

@@ -0,0 +1,64 @@
describe('API Health Check', () => {
it('GET /api/version returns version', () => {
cy.request('/api/version').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.have.property('version')
})
})
it('GET /api/oils returns oil list', () => {
cy.request('/api/oils').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
const oil = res.body[0]
expect(oil).to.have.property('name')
expect(oil).to.have.property('bottle_price')
expect(oil).to.have.property('drop_count')
})
})
it('GET /api/recipes returns recipe list', () => {
cy.request('/api/recipes').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
if (res.body.length > 0) {
const recipe = res.body[0]
expect(recipe).to.have.property('name')
expect(recipe).to.have.property('ingredients')
}
})
})
it('GET /api/tags returns tags array', () => {
cy.request('/api/tags').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/me returns anonymous user without auth', () => {
cy.request('/api/me').then(res => {
expect(res.status).to.eq(200)
expect(res.body.username).to.eq('anonymous')
expect(res.body.role).to.eq('viewer')
})
})
it('GET /api/me returns authenticated user with valid token', () => {
// Use the admin token from env or skip
const token = Cypress.env('ADMIN_TOKEN')
if (!token) {
cy.log('ADMIN_TOKEN not set, skipping auth test')
return
}
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body.id).to.not.be.null
expect(res.body.username).to.not.eq('anonymous')
})
})
})

View File

@@ -0,0 +1,32 @@
describe('App Loading', () => {
it('loads the home page with header and nav', () => {
cy.visit('/')
cy.get('.app-header').should('be.visible')
cy.contains('doTERRA 配方计算器').should('be.visible')
cy.get('.nav-tabs').should('be.visible')
cy.get('.nav-tab').should('have.length.gte', 4)
})
it('shows the search section by default', () => {
cy.visit('/')
cy.get('.nav-tab').first().should('have.class', 'active')
cy.get('input[placeholder*="搜索"]', { timeout: 8000 }).should('be.visible')
})
it('navigates between public tabs without login', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.url().should('include', '/oils')
cy.contains('精油价目').should('be.visible')
cy.get('.nav-tab').contains('配方查询').click()
cy.url().should('not.include', '/oils')
})
it('prompts login when accessing protected tabs', () => {
cy.visit('/')
cy.get('.nav-tab').contains('管理配方').click()
// Should show login modal or dialog
cy.get('[class*="overlay"], [class*="login"], [class*="modal"], [class*="dialog"]', { timeout: 3000 }).should('exist')
})
})

View File

@@ -0,0 +1,81 @@
describe('Authentication Flow', () => {
it('shows login button when not authenticated', () => {
cy.visit('/')
cy.contains('登录').should('be.visible')
})
it('opens login modal when clicking login', () => {
cy.visit('/')
cy.contains('登录').click()
cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('be.visible')
})
it('login modal has username and password fields', () => {
cy.visit('/')
cy.contains('登录').click()
cy.get('input[placeholder*="用户名"], input[type="text"]').should('exist')
cy.get('input[type="password"]').should('exist')
})
it('shows error for invalid login', () => {
cy.visit('/')
cy.contains('登录').click()
// Try submitting with invalid credentials
cy.get('input[placeholder*="用户名"], input[type="text"]').first().type('nonexistent_user_xyz')
cy.get('input[type="password"]').first().type('wrongpassword')
cy.contains('button', /登录|确定|提交/).click()
// Should show error (alert, toast, or inline message)
cy.wait(1000)
// The modal should still be visible (login failed)
cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('exist')
})
it('authenticated user sees their name in header', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.app-header', { timeout: 8000 }).should('be.visible')
cy.contains('Hera').should('be.visible')
})
it('logout clears auth and shows login button', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Click user name to open menu
cy.contains('Hera').click()
// Click logout
cy.contains(/退出|登出|logout/i).click()
// Should show login button again
cy.contains('登录', { timeout: 5000 }).should('be.visible')
})
it('token from URL param authenticates user', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/?token=' + ADMIN_TOKEN)
// Should authenticate and show user name
cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Token should be removed from URL
cy.url().should('not.include', 'token=')
})
it('protected tabs become accessible after login', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
cy.get('.nav-tab').contains('管理配方').click()
// Should navigate to manage page, not show login modal
cy.url().should('include', '/manage')
})
})

View File

@@ -0,0 +1,106 @@
// Demo walkthrough for video recording
// Timeline paced to match 90s TTS narration
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('doTERRA 精油配方计算器 - 功能演示', () => {
it('完整功能演示', { defaultCommandTimeout: 15000 }, () => {
// ===== 0:00-0:05 开场:首页加载 =====
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.app-header').should('be.visible')
cy.wait(4500)
// ===== 0:05-0:09 配方卡片列表 =====
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.wait(3500)
// ===== 0:09-0:12 滚动浏览 =====
cy.scrollTo(0, 500, { duration: 1200 })
cy.wait(1500)
cy.scrollTo('top', { duration: 800 })
cy.wait(1000)
// ===== 0:12-0:16 搜索框输入 =====
cy.get('input[placeholder*="搜索"]').click()
cy.wait(800)
cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 200 })
cy.wait(2500)
// ===== 0:16-0:20 搜索结果 =====
cy.wait(2000)
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(1500)
// ===== 0:20-0:24 点击配方卡片 =====
cy.get('.recipe-card').first().click()
cy.wait(4000)
// ===== 0:24-0:30 查看详情 =====
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
cy.wait(4500)
cy.get('button').contains(/✕|关闭|←/).first().click()
cy.wait(1500)
// ===== 0:30-0:34 切换精油价目 =====
cy.get('.nav-tab').contains('精油价目').click()
cy.wait(4000)
// ===== 0:34-0:38 搜索精油 =====
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.get('input[placeholder*="搜索精油"]').type('薰衣草', { delay: 200 })
cy.wait(2500)
cy.get('input[placeholder*="搜索精油"]').clear()
cy.wait(1000)
// ===== 0:38-0:42 切换瓶价/滴价 =====
cy.contains('滴价').click()
cy.wait(2000)
cy.contains('瓶价').click()
cy.wait(1500)
// ===== 0:42-0:47 管理配方 =====
cy.get('.nav-tab').contains('管理配方').click()
cy.wait(4500)
// ===== 0:47-0:52 管理页面浏览 =====
cy.scrollTo(0, 300, { duration: 1000 })
cy.wait(2000)
cy.scrollTo('top', { duration: 600 })
cy.wait(2000)
// ===== 0:52-0:56 个人库存 =====
cy.get('.nav-tab').contains('个人库存').click()
cy.wait(4500)
// ===== 0:56-1:00 库存推荐 =====
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2000)
cy.scrollTo('top', { duration: 400 })
cy.wait(1500)
// ===== 1:00-1:06 操作日志 =====
cy.get('.nav-tab').contains('操作日志').click()
cy.wait(3000)
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2500)
// ===== 1:06-1:12 Bug 追踪 =====
cy.get('.nav-tab').contains('Bug').click()
cy.wait(5500)
// ===== 1:12-1:18 用户管理 =====
cy.get('.nav-tab').contains('用户管理').click()
cy.wait(5500)
// ===== 1:18-1:22 回到首页 =====
cy.get('.nav-tab').contains('配方查询').click()
cy.wait(3500)
// ===== 1:22-1:30 结束 =====
cy.wait(5000)
})
})

View File

@@ -0,0 +1,87 @@
describe('Favorites System', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('API Level', () => {
let firstRecipeId
before(() => {
cy.request('/api/recipes').then(res => {
firstRecipeId = res.body[0].id
})
})
it('can add a favorite via API', () => {
cy.request({
method: 'POST',
url: `/api/favorites/${firstRecipeId}`,
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: {}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists the favorite', () => {
cy.request({
url: '/api/favorites',
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.body).to.include(firstRecipeId)
})
})
it('can remove the favorite via API', () => {
cy.request({
method: 'DELETE',
url: `/api/favorites/${firstRecipeId}`,
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.status).to.eq(200)
})
})
it('favorite is removed from list', () => {
cy.request({
url: '/api/favorites',
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.body).to.not.include(firstRecipeId)
})
})
})
describe('UI Level', () => {
it('recipe cards have star buttons for logged-in users', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Stars should be present on cards
cy.get('.recipe-card').first().within(() => {
cy.contains(/★|☆/).should('exist')
})
})
it('clicking star toggles favorite state', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).first().within(() => {
cy.contains(/★|☆/).then($star => {
const wasFav = $star.text().includes('★')
$star.trigger('click')
// Star text should have toggled
cy.wait(500)
cy.contains(/★|☆/).invoke('text').should(text => {
if (wasFav) expect(text).to.include('☆')
else expect(text).to.include('★')
})
})
})
})
})
})

View File

@@ -0,0 +1,69 @@
describe('Navigation & Routing', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
it('direct URL /oils loads oil reference page', () => {
cy.visit('/oils')
cy.contains('精油价目').should('be.visible')
})
it('direct URL / loads search page', () => {
cy.visit('/')
cy.get('input[placeholder*="搜索"]', { timeout: 8000 }).should('be.visible')
})
it('unknown route still renders the app', () => {
cy.visit('/nonexistent-page')
cy.get('.app-header').should('be.visible')
cy.get('.nav-tabs').should('be.visible')
})
it('back button works between tabs', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.url().should('include', '/oils')
cy.go('back')
cy.url().should('not.include', '/oils')
})
it('tab active state tracks after click', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.get('.nav-tab').contains('精油价目').should('have.class', 'active')
cy.get('.nav-tab').contains('配方查询').should('not.have.class', 'active')
})
it('admin tabs only visible when authenticated', () => {
cy.visit('/')
cy.get('.nav-tab').contains('操作日志').should('not.exist')
cy.get('.nav-tab').contains('用户管理').should('not.exist')
})
it('admin tabs appear after login', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.nav-tab', { timeout: 10000 }).contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('用户管理').should('be.visible')
})
it('all admin pages are navigable', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
const pages = [
{ tab: '管理配方', url: '/manage' },
{ tab: '个人库存', url: '/inventory' },
{ tab: '精油价目', url: '/oils' },
{ tab: '操作日志', url: '/audit' },
{ tab: '用户管理', url: '/users' },
]
pages.forEach(({ tab, url }) => {
cy.get('.nav-tab').contains(tab).click()
cy.url().should('include', url)
})
})
})

View File

@@ -0,0 +1,107 @@
describe('Oil Data Integrity', () => {
it('all oils have valid prices', () => {
cy.request('/api/oils').then(res => {
res.body.forEach(oil => {
expect(oil.name).to.be.a('string').and.not.be.empty
expect(oil.bottle_price).to.be.a('number').and.be.gte(0)
expect(oil.drop_count).to.be.a('number').and.be.gt(0)
})
})
})
it('common oils exist in the database', () => {
const expected = ['薰衣草', '茶树', '乳香', '柠檬', '椒样薄荷', '椰子油']
cy.request('/api/oils').then(res => {
const names = res.body.map(o => o.name)
expected.forEach(name => {
expect(names).to.include(name)
})
})
})
it('oil price per drop is correctly calculated', () => {
cy.request('/api/oils').then(res => {
res.body.forEach(oil => {
const ppd = oil.bottle_price / oil.drop_count
expect(ppd).to.be.a('number')
expect(ppd).to.be.gte(0)
expect(ppd).to.be.lte(100) // sanity check: no oil costs >100 per drop
})
})
})
it('drop counts match known volume standards', () => {
// Standard doTERRA volumes: 5ml=93, 10ml=186, 15ml=280
const validDropCounts = [46, 93, 160, 186, 280, 2146]
cy.request('/api/oils').then(res => {
const counts = new Set(res.body.map(o => o.drop_count))
// At least some should match standard volumes
const matching = [...counts].filter(c => validDropCounts.includes(c))
expect(matching.length).to.be.gte(1)
})
})
})
describe('Recipe Data Integrity', () => {
it('all recipes have valid structure', () => {
cy.request('/api/recipes').then(res => {
expect(res.body.length).to.be.gte(1)
res.body.forEach(recipe => {
expect(recipe).to.have.property('id')
expect(recipe).to.have.property('name').and.not.be.empty
expect(recipe).to.have.property('ingredients').and.be.an('array')
recipe.ingredients.forEach(ing => {
expect(ing).to.have.property('oil_name').and.not.be.empty
expect(ing).to.have.property('drops').and.be.a('number').and.be.gt(0)
})
})
})
})
it('recipes reference existing oils', () => {
cy.request('/api/oils').then(oilRes => {
const oilNames = new Set(oilRes.body.map(o => o.name))
cy.request('/api/recipes').then(recipeRes => {
let totalMissing = 0
recipeRes.body.forEach(recipe => {
recipe.ingredients.forEach(ing => {
if (!oilNames.has(ing.oil_name)) totalMissing++
})
})
// Allow some missing (discontinued oils), but not too many
const totalIngs = recipeRes.body.reduce((s, r) => s + r.ingredients.length, 0)
const missingRate = totalMissing / totalIngs
expect(missingRate).to.be.lt(0.2) // less than 20% missing
})
})
})
it('no duplicate recipe names', () => {
cy.request('/api/recipes').then(res => {
const names = res.body.map(r => r.name)
const unique = new Set(names)
// Allow some duplicates but flag if many
const dupRate = (names.length - unique.size) / names.length
expect(dupRate).to.be.lt(0.1) // less than 10% duplicates
})
})
it('recipe costs are calculable', () => {
cy.request('/api/oils').then(oilRes => {
const oilPrices = {}
oilRes.body.forEach(o => {
oilPrices[o.name] = o.bottle_price / o.drop_count
})
cy.request('/api/recipes').then(recipeRes => {
recipeRes.body.slice(0, 20).forEach(recipe => {
let cost = 0
recipe.ingredients.forEach(ing => {
cost += (oilPrices[ing.oil_name] || 0) * ing.drops
})
expect(cost).to.be.a('number')
expect(cost).to.be.gte(0)
})
})
})
})
})

View File

@@ -0,0 +1,32 @@
describe('Oil Reference Page', () => {
beforeEach(() => {
cy.visit('/oils')
cy.get('.oil-card, .oils-grid', { timeout: 10000 }).should('exist')
})
it('displays oil grid with items', () => {
cy.contains('精油价目').should('be.visible')
cy.get('.oil-card').should('have.length.gte', 10)
})
it('shows oil name and price on each chip', () => {
cy.get('.oil-card').first().should('contain', '¥')
})
it('filters oils by search', () => {
cy.get('.oil-card').then($chips => {
const initial = $chips.length
cy.get('input[placeholder*="搜索精油"]').type('薰衣草')
cy.wait(300)
cy.get('.oil-card').should('have.length.lt', initial)
})
})
it('toggles between bottle and drop price view', () => {
cy.get('.oil-card').first().invoke('text').then(textBefore => {
cy.contains('滴价').click()
cy.wait(300)
cy.get('.oil-card').first().invoke('text').should('not.eq', textBefore)
})
})
})

View File

@@ -0,0 +1,58 @@
describe('Performance', () => {
it('home page loads within 5 seconds', () => {
const start = Date.now()
cy.visit('/')
cy.get('.recipe-card', { timeout: 5000 }).should('have.length.gte', 1)
cy.then(() => {
const elapsed = Date.now() - start
expect(elapsed).to.be.lt(5000)
})
})
it('API /api/oils responds within 1 second', () => {
const start = Date.now()
cy.request('/api/oils').then(() => {
expect(Date.now() - start).to.be.lt(1000)
})
})
it('API /api/recipes responds within 2 seconds', () => {
const start = Date.now()
cy.request('/api/recipes').then(() => {
expect(Date.now() - start).to.be.lt(2000)
})
})
it('search filtering is near-instant', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
const start = Date.now()
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(300)
cy.get('.recipe-card').should('exist')
cy.then(() => {
expect(Date.now() - start).to.be.lt(2000)
})
})
it('oil reference page loads within 3 seconds', () => {
const start = Date.now()
cy.visit('/oils')
cy.get('.oil-card', { timeout: 3000 }).should('have.length.gte', 1)
cy.then(() => {
expect(Date.now() - start).to.be.lt(3000)
})
})
it('handles 250+ recipes without crashing', () => {
cy.request('/api/recipes').then(res => {
expect(res.body.length).to.be.gte(200)
})
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 10)
// Scroll to trigger lazy loading if any
cy.scrollTo('bottom')
cy.wait(500)
cy.get('.main').should('be.visible')
})
})

View File

@@ -0,0 +1,84 @@
describe('Recipe Detail', () => {
beforeEach(() => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('opens detail overlay when clicking a recipe card', () => {
cy.get('.recipe-card').first().click()
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
})
it('shows recipe name in detail view', () => {
// Get recipe name from card, however it's structured
cy.get('.recipe-card').first().invoke('text').then(cardText => {
cy.get('.recipe-card').first().click()
cy.wait(500)
// The detail view should show some text from the card
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
})
})
it('shows ingredient info with drops', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains('滴').should('exist')
})
it('shows cost with ¥ symbol', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains('¥').should('exist')
})
it('closes detail overlay when clicking close button', () => {
cy.get('.recipe-card').first().click()
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
cy.get('button').contains(/✕|关闭|←/).first().click()
cy.get('.recipe-card').should('be.visible')
})
it('shows action buttons in detail', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
// Should have at least one action button
cy.get('[class*="overlay"] button, [class*="detail"] button').should('have.length.gte', 1)
})
it('shows favorite star', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains(/★|☆|收藏/).should('exist')
})
})
describe('Recipe Detail - Editor (Admin)', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
beforeEach(() => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('shows edit button for admin', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains(/编辑|✏/).should('exist')
})
it('can switch to editor view', () => {
cy.get('.recipe-card').first().click()
cy.contains(/编辑|✏/).first().click()
cy.get('select, input[type="number"], .oil-select, .drops-input').should('exist')
})
it('editor shows save button', () => {
cy.get('.recipe-card').first().click()
cy.contains(/编辑|✏/).first().click()
cy.contains(/保存|💾/).should('exist')
})
})

View File

@@ -0,0 +1,45 @@
describe('Recipe Search', () => {
beforeEach(() => {
cy.visit('/')
// Wait for recipes to load
cy.get('.recipe-card, .empty-state', { timeout: 10000 }).should('exist')
})
it('displays recipe cards in the grid', () => {
cy.get('.recipe-card').should('have.length.gte', 1)
})
it('each recipe card shows name and oils', () => {
cy.get('.recipe-card').first().within(() => {
cy.get('.recipe-card-name').should('not.be.empty')
cy.get('.recipe-card-oils').should('not.be.empty')
})
})
it('filters recipes by search input', () => {
cy.get('.recipe-card').then($cards => {
const initialCount = $cards.length
cy.get('input[placeholder*="搜索"]').type('薰衣草')
// Should filter, possibly fewer results
cy.wait(500)
cy.get('.recipe-card').should('have.length.lte', initialCount)
})
})
it('clears search and restores all recipes', () => {
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(500)
cy.get('.recipe-card').then($filtered => {
const filteredCount = $filtered.length
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(500)
cy.get('.recipe-card').should('have.length.gte', filteredCount)
})
})
it('opens recipe detail when clicking a card', () => {
cy.get('.recipe-card').first().click()
// Should show detail overlay or panel
cy.get('[class*="overlay"], [class*="detail"]', { timeout: 5000 }).should('be.visible')
})
})

View File

@@ -0,0 +1,76 @@
describe('Responsive Design', () => {
describe('Mobile viewport (375x667)', () => {
beforeEach(() => {
cy.viewport(375, 667)
})
it('loads the app on mobile', () => {
cy.visit('/')
cy.get('.app-header').should('be.visible')
cy.contains('doTERRA').should('be.visible')
})
it('nav tabs are scrollable', () => {
cy.visit('/')
cy.get('.nav-tabs').should('have.css', 'overflow-x', 'auto')
})
it('recipe cards stack in single column', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// On mobile, cards should be full width
cy.get('.recipe-card').first().then($card => {
const width = $card.outerWidth()
expect(width).to.be.gte(300)
})
})
it('search input is usable on mobile', () => {
cy.visit('/')
cy.get('input[placeholder*="搜索"]').should('be.visible')
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.get('input[placeholder*="搜索"]').should('have.value', '薰衣草')
})
it('oil reference page works on mobile', () => {
cy.visit('/oils')
cy.contains('精油价目').should('be.visible')
cy.get('.oil-card').should('have.length.gte', 1)
})
})
describe('Tablet viewport (768x1024)', () => {
beforeEach(() => {
cy.viewport(768, 1024)
})
it('loads and shows recipe grid', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('oil grid shows multiple columns', () => {
cy.visit('/oils')
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
})
})
describe('Wide viewport (1920x1080)', () => {
beforeEach(() => {
cy.viewport(1920, 1080)
})
it('content is centered with max-width', () => {
cy.visit('/')
cy.get('.main').then($main => {
const width = $main.outerWidth()
expect(width).to.be.lte(960)
})
})
it('recipe grid shows multiple columns', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
})
})

View File

@@ -0,0 +1,67 @@
describe('Advanced Search Features', () => {
beforeEach(() => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('search input accepts text and app stays stable', () => {
cy.get('input[placeholder*="搜索"]').type('酸痛')
cy.wait(500)
// App should remain functional
cy.get('.main').should('be.visible')
cy.get('input[placeholder*="搜索"]').should('have.value', '酸痛')
})
it('searches by partial recipe name', () => {
cy.get('input[placeholder*="搜索"]').type('安睡')
cy.wait(500)
cy.get('.recipe-card').should('have.length.gte', 0)
})
it('returns fewer results for nonsense query', () => {
cy.get('.recipe-card').then($all => {
const total = $all.length
cy.get('input[placeholder*="搜索"]').type('xyzabcnonexistent')
cy.wait(500)
// Should show empty state or fewer results
cy.get('.recipe-card').should('have.length.lte', total)
})
})
it('search is case-insensitive for latin chars', () => {
cy.get('input[placeholder*="搜索"]').type('doterra')
cy.wait(500)
// Just verify no crash
cy.get('.main').should('be.visible')
})
it('handles special characters in search', () => {
cy.get('input[placeholder*="搜索"]').type('()【】')
cy.wait(300)
cy.get('.main').should('be.visible')
})
it('rapid typing updates results without crash', () => {
const input = cy.get('input[placeholder*="搜索"]')
input.type('薰')
cy.wait(100)
input.type('衣')
cy.wait(100)
input.type('草')
cy.wait(300)
cy.get('.recipe-card').should('have.length.gte', 0)
cy.get('.main').should('be.visible')
})
it('clearing search with button restores all recipes', () => {
cy.get('.recipe-card').then($initial => {
const count = $initial.length
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(300)
// Clear
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(300)
cy.get('.recipe-card').should('have.length', count)
})
})
})