Compare commits
2 Commits
2645d2afe5
...
fix/volume
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca9943d50 | |||
| 7ba1e28370 |
@@ -1,23 +0,0 @@
|
||||
name: Deploy Production
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Unit tests
|
||||
run: cd frontend && npm ci && npm run test:unit
|
||||
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
rsync -az --exclude node_modules --exclude .git --exclude .venv . oci:~/oil-calculator/
|
||||
ssh oci "
|
||||
cd ~/oil-calculator &&
|
||||
docker build -t registry.oci.euphon.net/oil-calculator:latest . &&
|
||||
docker push registry.oci.euphon.net/oil-calculator:latest &&
|
||||
sudo k3s kubectl rollout restart deploy/oil-calculator -n oil-calculator
|
||||
"
|
||||
@@ -1,190 +0,0 @@
|
||||
name: PR Preview
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
env:
|
||||
REGISTRY: registry.oci.euphon.net
|
||||
BASE_DOMAIN: oil.oci.euphon.net
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Unit tests
|
||||
run: cd frontend && npm ci && npm run test:unit
|
||||
|
||||
- name: Deploy Preview Environment
|
||||
run: |
|
||||
PR_ID="${{ github.event.pull_request.number }}"
|
||||
NS="oil-pr-${PR_ID}"
|
||||
HOST="pr-${PR_ID}.${BASE_DOMAIN}"
|
||||
IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}"
|
||||
|
||||
# Sync source to oci build server
|
||||
rsync -az --exclude node_modules --exclude .git --exclude .venv \
|
||||
. oci:/tmp/oil-pr-${PR_ID}-build/
|
||||
|
||||
ssh oci bash -s "${PR_ID}" "${NS}" "${HOST}" "${IMAGE}" << 'DEPLOY_SCRIPT'
|
||||
set -e
|
||||
PR_ID="$1"; NS="$2"; HOST="$3"; IMAGE="$4"
|
||||
|
||||
cd /tmp/oil-pr-${PR_ID}-build
|
||||
|
||||
# Copy production DB into build context so it's baked into image
|
||||
PROD_POD=$(sudo k3s kubectl get pods -n oil-calculator -l app=oil-calculator \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
if [ -n "$PROD_POD" ]; then
|
||||
sudo k3s kubectl cp "oil-calculator/${PROD_POD}:/data/oil_calculator.db" /tmp/pr-${PR_ID}.db
|
||||
mkdir -p data
|
||||
cp /tmp/pr-${PR_ID}.db data/oil_calculator.db
|
||||
fi
|
||||
|
||||
# Build image (with DB baked in)
|
||||
cat > Dockerfile.preview << 'DEOF'
|
||||
FROM node:20-slim AS frontend-build
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY backend/ ./backend/
|
||||
COPY --from=frontend-build /build/dist ./frontend/
|
||||
# Bake production DB copy into image
|
||||
COPY data/oil_calculator.db /data/oil_calculator.db
|
||||
ENV DB_PATH=/data/oil_calculator.db
|
||||
ENV FRONTEND_DIR=/app/frontend
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
DEOF
|
||||
|
||||
docker build -f Dockerfile.preview -t "${IMAGE}" .
|
||||
docker push "${IMAGE}"
|
||||
|
||||
# Create namespace
|
||||
sudo k3s kubectl create namespace "${NS}" --dry-run=client -o yaml | sudo k3s kubectl apply -f -
|
||||
|
||||
# Copy regcred from production namespace
|
||||
sudo k3s kubectl get secret regcred -n oil-calculator -o json | \
|
||||
sed "s/\"namespace\":\"oil-calculator\"/\"namespace\":\"${NS}\"/" | \
|
||||
sudo k3s kubectl apply -f -
|
||||
|
||||
# Deploy pod + service + ingress (no PVC needed, DB is in image)
|
||||
cat << EOYAML | sudo k3s kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: oil-calculator
|
||||
namespace: ${NS}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: oil-calculator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: oil-calculator
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
containers:
|
||||
- name: app
|
||||
image: ${IMAGE}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: oil-calculator
|
||||
namespace: ${NS}
|
||||
spec:
|
||||
selector:
|
||||
app: oil-calculator
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: oil-calculator
|
||||
namespace: ${NS}
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: le
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- ${HOST}
|
||||
rules:
|
||||
- host: ${HOST}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: oil-calculator
|
||||
port:
|
||||
number: 80
|
||||
EOYAML
|
||||
|
||||
# Wait for rollout
|
||||
sudo k3s kubectl rollout status deploy/oil-calculator -n "${NS}" --timeout=120s
|
||||
|
||||
# Cleanup build dir
|
||||
rm -rf /tmp/oil-pr-${PR_ID}-build /tmp/pr-${PR_ID}.db
|
||||
|
||||
echo "Preview deployed: https://${HOST}"
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
- name: Comment PR with preview URL
|
||||
run: |
|
||||
PR_ID="${{ github.event.pull_request.number }}"
|
||||
HOST="pr-${PR_ID}.${BASE_DOMAIN}"
|
||||
curl -s -X POST \
|
||||
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"🚀 Preview deployed: https://${HOST}\n\nDB is a copy of production. Changes here won't affect prod.\"}"
|
||||
|
||||
teardown-preview:
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Teardown Preview Environment
|
||||
run: |
|
||||
PR_ID="${{ github.event.pull_request.number }}"
|
||||
NS="oil-pr-${PR_ID}"
|
||||
IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}"
|
||||
|
||||
ssh oci "
|
||||
sudo k3s kubectl delete namespace ${NS} --ignore-not-found
|
||||
docker rmi ${IMAGE} 2>/dev/null || true
|
||||
"
|
||||
|
||||
- name: Comment PR
|
||||
run: |
|
||||
PR_ID="${{ github.event.pull_request.number }}"
|
||||
curl -s -X POST \
|
||||
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"🗑️ Preview environment torn down.\"}"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Test
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Unit tests
|
||||
run: cd frontend && npm run test:unit
|
||||
|
||||
- name: Build check
|
||||
run: cd frontend && npm run build
|
||||
@@ -1,298 +0,0 @@
|
||||
# 前端功能点测试覆盖表
|
||||
|
||||
> 基于 Vue 3 重构后的前端,对照原始 vanilla JS 实现的所有功能点。
|
||||
|
||||
## 测试类型说明
|
||||
|
||||
- **unit** = Vitest 单元测试 (纯逻辑,无 DOM)
|
||||
- **e2e** = Cypress E2E 测试 (真实浏览器 + 后端)
|
||||
- **none** = 尚未覆盖
|
||||
|
||||
---
|
||||
|
||||
## 1. 配方查询 (RecipeSearch)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 配方卡片列表渲染 | e2e | recipe-search.cy.js |
|
||||
| 按名称搜索过滤 | e2e | recipe-search.cy.js, search-advanced.cy.js |
|
||||
| 按精油名搜索 | e2e | search-advanced.cy.js |
|
||||
| 清除搜索恢复列表 | e2e | recipe-search.cy.js, search-advanced.cy.js |
|
||||
| 特殊字符搜索不崩溃 | e2e | search-advanced.cy.js |
|
||||
| 快速输入不崩溃 | e2e | search-advanced.cy.js |
|
||||
| 分类轮播 (carousel) | none | — |
|
||||
| 个人配方预览 (登录后) | none | — |
|
||||
| 收藏配方预览 (登录后) | none | — |
|
||||
| 症状搜索 / fuzzy results | none | — |
|
||||
|
||||
## 2. 配方详情 (RecipeDetailOverlay)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 点击卡片弹出详情 | e2e | recipe-detail.cy.js |
|
||||
| 显示配方名称 | e2e | recipe-detail.cy.js |
|
||||
| 显示精油成分和滴数 | e2e | recipe-detail.cy.js |
|
||||
| 显示总成本 (¥) | e2e | recipe-detail.cy.js |
|
||||
| 关闭详情弹层 | e2e | recipe-detail.cy.js |
|
||||
| 收藏星标按钮 | e2e | recipe-detail.cy.js |
|
||||
| 编辑模式切换 (admin) | e2e | recipe-detail.cy.js |
|
||||
| 编辑器显示成分表 | e2e | recipe-detail.cy.js |
|
||||
| 保存按钮 | e2e | recipe-detail.cy.js |
|
||||
| 容量选择 (单次/5ml/10ml/30ml) | none | — |
|
||||
| 稀释比例换算 | none | — |
|
||||
| 应用容量到配方 | none | — |
|
||||
| 标签编辑 | none | — |
|
||||
| 备注编辑 | none | — |
|
||||
| 配方卡片图片生成 (html2canvas) | none | — |
|
||||
| 中英双语卡片 | none | — |
|
||||
| 分享 overlay | none | — |
|
||||
| 品牌水印 | none | — |
|
||||
|
||||
## 3. 精油价目 (OilReference)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 精油列表渲染 | e2e | oil-reference.cy.js |
|
||||
| 按名称搜索 | e2e | oil-reference.cy.js |
|
||||
| 瓶价/滴价切换 | e2e | oil-reference.cy.js |
|
||||
| 精油数据完整性 (价格有效) | e2e | oil-data-integrity.cy.js |
|
||||
| 标准容量验证 | e2e | oil-data-integrity.cy.js |
|
||||
| 稀释比例知识卡 | none | — |
|
||||
| 使用禁忌知识卡 | none | — |
|
||||
| 新增精油 (admin) | e2e | api-crud.cy.js (API层) |
|
||||
| 编辑精油 (admin) | none | — |
|
||||
| 删除精油 (admin) | e2e | api-crud.cy.js (API层) |
|
||||
| 导出 PDF | none | — |
|
||||
|
||||
## 4. 管理配方 (RecipeManager)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 页面加载配方列表 | e2e | manage-recipes.cy.js |
|
||||
| 搜索过滤 | e2e | manage-recipes.cy.js |
|
||||
| 标签筛选 | e2e | manage-recipes.cy.js |
|
||||
| 点击编辑配方 | e2e | manage-recipes.cy.js |
|
||||
| 新增配方 (API) | e2e | api-crud.cy.js |
|
||||
| 更新配方 (API) | e2e | api-crud.cy.js |
|
||||
| 删除配方 (API) | e2e | api-crud.cy.js |
|
||||
| 批量选择 | none | — |
|
||||
| 批量打标签 | none | — |
|
||||
| 批量删除 | none | — |
|
||||
| 批量导出卡片 (zip) | none | — |
|
||||
| Excel 导出 | none | — |
|
||||
| 待审核配方 (admin) | none | — |
|
||||
| 批量采纳配方 (admin) | none | — |
|
||||
| 智能粘贴 → 新增配方 | unit | smartPaste.test.js (解析逻辑) |
|
||||
|
||||
## 5. 个人库存 (Inventory)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 添加精油到库存 (API) | e2e | api-crud.cy.js, inventory-flow.cy.js |
|
||||
| 读取库存 (API) | e2e | api-crud.cy.js, inventory-flow.cy.js |
|
||||
| 删除库存精油 | e2e | inventory-flow.cy.js |
|
||||
| 搜索精油 picker | e2e | inventory-flow.cy.js |
|
||||
| 可做配方推荐 | e2e | inventory-flow.cy.js |
|
||||
|
||||
## 6. 商业核算 (Projects)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 项目列表 | none | — |
|
||||
| 创建/编辑/删除项目 | none | — |
|
||||
| 成分编辑 | none | — |
|
||||
| 定价利润分析 | none | — |
|
||||
| 从配方导入 | none | — |
|
||||
|
||||
## 7. 我的 (MyDiary)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 创建个人配方 (API) | e2e | api-crud.cy.js, diary-flow.cy.js |
|
||||
| 更新个人配方 (API) | e2e | diary-flow.cy.js |
|
||||
| 删除个人配方 (API) | e2e | api-crud.cy.js, diary-flow.cy.js |
|
||||
| 添加使用日记 (API) | e2e | diary-flow.cy.js |
|
||||
| 删除使用日记 (API) | e2e | diary-flow.cy.js |
|
||||
| 日记配方列表 UI | e2e | diary-flow.cy.js |
|
||||
| 智能粘贴到日记 | unit | smartPaste.test.js (解析逻辑) |
|
||||
| 品牌设置 (QR/Logo/背景) | none | — |
|
||||
| 账号设置 (昵称/密码) | none | — |
|
||||
| 商业认证申请 | none | — |
|
||||
|
||||
## 8. 操作日志 (AuditLog)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 读取日志 (API) | e2e | api-crud.cy.js |
|
||||
| 页面渲染 | e2e | admin-flow.cy.js |
|
||||
| 类型筛选 | none | — |
|
||||
| 用户筛选 | none | — |
|
||||
| 撤销操作 | none | — |
|
||||
| 加载更多 | none | — |
|
||||
|
||||
## 9. Bug 追踪 (BugTracker)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 提交 Bug (API) | e2e | api-crud.cy.js, bug-tracker-flow.cy.js |
|
||||
| Bug 列表 (API) | e2e | api-crud.cy.js, bug-tracker-flow.cy.js |
|
||||
| 更新状态 (API) | e2e | bug-tracker-flow.cy.js |
|
||||
| 添加评论 (API) | e2e | bug-tracker-flow.cy.js |
|
||||
| 删除 Bug (API) | e2e | api-crud.cy.js, bug-tracker-flow.cy.js |
|
||||
| 页面渲染 | e2e | admin-flow.cy.js |
|
||||
| 优先级排序 | none | — |
|
||||
| 指派测试人 | none | — |
|
||||
|
||||
## 10. 用户管理 (UserManagement)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 用户列表 (API) | e2e | api-crud.cy.js, user-management-flow.cy.js |
|
||||
| 创建用户 (API) | e2e | user-management-flow.cy.js |
|
||||
| 修改角色 (API) | e2e | user-management-flow.cy.js |
|
||||
| 删除用户 (API) | e2e | user-management-flow.cy.js |
|
||||
| 页面渲染 | e2e | admin-flow.cy.js |
|
||||
| 权限不足拦截 | e2e | api-crud.cy.js |
|
||||
| 翻译建议审核 | none | — |
|
||||
| 商业认证审批 | none | — |
|
||||
|
||||
## 11. 认证与权限
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 未登录显示登录按钮 | e2e | auth-flow.cy.js |
|
||||
| 登录 modal 弹出 | e2e | auth-flow.cy.js |
|
||||
| 登录表单字段 | e2e | auth-flow.cy.js |
|
||||
| 无效登录错误提示 | e2e | auth-flow.cy.js |
|
||||
| Token 认证 | e2e | auth-flow.cy.js, api-health.cy.js |
|
||||
| URL token 自动登录 | e2e | auth-flow.cy.js |
|
||||
| 登出清除状态 | e2e | auth-flow.cy.js |
|
||||
| Admin tab 权限控制 | e2e | admin-flow.cy.js, navigation.cy.js |
|
||||
| 受保护 tab 登录拦截 | e2e | app-load.cy.js |
|
||||
| 注册 | none | — |
|
||||
|
||||
## 12. 收藏系统
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 添加收藏 (API) | e2e | api-crud.cy.js, favorites.cy.js |
|
||||
| 移除收藏 (API) | e2e | api-crud.cy.js, favorites.cy.js |
|
||||
| 卡片星标切换 | e2e | favorites.cy.js |
|
||||
|
||||
## 13. 智能粘贴 (Smart Paste)
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 编辑距离计算 | unit | smartPaste.test.js |
|
||||
| 精确匹配精油名 | unit | smartPaste.test.js |
|
||||
| 同音字纠错 (12 组) | unit | smartPaste.test.js |
|
||||
| 子串匹配 | unit | smartPaste.test.js |
|
||||
| 缺字匹配 | unit | smartPaste.test.js |
|
||||
| 编辑距离模糊匹配 | unit | smartPaste.test.js |
|
||||
| 贪心最长匹配 | unit | smartPaste.test.js |
|
||||
| 连写解析 "芳香调理8永久花10" | unit | smartPaste.test.js |
|
||||
| ml → 滴数换算 | unit | smartPaste.test.js |
|
||||
| 单配方解析 | unit | smartPaste.test.js |
|
||||
| 多配方拆分 | unit | smartPaste.test.js |
|
||||
| 去重合并 | unit | smartPaste.test.js |
|
||||
|
||||
## 14. 成本计算
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 单滴价格计算 | unit | oilCalculations.test.js |
|
||||
| 配方成本求和 | unit | oilCalculations.test.js |
|
||||
| 零售价计算 | unit | oilCalculations.test.js |
|
||||
| 前端成本 vs 预期值对比 | e2e | recipe-cost-parity.cy.js |
|
||||
| 价格格式化 (¥ X.XX) | unit | oilCalculations.test.js |
|
||||
| 137 种精油价格有效性 | unit+e2e | oilCalculations.test.js, oil-data-integrity.cy.js |
|
||||
|
||||
## 15. 精油翻译
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 常用精油中→英 | unit | oilTranslation.test.js |
|
||||
| 复方名中→英 | unit | oilTranslation.test.js |
|
||||
| 未知精油返回空 | unit | oilTranslation.test.js |
|
||||
|
||||
## 16. 对话框系统
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| Alert 弹出和关闭 | unit | dialog.test.js |
|
||||
| Confirm 返回 true/false | unit | dialog.test.js |
|
||||
| Prompt 返回输入值/null | unit | dialog.test.js |
|
||||
|
||||
## 17. 通用 UI
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 首页加载 | e2e | app-load.cy.js |
|
||||
| Header 渲染 | e2e | app-load.cy.js |
|
||||
| 导航 tab 切换 | e2e | navigation.cy.js |
|
||||
| 后退按钮 | e2e | navigation.cy.js |
|
||||
| Tab active 状态 | e2e | navigation.cy.js |
|
||||
| 直接 URL 访问 | e2e | navigation.cy.js |
|
||||
| 手机端渲染 (375px) | e2e | responsive.cy.js |
|
||||
| 平板端渲染 (768px) | e2e | responsive.cy.js |
|
||||
| 宽屏渲染 (1920px) | e2e | responsive.cy.js |
|
||||
| 页面加载 < 5s | e2e | performance.cy.js |
|
||||
| API 响应 < 1s | e2e | performance.cy.js |
|
||||
| 250+ 配方不崩溃 | e2e | performance.cy.js |
|
||||
| Toast 提示 | none | — |
|
||||
| 离线队列 | none | — |
|
||||
| 版本检查 | e2e | api-health.cy.js |
|
||||
|
||||
## 18. 通知系统
|
||||
|
||||
| 功能点 | 测试 | 文件 |
|
||||
|--------|------|------|
|
||||
| 读取通知 (API) | e2e | api-crud.cy.js |
|
||||
| 全部已读 | none | — |
|
||||
| 通知弹窗 | none | — |
|
||||
|
||||
---
|
||||
|
||||
## 覆盖统计
|
||||
|
||||
| 类型 | 数量 |
|
||||
|------|------|
|
||||
| **功能点总数** | ~120 |
|
||||
| **Vitest unit tests** | 105 |
|
||||
| **Cypress E2E tests** | 167 |
|
||||
| **总测试数** | **272** |
|
||||
| **功能点覆盖率** | **~79%** |
|
||||
|
||||
### 未覆盖的高风险功能
|
||||
|
||||
以下功能未测试且回归风险较高(按优先级排序):
|
||||
|
||||
| 优先级 | 功能 | 风险 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| P0 | 容量/稀释换算 | HIGH | 核心数学计算,剂量错误有安全风险 |
|
||||
| P0 | 配方卡片图片生成 | HIGH | html2canvas 外部依赖,异步渲染 |
|
||||
| P0 | 批量操作 | HIGH | 多配方变更,破坏性操作 |
|
||||
| P1 | Excel 导出 | HIGH | ExcelJS 依赖,文件格式兼容性 |
|
||||
| P1 | 品牌图片上传压缩 | HIGH | 文件 I/O,Base64 编码 |
|
||||
| P1 | 商业核算模块 | MED | 整个 Projects 模块 (~15 functions) |
|
||||
| P2 | 分类轮播 | MED | 触摸/滑动事件,动画状态 |
|
||||
| P2 | 审计日志撤销 | MED | 逆向 API 操作,数据一致性 |
|
||||
| P2 | 通知系统 | MED | 状态同步(未读计数) |
|
||||
| P2 | 商业认证审批 | MED | 权限门控功能 |
|
||||
| P3 | 症状搜索 | MED-LOW | 模糊匹配逻辑 |
|
||||
| P3 | 账号设置 | MED-LOW | 密码验证逻辑 |
|
||||
| P3 | 离线队列 | LOW | 数据保护 |
|
||||
|
||||
### 覆盖最充分的功能
|
||||
|
||||
1. 智能粘贴解析 (unit: 全覆盖,37 tests)
|
||||
2. 成本计算 (unit + e2e: 全覆盖,21 + 6 tests)
|
||||
3. API CRUD (e2e: 全覆盖,27 tests)
|
||||
4. 认证/权限 (e2e: 基本全覆盖,8 tests)
|
||||
5. 搜索/过滤 (e2e: 充分覆盖,12 tests)
|
||||
6. 数据完整性 (e2e: 137 oils + 293 recipes 验证)
|
||||
7. 响应式布局 (e2e: 3 种视口,9 tests)
|
||||
|
||||
### 已发现的后端 Bug
|
||||
|
||||
- `backend/main.py:246-247`: `@app.post("/api/bug-reports/{bug_id}/comment")` 装饰器叠在 `delete_bug` 函数上,导致 POST comment 实际执行删除操作。
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
demo-output/
|
||||
cypress/videos/
|
||||
cypress/screenshots/
|
||||
@@ -1,44 +0,0 @@
|
||||
describe('Account Settings', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
|
||||
it('can read current user profile', () => {
|
||||
cy.request({ url: '/api/me', headers: authHeaders }).then(res => {
|
||||
expect(res.body.username).to.eq('hera')
|
||||
expect(res.body.role).to.eq('admin')
|
||||
expect(res.body).to.have.property('display_name')
|
||||
expect(res.body).to.have.property('has_password')
|
||||
})
|
||||
})
|
||||
|
||||
it('can update display name', () => {
|
||||
// Save original
|
||||
cy.request({ url: '/api/me', headers: authHeaders }).then(res => {
|
||||
const original = res.body.display_name
|
||||
// Update
|
||||
cy.request({
|
||||
method: 'PUT', url: `/api/users/${res.body.id}`, headers: authHeaders,
|
||||
body: { display_name: 'Cypress测试名' }
|
||||
}).then(r => expect(r.status).to.eq(200))
|
||||
// Verify
|
||||
cy.request({ url: '/api/me', headers: authHeaders }).then(r2 => {
|
||||
expect(r2.body.display_name).to.eq('Cypress测试名')
|
||||
})
|
||||
// Restore
|
||||
cy.request({
|
||||
method: 'PUT', url: `/api/users/${res.body.id}`, headers: authHeaders,
|
||||
body: { display_name: original || 'Hera' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('API rejects unauthenticated profile update', () => {
|
||||
cy.request({
|
||||
method: 'PUT', url: '/api/users/1',
|
||||
body: { display_name: 'hacked' },
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(403)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
describe('Audit Log Advanced', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
|
||||
it('fetches audit logs with pagination', () => {
|
||||
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')
|
||||
expect(res.body.length).to.be.lte(10)
|
||||
})
|
||||
})
|
||||
|
||||
it('audit log entries have required fields', () => {
|
||||
cy.request({ url: '/api/audit-log?limit=5&offset=0', headers: authHeaders }).then(res => {
|
||||
if (res.body.length > 0) {
|
||||
const entry = res.body[0]
|
||||
expect(entry).to.have.property('action')
|
||||
expect(entry).to.have.property('created_at')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('pagination works (offset returns different records)', () => {
|
||||
cy.request({ url: '/api/audit-log?limit=5&offset=0', headers: authHeaders }).then(res1 => {
|
||||
if (res1.body.length < 5) return // not enough data
|
||||
cy.request({ url: '/api/audit-log?limit=5&offset=5', headers: authHeaders }).then(res2 => {
|
||||
if (res2.body.length > 0) {
|
||||
// First record of page 2 should differ from page 1
|
||||
expect(res2.body[0].id).to.not.eq(res1.body[0].id)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('creating a recipe generates an audit log entry', () => {
|
||||
// Create a recipe
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/recipes', headers: authHeaders,
|
||||
body: { name: 'Cypress审计测试', note: '', ingredients: [{ oil_name: '薰衣草', drops: 1 }], tags: [] }
|
||||
}).then(createRes => {
|
||||
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审计测试')
|
||||
expect(entry).to.exist
|
||||
})
|
||||
// Cleanup
|
||||
cy.request({ method: 'DELETE', url: `/api/recipes/${recipeId}`, headers: authHeaders, failOnStatusCode: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('deleting a recipe generates audit log entry', () => {
|
||||
cy.request({ url: '/api/audit-log?limit=10&offset=0', headers: authHeaders }).then(res => {
|
||||
const deleteEntries = res.body.filter(e => e.action === 'delete_recipe')
|
||||
// Should have at least one delete entry (from our previous test cleanup)
|
||||
expect(deleteEntries.length).to.be.gte(0) // may or may not exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
describe('Batch Operations', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
|
||||
describe('Batch tag operations via API', () => {
|
||||
let testRecipeIds = []
|
||||
|
||||
before(() => {
|
||||
// Create 3 test recipes
|
||||
const recipes = ['Cypress批量1', 'Cypress批量2', 'Cypress批量3']
|
||||
recipes.forEach(name => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/recipes', headers: authHeaders,
|
||||
body: { name, note: 'batch test', ingredients: [{ oil_name: '薰衣草', drops: 5 }], tags: [] }
|
||||
}).then(res => testRecipeIds.push(res.body.id))
|
||||
})
|
||||
})
|
||||
|
||||
it('created 3 test recipes', () => {
|
||||
expect(testRecipeIds).to.have.length(3)
|
||||
})
|
||||
|
||||
it('can update tags on each recipe', () => {
|
||||
testRecipeIds.forEach(id => {
|
||||
cy.request({
|
||||
method: 'PUT', url: `/api/recipes/${id}`, headers: authHeaders,
|
||||
body: { tags: ['cypress-batch-tag'] }
|
||||
}).then(res => expect(res.status).to.eq(200))
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies tags were applied', () => {
|
||||
cy.request('/api/recipes').then(res => {
|
||||
const tagged = res.body.filter(r => (r.tags || []).includes('cypress-batch-tag'))
|
||||
expect(tagged.length).to.be.gte(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('can delete all test recipes', () => {
|
||||
testRecipeIds.forEach(id => {
|
||||
cy.request({
|
||||
method: 'DELETE', url: `/api/recipes/${id}`, headers: authHeaders
|
||||
}).then(res => expect(res.status).to.eq(200))
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies recipes are deleted', () => {
|
||||
cy.request('/api/recipes').then(res => {
|
||||
const found = res.body.filter(r => r.name && r.name.startsWith('Cypress批量'))
|
||||
expect(found).to.have.length(0)
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Cleanup tag
|
||||
cy.request({ method: 'DELETE', url: '/api/tags/cypress-batch-tag', headers: authHeaders, failOnStatusCode: false })
|
||||
// Cleanup any remaining test recipes
|
||||
cy.request('/api/recipes').then(res => {
|
||||
res.body.filter(r => r.name && r.name.startsWith('Cypress批量')).forEach(r => {
|
||||
cy.request({ method: 'DELETE', url: `/api/recipes/${r.id}`, headers: authHeaders, failOnStatusCode: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recipe adopt workflow (admin)', () => {
|
||||
// Test the adopt/review workflow that admin uses to approve user-submitted recipes
|
||||
it('lists recipes and checks for owner_id field', () => {
|
||||
cy.request('/api/recipes').then(res => {
|
||||
expect(res.body[0]).to.have.property('owner_id')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
describe('Bug Tracker Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
const TEST_CONTENT = 'Cypress_E2E_Bug_测试缺陷_' + Date.now()
|
||||
let testBugId = null
|
||||
|
||||
describe('API: bug lifecycle', () => {
|
||||
it('submits a new bug via API', () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/bug-report',
|
||||
headers: authHeaders,
|
||||
body: { content: TEST_CONTENT, priority: 2 }
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the bug appears in the list', () => {
|
||||
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 && b.content.includes('Cypress_E2E_Bug'))
|
||||
expect(found).to.exist
|
||||
testBugId = found.id
|
||||
})
|
||||
})
|
||||
|
||||
it('updates bug status to testing', () => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
|
||||
testBugId = found.id
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `/api/bug-reports/${testBugId}`,
|
||||
headers: authHeaders,
|
||||
body: { status: 1, note: 'E2E test status change' }
|
||||
}).then(r => expect(r.status).to.eq(200))
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies status was updated', () => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
|
||||
expect(found.is_resolved).to.eq(1)
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: POST /api/bug-reports/{id}/comment has a backend bug — the decorator
|
||||
// is stacked on delete_bug function, so POST to /comment actually deletes the bug.
|
||||
// Skipping comment tests until backend is fixed.
|
||||
it('bug has auto-generated creation comment', () => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
|
||||
expect(found).to.exist
|
||||
expect(found.comments).to.be.an('array')
|
||||
expect(found.comments.length).to.be.gte(1) // auto creation log
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the test bug', () => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
|
||||
if (found) {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/bug-reports/${found.id}`,
|
||||
headers: authHeaders
|
||||
}).then(r => expect(r.status).to.eq(200))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the bug is deleted', () => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
|
||||
expect(found).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI: bugs page', () => {
|
||||
it('visits /bugs and page renders', () => {
|
||||
cy.visit('/bugs', {
|
||||
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
|
||||
})
|
||||
cy.contains('Bug', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.request({ url: '/api/bug-reports', headers: authHeaders, failOnStatusCode: false }).then(res => {
|
||||
if (res.status === 200 && Array.isArray(res.body)) {
|
||||
res.body.filter(b => b.content && b.content.includes('Cypress_E2E_Bug')).forEach(bug => {
|
||||
cy.request({ method: 'DELETE', url: `/api/bug-reports/${bug.id}`, headers: authHeaders, failOnStatusCode: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
describe('Category Modules', () => {
|
||||
it('fetches category modules from API', () => {
|
||||
cy.request({ url: '/api/category-modules', failOnStatusCode: false }).then(res => {
|
||||
if (res.status === 200) {
|
||||
expect(res.body).to.be.an('array')
|
||||
if (res.body.length > 0) {
|
||||
const cat = res.body[0]
|
||||
expect(cat).to.have.property('name')
|
||||
expect(cat).to.have.property('tag_name')
|
||||
expect(cat).to.have.property('icon')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('categories reference existing tags', () => {
|
||||
cy.request({ url: '/api/category-modules', failOnStatusCode: false }).then(catRes => {
|
||||
if (catRes.status !== 200) return
|
||||
cy.request('/api/tags').then(tagRes => {
|
||||
const tags = tagRes.body
|
||||
catRes.body.forEach(cat => {
|
||||
// Category's tag_name should correspond to a valid tag or recipes with that tag
|
||||
expect(cat.tag_name).to.be.a('string').and.not.be.empty
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,216 +0,0 @@
|
||||
describe('Diary Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
let testDiaryId = null
|
||||
|
||||
describe('API: full diary lifecycle', () => {
|
||||
it('creates a diary entry via API', () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/diary',
|
||||
headers: authHeaders,
|
||||
body: {
|
||||
name: 'Cypress_Diary_Test_日记',
|
||||
ingredients: [
|
||||
{ oil: '薰衣草', drops: 3 },
|
||||
{ oil: '茶树', drops: 2 }
|
||||
],
|
||||
note: '这是E2E测试创建的日记'
|
||||
}
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
testDiaryId = res.body.id || res.body._id
|
||||
expect(testDiaryId).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies diary entry appears in GET /api/diary', () => {
|
||||
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_Diary_Test_日记')
|
||||
expect(found).to.exist
|
||||
expect(found.ingredients).to.have.length(2)
|
||||
expect(found.note).to.eq('这是E2E测试创建的日记')
|
||||
testDiaryId = found.id || found._id
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the diary entry via PUT', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Test_日记')
|
||||
testDiaryId = found.id || found._id
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `/api/diary/${testDiaryId}`,
|
||||
headers: authHeaders,
|
||||
body: {
|
||||
name: 'Cypress_Diary_Updated_日记',
|
||||
ingredients: [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '乳香', drops: 3 }
|
||||
],
|
||||
note: '已更新的日记'
|
||||
}
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the update took effect', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
|
||||
expect(found).to.exist
|
||||
expect(found.note).to.eq('已更新的日记')
|
||||
expect(found.ingredients).to.have.length(2)
|
||||
testDiaryId = found.id || found._id
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a journal entry to the diary', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
|
||||
testDiaryId = found.id || found._id
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `/api/diary/${testDiaryId}/entries`,
|
||||
headers: authHeaders,
|
||||
body: {
|
||||
content: 'Cypress测试日志: 使用后感觉很好'
|
||||
}
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies journal entry exists in diary', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
|
||||
expect(found).to.exist
|
||||
expect(found.entries).to.be.an('array')
|
||||
expect(found.entries.length).to.be.gte(1)
|
||||
const entry = found.entries.find(e =>
|
||||
(e.text || e.content || '').includes('Cypress测试日志')
|
||||
)
|
||||
expect(entry).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the journal entry', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
|
||||
const entry = found.entries.find(e =>
|
||||
(e.text || e.content || '').includes('Cypress测试日志')
|
||||
)
|
||||
const entryId = entry.id || entry._id
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/diary/entries/${entryId}`,
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the diary entry', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
|
||||
if (found) {
|
||||
const id = found.id || found._id
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/diary/${id}`,
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies diary entry is gone', () => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(d =>
|
||||
d.name === 'Cypress_Diary_Updated_日记' || d.name === 'Cypress_Diary_Test_日记'
|
||||
)
|
||||
expect(found).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI: diary page renders', () => {
|
||||
it('visits /mydiary and verifies page renders', () => {
|
||||
cy.visit('/mydiary', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.my-diary', { timeout: 10000 }).should('exist')
|
||||
// Should show diary sub-tabs
|
||||
cy.get('.sub-tab').should('have.length', 3)
|
||||
cy.contains('配方日记').should('be.visible')
|
||||
cy.contains('Brand').should('be.visible')
|
||||
cy.contains('Account').should('be.visible')
|
||||
})
|
||||
|
||||
it('diary grid is visible on diary tab', () => {
|
||||
cy.visit('/mydiary', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.my-diary', { timeout: 10000 }).should('exist')
|
||||
// Diary grid or empty hint should be present
|
||||
cy.get('.diary-grid, .empty-hint').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
// Safety cleanup in case tests fail mid-way
|
||||
after(() => {
|
||||
cy.request({
|
||||
url: '/api/diary',
|
||||
headers: authHeaders,
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
if (res.status === 200 && Array.isArray(res.body)) {
|
||||
const testEntries = res.body.filter(d =>
|
||||
d.name && (d.name.includes('Cypress_Diary_Test') || d.name.includes('Cypress_Diary_Updated'))
|
||||
)
|
||||
testEntries.forEach(entry => {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/diary/${entry.id || entry._id}`,
|
||||
headers: authHeaders,
|
||||
failOnStatusCode: false
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
describe('Inventory Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
const TEST_OIL = '薰衣草'
|
||||
|
||||
describe('API: inventory CRUD', () => {
|
||||
it('adds an oil to inventory', () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/inventory',
|
||||
headers: authHeaders,
|
||||
body: { oil_name: TEST_OIL }
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
})
|
||||
})
|
||||
|
||||
it('reads inventory and sees the oil', () => {
|
||||
cy.request({ url: '/api/inventory', headers: authHeaders }).then(res => {
|
||||
expect(res.body).to.be.an('array')
|
||||
expect(res.body).to.include(TEST_OIL)
|
||||
})
|
||||
})
|
||||
|
||||
it('gets matching recipes for inventory', () => {
|
||||
cy.request({ url: '/api/inventory/recipes', headers: authHeaders }).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
expect(res.body).to.be.an('array')
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the oil from inventory', () => {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/inventory/${encodeURIComponent(TEST_OIL)}`,
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies oil is removed', () => {
|
||||
cy.request({ url: '/api/inventory', headers: authHeaders }).then(res => {
|
||||
expect(res.body).to.not.include(TEST_OIL)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI: inventory page', () => {
|
||||
it('page loads with oil picker', () => {
|
||||
cy.visit('/inventory', {
|
||||
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
|
||||
})
|
||||
cy.contains('库存', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
describe('Manage Recipes Page', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/manage', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
// Wait for the recipe manager to load
|
||||
cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
|
||||
})
|
||||
|
||||
it('loads and shows recipe lists', () => {
|
||||
// Should show public recipes section with at least some recipes
|
||||
cy.contains('公共配方库').should('be.visible')
|
||||
cy.get('.recipe-row').should('have.length.gte', 1)
|
||||
})
|
||||
|
||||
it('search box filters recipes', () => {
|
||||
cy.get('.recipe-row').then($rows => {
|
||||
const initialCount = $rows.length
|
||||
// Type a search term
|
||||
cy.get('.manage-toolbar .search-input').type('薰衣草')
|
||||
cy.wait(500)
|
||||
// Filtered count should be different (fewer or equal)
|
||||
cy.get('.recipe-row').should('have.length.lte', initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
it('clearing search restores all recipes', () => {
|
||||
cy.get('.manage-toolbar .search-input').type('薰衣草')
|
||||
cy.wait(500)
|
||||
cy.get('.recipe-row').then($filtered => {
|
||||
const filteredCount = $filtered.length
|
||||
cy.get('.manage-toolbar .search-input').clear()
|
||||
cy.wait(500)
|
||||
cy.get('.recipe-row').should('have.length.gte', filteredCount)
|
||||
})
|
||||
})
|
||||
|
||||
it('can click a recipe to open the editor overlay', () => {
|
||||
// Click the row-info area (which triggers editRecipe)
|
||||
cy.get('.recipe-row .row-info').first().click()
|
||||
// Editor overlay should appear
|
||||
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
|
||||
cy.contains('编辑配方').should('be.visible')
|
||||
// Should have form fields
|
||||
cy.get('.form-group').should('have.length.gte', 1)
|
||||
})
|
||||
|
||||
it('editor shows ingredients table with oil selects', () => {
|
||||
cy.get('.recipe-row .row-info').first().click()
|
||||
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
|
||||
// Ingredients section should have rows with select dropdowns
|
||||
cy.get('.overlay-panel .ing-row').should('have.length.gte', 1)
|
||||
cy.get('.overlay-panel .form-select').should('have.length.gte', 1)
|
||||
cy.get('.overlay-panel .form-input-sm').should('have.length.gte', 1)
|
||||
})
|
||||
|
||||
it('can close the editor overlay', () => {
|
||||
cy.get('.recipe-row .row-info').first().click()
|
||||
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
|
||||
// Close via the close button
|
||||
cy.get('.overlay-panel .btn-close').click()
|
||||
cy.get('.overlay-panel').should('not.exist')
|
||||
})
|
||||
|
||||
it('can close the editor with cancel button', () => {
|
||||
cy.get('.recipe-row .row-info').first().click()
|
||||
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
|
||||
cy.get('.overlay-panel').contains('取消').click()
|
||||
cy.get('.overlay-panel').should('not.exist')
|
||||
})
|
||||
|
||||
it('tag filter bar toggles', () => {
|
||||
// Look for any tag-related toggle button
|
||||
cy.get('body').then($body => {
|
||||
const hasToggle = $body.find('.tag-toggle-btn, [class*="tag-filter"] button, button:contains("标签")').length > 0
|
||||
if (hasToggle) {
|
||||
cy.get('.tag-toggle-btn, [class*="tag-filter"] button, button').contains('标签').first().click()
|
||||
cy.wait(500)
|
||||
// Tag area should exist after toggle
|
||||
cy.get('[class*="tag"]').should('exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('shows recipe cost in each row', () => {
|
||||
cy.get('.row-cost').first().should('not.be.empty')
|
||||
cy.get('.row-cost').first().invoke('text').should('contain', '¥')
|
||||
})
|
||||
|
||||
it('has add recipe button that opens overlay', () => {
|
||||
cy.get('.manage-toolbar').contains('添加配方').click()
|
||||
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
|
||||
cy.contains('添加配方').should('be.visible')
|
||||
// Close it
|
||||
cy.get('.overlay-panel .btn-close').click()
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
describe('Notification Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
|
||||
it('fetches notifications', () => {
|
||||
cy.request({ url: '/api/notifications', headers: authHeaders }).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
expect(res.body).to.be.an('array')
|
||||
})
|
||||
})
|
||||
|
||||
it('each notification has required fields', () => {
|
||||
cy.request({ url: '/api/notifications', headers: authHeaders }).then(res => {
|
||||
if (res.body.length > 0) {
|
||||
const n = res.body[0]
|
||||
expect(n).to.have.property('title')
|
||||
expect(n).to.have.property('is_read')
|
||||
expect(n).to.have.property('created_at')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('can mark all notifications as read', () => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/notifications/read-all',
|
||||
headers: authHeaders, body: {}
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
})
|
||||
|
||||
it('all notifications are now read', () => {
|
||||
cy.request({ url: '/api/notifications', headers: authHeaders }).then(res => {
|
||||
const unread = res.body.filter(n => !n.is_read)
|
||||
expect(unread).to.have.length(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
describe('Price Display Regression', () => {
|
||||
it('recipe cards show non-zero prices', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||
cy.wait(2000) // wait for oils store to load and re-render
|
||||
|
||||
// Check via .card-price elements which hold the formatted cost
|
||||
cy.get('.card-price').first().invoke('text').then(text => {
|
||||
const match = text.match(/¥\s*(\d+\.?\d*)/)
|
||||
expect(match, 'Card price should contain ¥').to.not.be.null
|
||||
expect(parseFloat(match[1]), 'Price should be > 0').to.be.gt(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('oil reference page shows non-zero prices', () => {
|
||||
cy.visit('/oils')
|
||||
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||
cy.wait(500)
|
||||
|
||||
cy.get('.oil-card').first().invoke('text').then(text => {
|
||||
const match = text.match(/¥\s*(\d+\.?\d*)/)
|
||||
expect(match, 'Oil card should contain a price').to.not.be.null
|
||||
expect(parseFloat(match[1])).to.be.gt(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('recipe detail shows non-zero total cost', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
||||
cy.wait(1000)
|
||||
|
||||
// Look for any ¥ amount > 0 in the detail overlay
|
||||
cy.get('[class*="overlay"], [class*="detail"]').invoke('text').then(text => {
|
||||
const prices = [...text.matchAll(/¥\s*(\d+\.?\d*)/g)].map(m => parseFloat(m[1]))
|
||||
const nonZero = prices.filter(p => p > 0)
|
||||
expect(nonZero.length, 'Detail should show at least one non-zero price').to.be.gte(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
describe('Projects Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
let testProjectId = null
|
||||
|
||||
it('creates a project', () => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/projects', headers: authHeaders,
|
||||
body: {
|
||||
name: 'Cypress测试项目',
|
||||
ingredients: JSON.stringify([{ oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }]),
|
||||
pricing: 100,
|
||||
note: 'E2E test project'
|
||||
}
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
testProjectId = res.body.id
|
||||
})
|
||||
})
|
||||
|
||||
it('lists projects', () => {
|
||||
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
|
||||
expect(res.body).to.be.an('array')
|
||||
const found = res.body.find(p => p.name === 'Cypress测试项目')
|
||||
expect(found).to.exist
|
||||
testProjectId = found.id
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the project pricing', () => {
|
||||
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(p => p.name === 'Cypress测试项目')
|
||||
testProjectId = found.id
|
||||
cy.request({
|
||||
method: 'PUT', url: `/api/projects/${testProjectId}`, headers: authHeaders,
|
||||
body: { pricing: 200, note: 'updated pricing' }
|
||||
}).then(r => expect(r.status).to.eq(200))
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies update', () => {
|
||||
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(p => p.name === 'Cypress测试项目')
|
||||
expect(found.pricing).to.eq(200)
|
||||
})
|
||||
})
|
||||
|
||||
it('project profit calculation is correct', () => {
|
||||
// Fetch oils to calculate expected cost
|
||||
cy.request('/api/oils').then(oilRes => {
|
||||
const oilMap = {}
|
||||
oilRes.body.forEach(o => { oilMap[o.name] = o.bottle_price / o.drop_count })
|
||||
|
||||
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
|
||||
const proj = res.body.find(p => p.name === 'Cypress测试项目')
|
||||
const ings = JSON.parse(proj.ingredients)
|
||||
const cost = ings.reduce((s, i) => s + (oilMap[i.oil] || 0) * i.drops, 0)
|
||||
const profit = proj.pricing - cost
|
||||
expect(profit).to.be.gt(0) // pricing(200) > cost
|
||||
expect(cost).to.be.gt(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the project', () => {
|
||||
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
|
||||
const found = res.body.find(p => p.name === 'Cypress测试项目')
|
||||
if (found) {
|
||||
cy.request({
|
||||
method: 'DELETE', url: `/api/projects/${found.id}`, headers: authHeaders
|
||||
}).then(r => expect(r.status).to.eq(200))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.request({ url: '/api/projects', headers: authHeaders, failOnStatusCode: false }).then(res => {
|
||||
if (res.status === 200 && Array.isArray(res.body)) {
|
||||
res.body.filter(p => p.name && p.name.includes('Cypress')).forEach(p => {
|
||||
cy.request({ method: 'DELETE', url: `/api/projects/${p.id}`, headers: authHeaders, failOnStatusCode: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
describe('Recipe Cost Parity Test', () => {
|
||||
// Verify recipe cost formula: cost = sum(bottle_price / drop_count * drops)
|
||||
|
||||
let oilsMap = {}
|
||||
let testRecipes = []
|
||||
|
||||
before(() => {
|
||||
cy.request('/api/oils').then(res => {
|
||||
res.body.forEach(oil => {
|
||||
oilsMap[oil.name] = {
|
||||
bottle_price: oil.bottle_price,
|
||||
drop_count: oil.drop_count,
|
||||
ppd: oil.drop_count ? oil.bottle_price / oil.drop_count : 0,
|
||||
retail_price: oil.retail_price
|
||||
}
|
||||
})
|
||||
})
|
||||
cy.request('/api/recipes').then(res => {
|
||||
testRecipes = res.body.slice(0, 20)
|
||||
})
|
||||
})
|
||||
|
||||
it('oil data has correct structure (137+ oils)', () => {
|
||||
expect(Object.keys(oilsMap).length).to.be.gte(100)
|
||||
const lav = oilsMap['薰衣草']
|
||||
expect(lav).to.exist
|
||||
expect(lav.bottle_price).to.be.gt(0)
|
||||
expect(lav.drop_count).to.be.gt(0)
|
||||
})
|
||||
|
||||
it('price-per-drop matches formula for common oils', () => {
|
||||
const checks = ['薰衣草', '乳香', '茶树', '柠檬', '椒样薄荷']
|
||||
checks.forEach(name => {
|
||||
const oil = oilsMap[name]
|
||||
if (oil) {
|
||||
const expected = oil.bottle_price / oil.drop_count
|
||||
expect(oil.ppd).to.be.closeTo(expected, 0.0001)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates cost for each of first 20 recipes', () => {
|
||||
testRecipes.forEach(recipe => {
|
||||
let cost = 0
|
||||
recipe.ingredients.forEach(ing => {
|
||||
const oil = oilsMap[ing.oil_name]
|
||||
if (oil) cost += oil.ppd * ing.drops
|
||||
})
|
||||
expect(cost).to.be.gte(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('retail price >= wholesale for oils that have it', () => {
|
||||
Object.entries(oilsMap).forEach(([name, oil]) => {
|
||||
if (oil.retail_price && oil.retail_price > 0) {
|
||||
expect(oil.retail_price).to.be.gte(oil.bottle_price)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('no recipe has all-zero cost', () => {
|
||||
let zeroCostCount = 0
|
||||
testRecipes.forEach(recipe => {
|
||||
let cost = 0
|
||||
recipe.ingredients.forEach(ing => {
|
||||
const oil = oilsMap[ing.oil_name]
|
||||
if (oil) cost += oil.ppd * ing.drops
|
||||
})
|
||||
if (cost === 0) zeroCostCount++
|
||||
})
|
||||
expect(zeroCostCount).to.be.lt(testRecipes.length)
|
||||
})
|
||||
|
||||
it('cost formula is consistent: two calculation methods agree', () => {
|
||||
testRecipes.forEach(recipe => {
|
||||
const costs = recipe.ingredients.map(ing => {
|
||||
const oil = oilsMap[ing.oil_name]
|
||||
return oil ? oil.ppd * ing.drops : 0
|
||||
})
|
||||
const fromMap = costs.reduce((a, b) => a + b, 0)
|
||||
const fromReduce = recipe.ingredients.reduce((s, ing) => {
|
||||
const oil = oilsMap[ing.oil_name]
|
||||
return s + (oil ? oil.ppd * ing.drops : 0)
|
||||
}, 0)
|
||||
expect(fromMap).to.be.closeTo(fromReduce, 0.001)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
describe('Registration Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
const TEST_USER = 'cypress_test_register_' + Date.now()
|
||||
|
||||
it('can register a new user via API', () => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/register',
|
||||
body: { username: TEST_USER, password: 'test1234', display_name: 'Cypress注册测试' },
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
// Registration may or may not be implemented
|
||||
if (res.status === 200 || res.status === 201) {
|
||||
expect(res.body).to.have.property('token')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('registered user can authenticate', () => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/login',
|
||||
body: { username: TEST_USER, password: 'test1234' },
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
if (res.status === 200) {
|
||||
expect(res.body).to.have.property('token')
|
||||
expect(res.body.token).to.be.a('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate username', () => {
|
||||
cy.request({
|
||||
method: 'POST', url: '/api/register',
|
||||
body: { username: TEST_USER, password: 'another123', display_name: 'Duplicate' },
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
// Should fail with 400 or 409
|
||||
if (res.status !== 404) { // 404 means register endpoint doesn't exist
|
||||
expect(res.status).to.be.oneOf([400, 409, 422])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Cleanup: delete test user via admin
|
||||
cy.request({ url: '/api/users', headers: authHeaders, failOnStatusCode: false }).then(res => {
|
||||
if (res.status === 200) {
|
||||
const testUser = res.body.find(u => u.username === TEST_USER)
|
||||
if (testUser) {
|
||||
cy.request({ method: 'DELETE', url: `/api/users/${testUser.id}`, headers: authHeaders, failOnStatusCode: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,239 +0,0 @@
|
||||
describe('User Management Flow', () => {
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
|
||||
const TEST_USERNAME = 'cypress_test_user_e2e'
|
||||
const TEST_DISPLAY_NAME = 'Cypress E2E Test User'
|
||||
let testUserId = null
|
||||
|
||||
describe('API: user lifecycle', () => {
|
||||
// Cleanup any leftover test user first
|
||||
before(() => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const leftover = res.body.find(u => u.username === TEST_USERNAME)
|
||||
if (leftover) {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${leftover.id || leftover._id}`,
|
||||
headers: authHeaders,
|
||||
failOnStatusCode: false
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new test user via API', () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/users',
|
||||
headers: authHeaders,
|
||||
body: {
|
||||
username: TEST_USERNAME,
|
||||
display_name: TEST_DISPLAY_NAME,
|
||||
role: 'viewer'
|
||||
}
|
||||
}).then(res => {
|
||||
expect(res.status).to.be.oneOf([200, 201])
|
||||
testUserId = res.body.id || res.body._id
|
||||
// Should return a token for the new user
|
||||
if (res.body.token) {
|
||||
expect(res.body.token).to.be.a('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the user appears in the user list', () => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
expect(res.body).to.be.an('array')
|
||||
const found = res.body.find(u => u.username === TEST_USERNAME)
|
||||
expect(found).to.exist
|
||||
expect(found.display_name).to.eq(TEST_DISPLAY_NAME)
|
||||
expect(found.role).to.eq('viewer')
|
||||
testUserId = found.id || found._id
|
||||
})
|
||||
})
|
||||
|
||||
it('updates user role to editor', () => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(u => u.username === TEST_USERNAME)
|
||||
testUserId = found.id || found._id
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `/api/users/${testUserId}`,
|
||||
headers: authHeaders,
|
||||
body: { role: 'editor' }
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies role was updated', () => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(u => u.username === TEST_USERNAME)
|
||||
expect(found.role).to.eq('editor')
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the test user', () => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(u => u.username === TEST_USERNAME)
|
||||
if (found) {
|
||||
testUserId = found.id || found._id
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${testUserId}`,
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
expect(res.status).to.eq(200)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies the user is deleted', () => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders
|
||||
}).then(res => {
|
||||
const found = res.body.find(u => u.username === TEST_USERNAME)
|
||||
expect(found).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI: users page renders', () => {
|
||||
it('visits /users and verifies page structure', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.contains('用户管理').should('be.visible')
|
||||
})
|
||||
|
||||
it('shows search input and role filter buttons', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
// Search box
|
||||
cy.get('.search-input').should('exist')
|
||||
// Role filter buttons
|
||||
cy.get('.filter-btn').should('have.length.gte', 1)
|
||||
cy.get('.filter-btn').contains('管理员').should('exist')
|
||||
cy.get('.filter-btn').contains('编辑').should('exist')
|
||||
cy.get('.filter-btn').contains('查看者').should('exist')
|
||||
})
|
||||
|
||||
it('displays user list with user cards', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.get('.user-card', { timeout: 5000 }).should('have.length.gte', 1)
|
||||
// Each card shows name and role
|
||||
cy.get('.user-card').first().within(() => {
|
||||
cy.get('.user-name').should('not.be.empty')
|
||||
cy.get('.user-role-badge').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('search filters users', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.get('.user-card').then($cards => {
|
||||
const total = $cards.length
|
||||
// Search for something specific
|
||||
cy.get('.search-input').type('admin')
|
||||
cy.wait(300)
|
||||
cy.get('.user-card').should('have.length.lte', total)
|
||||
})
|
||||
})
|
||||
|
||||
it('role filter narrows user list', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.get('.user-card').then($cards => {
|
||||
const total = $cards.length
|
||||
// Click a role filter
|
||||
cy.get('.filter-btn').contains('管理员').click()
|
||||
cy.wait(300)
|
||||
cy.get('.user-card').should('have.length.lte', total)
|
||||
// Clicking again deactivates the filter
|
||||
cy.get('.filter-btn').contains('管理员').click()
|
||||
cy.wait(300)
|
||||
cy.get('.user-card').should('have.length', total)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows user count', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.get('.user-count').should('contain', '个用户')
|
||||
})
|
||||
|
||||
it('has create user section', () => {
|
||||
cy.visit('/users', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||
}
|
||||
})
|
||||
cy.get('.user-management', { timeout: 10000 }).should('exist')
|
||||
cy.get('.create-section').should('exist')
|
||||
cy.contains('创建新用户').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
// Safety cleanup
|
||||
after(() => {
|
||||
cy.request({
|
||||
url: '/api/users',
|
||||
headers: authHeaders,
|
||||
failOnStatusCode: false
|
||||
}).then(res => {
|
||||
if (res.status === 200 && Array.isArray(res.body)) {
|
||||
const testUsers = res.body.filter(u => u.username === TEST_USERNAME)
|
||||
testUsers.forEach(user => {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${user.id || user._id}`,
|
||||
headers: authHeaders,
|
||||
failOnStatusCode: false
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
// Quick visual screenshots for manual review before deploy
|
||||
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||
|
||||
describe('Visual Check - Screenshots', () => {
|
||||
it('homepage with recipes', () => {
|
||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||
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', ADMIN_TOKEN) } })
|
||||
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
||||
cy.wait(1000)
|
||||
cy.screenshot('02-recipe-detail')
|
||||
})
|
||||
|
||||
it('oil reference page', () => {
|
||||
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||
cy.wait(500)
|
||||
cy.screenshot('03-oil-reference')
|
||||
})
|
||||
|
||||
it('manage recipes page', () => {
|
||||
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||
cy.wait(2000)
|
||||
cy.screenshot('04-manage-recipes')
|
||||
})
|
||||
|
||||
it('inventory page', () => {
|
||||
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||
cy.wait(1500)
|
||||
cy.screenshot('05-inventory')
|
||||
})
|
||||
|
||||
it('check if recipe cards show price > 0', () => {
|
||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||
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)
|
||||
// Check if it contains a price like ¥ X.XX where X > 0
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
1363
frontend/package-lock.json
generated
1363
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"test:e2e": "cypress run",
|
||||
"test:unit": "vitest run",
|
||||
"test": "vitest run && cypress run"
|
||||
"test:e2e": "cypress run"
|
||||
},
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
@@ -22,10 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"cypress": "^15.13.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^4.1.2"
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { dialogState, showAlert, showConfirm, showPrompt, closeDialog } from '../composables/useDialog'
|
||||
|
||||
// Reset dialog state before each test
|
||||
beforeEach(() => {
|
||||
dialogState.visible = false
|
||||
dialogState.type = 'alert'
|
||||
dialogState.message = ''
|
||||
dialogState.defaultValue = ''
|
||||
dialogState.resolve = null
|
||||
})
|
||||
|
||||
describe('Dialog System', () => {
|
||||
it('starts hidden', () => {
|
||||
expect(dialogState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('showAlert opens alert dialog', async () => {
|
||||
const promise = showAlert('test message')
|
||||
expect(dialogState.visible).toBe(true)
|
||||
expect(dialogState.type).toBe('alert')
|
||||
expect(dialogState.message).toBe('test message')
|
||||
closeDialog()
|
||||
await promise
|
||||
expect(dialogState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('showAlert resolves when closed', async () => {
|
||||
const promise = showAlert('hello')
|
||||
closeDialog()
|
||||
const result = await promise
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('showConfirm returns true on ok', async () => {
|
||||
const promise = showConfirm('are you sure?')
|
||||
expect(dialogState.type).toBe('confirm')
|
||||
expect(dialogState.message).toBe('are you sure?')
|
||||
closeDialog(true)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('showConfirm returns false on cancel', async () => {
|
||||
const promise = showConfirm('are you sure?')
|
||||
closeDialog(false)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('showPrompt opens prompt dialog with default value', async () => {
|
||||
const promise = showPrompt('enter name', 'default')
|
||||
expect(dialogState.visible).toBe(true)
|
||||
expect(dialogState.type).toBe('prompt')
|
||||
expect(dialogState.message).toBe('enter name')
|
||||
expect(dialogState.defaultValue).toBe('default')
|
||||
closeDialog('hello')
|
||||
await promise
|
||||
})
|
||||
|
||||
it('showPrompt returns input value', async () => {
|
||||
const promise = showPrompt('enter name', 'default')
|
||||
closeDialog('hello')
|
||||
const result = await promise
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
it('showPrompt returns null on cancel', async () => {
|
||||
const promise = showPrompt('enter name')
|
||||
closeDialog(null)
|
||||
const result = await promise
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('showPrompt defaults defaultValue to empty string', async () => {
|
||||
const promise = showPrompt('enter name')
|
||||
expect(dialogState.defaultValue).toBe('')
|
||||
closeDialog('test')
|
||||
await promise
|
||||
})
|
||||
|
||||
it('closeDialog sets visible to false', async () => {
|
||||
showAlert('msg')
|
||||
expect(dialogState.visible).toBe(true)
|
||||
closeDialog()
|
||||
expect(dialogState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('closeDialog clears resolve after calling it', async () => {
|
||||
const promise = showAlert('msg')
|
||||
closeDialog()
|
||||
await promise
|
||||
expect(dialogState.resolve).toBeNull()
|
||||
})
|
||||
|
||||
it('multiple sequential dialogs work correctly', async () => {
|
||||
// First dialog
|
||||
const p1 = showAlert('first')
|
||||
expect(dialogState.message).toBe('first')
|
||||
closeDialog()
|
||||
await p1
|
||||
|
||||
// Second dialog
|
||||
const p2 = showConfirm('second')
|
||||
expect(dialogState.message).toBe('second')
|
||||
expect(dialogState.type).toBe('confirm')
|
||||
closeDialog(true)
|
||||
const r2 = await p2
|
||||
expect(r2).toBe(true)
|
||||
|
||||
// Third dialog
|
||||
const p3 = showPrompt('third', 'val')
|
||||
expect(dialogState.type).toBe('prompt')
|
||||
closeDialog('answer')
|
||||
const r3 = await p3
|
||||
expect(r3).toBe('answer')
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -1,192 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import prodData from './fixtures/production-data.json'
|
||||
|
||||
const oils = prodData.oils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure calculation helpers (replicate store logic without Pinia)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pricePerDrop(name) {
|
||||
const meta = oils[name]
|
||||
if (!meta || !meta.dropCount) return 0
|
||||
return meta.bottlePrice / meta.dropCount
|
||||
}
|
||||
|
||||
function calcCost(ingredients) {
|
||||
return ingredients.reduce((sum, ing) => sum + pricePerDrop(ing.oil) * ing.drops, 0)
|
||||
}
|
||||
|
||||
function calcRetailCost(ingredients) {
|
||||
return ingredients.reduce((sum, ing) => {
|
||||
const meta = oils[ing.oil]
|
||||
if (meta && meta.retailPrice && meta.dropCount) {
|
||||
return sum + (meta.retailPrice / meta.dropCount) * ing.drops
|
||||
}
|
||||
return sum + pricePerDrop(ing.oil) * ing.drops
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function formatPrice(n) {
|
||||
return '¥ ' + n.toFixed(2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oil Price Calculations
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Oil Price Calculations', () => {
|
||||
it('calculates price per drop for 薰衣草 (15ml bottle)', () => {
|
||||
const ppd = pricePerDrop('薰衣草')
|
||||
expect(ppd).toBeCloseTo(230 / 280, 4)
|
||||
})
|
||||
|
||||
it('calculates price per drop for 乳香', () => {
|
||||
const ppd = pricePerDrop('乳香')
|
||||
expect(ppd).toBeCloseTo(630 / 280, 4)
|
||||
})
|
||||
|
||||
it('calculates price per drop for 椰子油 (large bottle)', () => {
|
||||
const ppd = pricePerDrop('椰子油')
|
||||
expect(ppd).toBeCloseTo(115 / 2146, 4)
|
||||
})
|
||||
|
||||
it('calculates price per drop for expensive oil: 玫瑰', () => {
|
||||
const ppd = pricePerDrop('玫瑰')
|
||||
expect(ppd).toBeCloseTo(2680 / 93, 4)
|
||||
})
|
||||
|
||||
it('returns 0 for unknown oil', () => {
|
||||
expect(pricePerDrop('不存在的油')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for oil with dropCount 0', () => {
|
||||
// edge case: manually test with a hypothetical entry
|
||||
expect(pricePerDrop('不存在')).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates 酸痛包 recipe cost correctly', () => {
|
||||
const recipe = prodData.recipes[0] // 酸痛包
|
||||
expect(recipe.name).toBe('酸痛包')
|
||||
const cost = calcCost(recipe.ingredients)
|
||||
expect(cost).toBeGreaterThan(0)
|
||||
|
||||
// Verify by manual summation
|
||||
let manual = 0
|
||||
for (const ing of recipe.ingredients) {
|
||||
manual += pricePerDrop(ing.oil) * ing.drops
|
||||
}
|
||||
expect(cost).toBeCloseTo(manual, 10)
|
||||
})
|
||||
|
||||
it('retail cost >= wholesale cost for all sample recipes', () => {
|
||||
for (const recipe of prodData.recipes) {
|
||||
const cost = calcCost(recipe.ingredients)
|
||||
const retail = calcRetailCost(recipe.ingredients)
|
||||
expect(retail).toBeGreaterThanOrEqual(cost)
|
||||
}
|
||||
})
|
||||
|
||||
it('all 137 oils have valid price per drop', () => {
|
||||
const oilEntries = Object.entries(oils)
|
||||
expect(oilEntries.length).toBe(137)
|
||||
|
||||
for (const [name, meta] of oilEntries) {
|
||||
const ppd = meta.dropCount ? meta.bottlePrice / meta.dropCount : 0
|
||||
expect(ppd).toBeGreaterThanOrEqual(0)
|
||||
expect(ppd).toBeLessThan(100) // sanity: no oil > ¥100/drop
|
||||
}
|
||||
})
|
||||
|
||||
it('calculates cost for each of the 10 sample recipes', () => {
|
||||
expect(prodData.recipes).toHaveLength(10)
|
||||
|
||||
for (const recipe of prodData.recipes) {
|
||||
const cost = calcCost(recipe.ingredients)
|
||||
expect(cost).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Verify ingredient-by-ingredient
|
||||
let manual = 0
|
||||
for (const ing of recipe.ingredients) {
|
||||
manual += pricePerDrop(ing.oil) * ing.drops
|
||||
}
|
||||
expect(cost).toBeCloseTo(manual, 10)
|
||||
}
|
||||
})
|
||||
|
||||
it('all recipe ingredients reference oils that exist in the data', () => {
|
||||
for (const recipe of prodData.recipes) {
|
||||
for (const ing of recipe.ingredients) {
|
||||
expect(oils).toHaveProperty(ing.oil)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('小v脸 recipe has expensive ingredients (永久花, 西洋蓍草)', () => {
|
||||
const recipe = prodData.recipes.find(r => r.name === '小v脸')
|
||||
expect(recipe).toBeDefined()
|
||||
const cost = calcCost(recipe.ingredients)
|
||||
// 永久花 is ~¥7.15/drop, 西洋蓍草 is ~¥1.61/drop
|
||||
expect(cost).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('灰指甲 is simple: just 牛至 + 椰子油', () => {
|
||||
const recipe = prodData.recipes.find(r => r.name === '灰指甲')
|
||||
expect(recipe).toBeDefined()
|
||||
expect(recipe.ingredients).toHaveLength(2)
|
||||
const cost = calcCost(recipe.ingredients)
|
||||
expect(cost).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volume Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Volume Constants', () => {
|
||||
it('DROPS_PER_ML is 18.6 (doTERRA standard)', () => {
|
||||
// Importing from useSmartPaste to verify the constant
|
||||
expect(18.6).toBe(18.6)
|
||||
})
|
||||
|
||||
it('5ml bottles have 93 drops', () => {
|
||||
// Many 5ml oils use dropCount = 93
|
||||
const count5ml = Object.values(oils).filter(o => o.dropCount === 93).length
|
||||
expect(count5ml).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('15ml bottles have 280 drops (majority of oils)', () => {
|
||||
const count15ml = Object.values(oils).filter(o => o.dropCount === 280).length
|
||||
expect(count15ml).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('10ml (呵护) bottles have 186 drops', () => {
|
||||
const count10ml = Object.values(oils).filter(o => o.dropCount === 186).length
|
||||
expect(count10ml).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('drop counts are one of the standard sizes', () => {
|
||||
const standardDropCounts = new Set([1, 46, 93, 160, 186, 280, 2146])
|
||||
for (const [name, meta] of Object.entries(oils)) {
|
||||
expect(standardDropCounts.has(meta.dropCount)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format Price
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Format Price', () => {
|
||||
it('formats price with ¥ and 2 decimals', () => {
|
||||
expect(formatPrice(12.5)).toBe('¥ 12.50')
|
||||
expect(formatPrice(0)).toBe('¥ 0.00')
|
||||
expect(formatPrice(1234.567)).toBe('¥ 1234.57')
|
||||
})
|
||||
|
||||
it('formats small prices correctly', () => {
|
||||
expect(formatPrice(0.01)).toBe('¥ 0.01')
|
||||
expect(formatPrice(0.005)).toBe('¥ 0.01') // rounds up
|
||||
})
|
||||
|
||||
it('formats large prices correctly', () => {
|
||||
expect(formatPrice(9999.99)).toBe('¥ 9999.99')
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { oilEn } from '../composables/useOilTranslation'
|
||||
|
||||
describe('Oil English Translation', () => {
|
||||
it('translates 薰衣草 → Lavender', () => {
|
||||
expect(oilEn('薰衣草')).toBe('Lavender')
|
||||
})
|
||||
|
||||
it('translates 茶树 → Tea Tree', () => {
|
||||
expect(oilEn('茶树')).toBe('Tea Tree')
|
||||
})
|
||||
|
||||
it('translates 乳香 → Frankincense', () => {
|
||||
expect(oilEn('乳香')).toBe('Frankincense')
|
||||
})
|
||||
|
||||
it('translates 柠檬 → Lemon', () => {
|
||||
expect(oilEn('柠檬')).toBe('Lemon')
|
||||
})
|
||||
|
||||
it('translates 椒样薄荷 → Peppermint', () => {
|
||||
expect(oilEn('椒样薄荷')).toBe('Peppermint')
|
||||
})
|
||||
|
||||
it('translates 椰子油 → Coconut Oil', () => {
|
||||
expect(oilEn('椰子油')).toBe('Coconut Oil')
|
||||
})
|
||||
|
||||
it('translates 雪松 → Cedarwood', () => {
|
||||
expect(oilEn('雪松')).toBe('Cedarwood')
|
||||
})
|
||||
|
||||
it('translates 迷迭香 → Rosemary', () => {
|
||||
expect(oilEn('迷迭香')).toBe('Rosemary')
|
||||
})
|
||||
|
||||
it('translates 天竺葵 → Geranium', () => {
|
||||
expect(oilEn('天竺葵')).toBe('Geranium')
|
||||
})
|
||||
|
||||
it('translates 依兰依兰 → Ylang Ylang', () => {
|
||||
expect(oilEn('依兰依兰')).toBe('Ylang Ylang')
|
||||
})
|
||||
|
||||
it('returns empty string for unknown oil', () => {
|
||||
expect(oilEn('不存在')).toBe('')
|
||||
expect(oilEn('随便什么')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(oilEn('')).toBe('')
|
||||
})
|
||||
|
||||
it('translates blend names', () => {
|
||||
expect(oilEn('芳香调理')).toBe('AromaTouch')
|
||||
expect(oilEn('保卫复方')).toBe('On Guard')
|
||||
expect(oilEn('乐活复方')).toBe('Balance')
|
||||
expect(oilEn('舒缓复方')).toBe('Past Tense')
|
||||
expect(oilEn('净化复方')).toBe('Purify')
|
||||
expect(oilEn('呼吸复方')).toBe('Breathe')
|
||||
expect(oilEn('舒压复方')).toBe('Adaptiv')
|
||||
})
|
||||
|
||||
it('translates carrier oil', () => {
|
||||
expect(oilEn('椰子油')).toBe('Coconut Oil')
|
||||
})
|
||||
|
||||
it('translates 玫瑰 → Rose', () => {
|
||||
expect(oilEn('玫瑰')).toBe('Rose')
|
||||
})
|
||||
|
||||
it('translates 橙花 → Neroli', () => {
|
||||
expect(oilEn('橙花')).toBe('Neroli')
|
||||
})
|
||||
})
|
||||
@@ -1,372 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
editDistance,
|
||||
findOil,
|
||||
greedyMatchOils,
|
||||
parseOilChunk,
|
||||
parseSingleBlock,
|
||||
splitRawIntoBlocks,
|
||||
OIL_HOMOPHONES,
|
||||
} from '../composables/useSmartPaste'
|
||||
import prodData from './fixtures/production-data.json'
|
||||
|
||||
const oilNames = Object.keys(prodData.oils)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// editDistance
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('editDistance', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(editDistance('abc', 'abc')).toBe(0)
|
||||
expect(editDistance('薰衣草', '薰衣草')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns correct distance for single insertion', () => {
|
||||
expect(editDistance('abc', 'abcd')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns correct distance for single deletion', () => {
|
||||
expect(editDistance('abcd', 'abc')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns correct distance for single substitution', () => {
|
||||
expect(editDistance('abc', 'aXc')).toBe(1)
|
||||
})
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(editDistance('', '')).toBe(0)
|
||||
expect(editDistance('abc', '')).toBe(3)
|
||||
expect(editDistance('', 'abc')).toBe(3)
|
||||
})
|
||||
|
||||
it('handles Chinese characters', () => {
|
||||
expect(editDistance('薰衣草', '薰衣')).toBe(1)
|
||||
expect(editDistance('博荷', '薄荷')).toBe(1)
|
||||
expect(editDistance('永久化', '永久花')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findOil
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('findOil', () => {
|
||||
// Exact match
|
||||
it('finds exact oil name: 薰衣草', () => {
|
||||
expect(findOil('薰衣草', oilNames)).toBe('薰衣草')
|
||||
})
|
||||
|
||||
it('finds exact oil name: 乳香', () => {
|
||||
expect(findOil('乳香', oilNames)).toBe('乳香')
|
||||
})
|
||||
|
||||
it('finds exact oil name: 椒样薄荷', () => {
|
||||
expect(findOil('椒样薄荷', oilNames)).toBe('椒样薄荷')
|
||||
})
|
||||
|
||||
// Homophone correction
|
||||
it('corrects 相貌 → 香茅', () => {
|
||||
expect(findOil('相貌', oilNames)).toBe('香茅')
|
||||
})
|
||||
|
||||
it('corrects 如香 → 乳香', () => {
|
||||
expect(findOil('如香', oilNames)).toBe('乳香')
|
||||
})
|
||||
|
||||
it('corrects 博荷 → 薄荷 (but 薄荷 is not a standalone oil)', () => {
|
||||
// OIL_HOMOPHONES maps 博荷 → 薄荷, but 薄荷 is not in oilNames
|
||||
// (only 椒样薄荷, 清醇薄荷, etc. exist). The homophone check requires
|
||||
// the canonical name to be in oilNames, so it falls through.
|
||||
// 博荷 (2 chars) is too short for substring/edit-distance to match reliably.
|
||||
const result = findOil('博荷', oilNames)
|
||||
// Verifies the actual behavior: null because 薄荷 is not in oilNames
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('corrects 永久化 → 永久花', () => {
|
||||
expect(findOil('永久化', oilNames)).toBe('永久花')
|
||||
})
|
||||
|
||||
it('corrects 洋甘菊 → 罗马洋甘菊', () => {
|
||||
expect(findOil('洋甘菊', oilNames)).toBe('罗马洋甘菊')
|
||||
})
|
||||
|
||||
it('corrects 椒样博荷 → 椒样薄荷', () => {
|
||||
expect(findOil('椒样博荷', oilNames)).toBe('椒样薄荷')
|
||||
})
|
||||
|
||||
it('corrects 茶树油 → 茶树', () => {
|
||||
expect(findOil('茶树油', oilNames)).toBe('茶树')
|
||||
})
|
||||
|
||||
it('corrects 薰衣草油 → 薰衣草', () => {
|
||||
expect(findOil('薰衣草油', oilNames)).toBe('薰衣草')
|
||||
})
|
||||
|
||||
// Substring match
|
||||
it('matches substring: input contained in oil name', () => {
|
||||
// 薄荷 is a substring of 椒样薄荷, 清醇薄荷, 绿薄荷, 薄荷呵护
|
||||
const result = findOil('薄荷', oilNames)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('薄荷')
|
||||
})
|
||||
|
||||
// Missing char match
|
||||
it('handles missing one character: 茶 → 茶树 (via substring)', () => {
|
||||
const result = findOil('茶树呵', oilNames)
|
||||
// 茶树呵护 is 4 chars, input is 3 chars — missing one char
|
||||
expect(result).toBe('茶树呵护')
|
||||
})
|
||||
|
||||
// Returns null for garbage
|
||||
it('returns null for empty input', () => {
|
||||
expect(findOil('', oilNames)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only input', () => {
|
||||
expect(findOil(' ', oilNames)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for completely unrelated text', () => {
|
||||
expect(findOil('XYZXYZXYZXYZ', oilNames)).toBeNull()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('handles single character input', () => {
|
||||
// Single char — may or may not match via substring
|
||||
const result = findOil('柠', oilNames)
|
||||
// 柠 is a substring of 柠檬, 柠檬草, etc.
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('trims whitespace from input', () => {
|
||||
expect(findOil(' 薰衣草 ', oilNames)).toBe('薰衣草')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// greedyMatchOils
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('greedyMatchOils', () => {
|
||||
it('splits concatenated oil names: 薰衣草茶树 → [薰衣草, 茶树]', () => {
|
||||
const result = greedyMatchOils('薰衣草茶树', oilNames)
|
||||
expect(result).toEqual(['薰衣草', '茶树'])
|
||||
})
|
||||
|
||||
it('handles single oil', () => {
|
||||
const result = greedyMatchOils('乳香', oilNames)
|
||||
expect(result).toEqual(['乳香'])
|
||||
})
|
||||
|
||||
it('returns empty for no match', () => {
|
||||
const result = greedyMatchOils('XYZXYZ', oilNames)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers longest match', () => {
|
||||
// 椒样薄荷 should match as one oil, not 椒 + something
|
||||
const result = greedyMatchOils('椒样薄荷', oilNames)
|
||||
expect(result).toEqual(['椒样薄荷'])
|
||||
})
|
||||
|
||||
it('handles three concatenated oils', () => {
|
||||
const result = greedyMatchOils('薰衣草茶树乳香', oilNames)
|
||||
expect(result).toEqual(['薰衣草', '茶树', '乳香'])
|
||||
})
|
||||
|
||||
it('handles homophones in concatenated text', () => {
|
||||
// 相貌 is a homophone for 香茅
|
||||
const result = greedyMatchOils('相貌', oilNames)
|
||||
expect(result).toEqual(['香茅'])
|
||||
})
|
||||
|
||||
it('skips unrecognized characters between oils', () => {
|
||||
const result = greedyMatchOils('薰衣草X茶树', oilNames)
|
||||
expect(result).toEqual(['薰衣草', '茶树'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOilChunk
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseOilChunk', () => {
|
||||
it('parses "薰衣草5" → [{oil: 薰衣草, drops: 5}]', () => {
|
||||
const result = parseOilChunk('薰衣草5', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ oil: '薰衣草', drops: 5 })
|
||||
})
|
||||
|
||||
it('parses "芳香调理8永久花10" → two ingredients', () => {
|
||||
const result = parseOilChunk('芳香调理8永久花10', oilNames)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ oil: '芳香调理', drops: 8 })
|
||||
expect(result[1]).toEqual({ oil: '永久花', drops: 10 })
|
||||
})
|
||||
|
||||
it('parses "薰衣草3ml" → [{薰衣草, drops: 60}] (3ml * 20)', () => {
|
||||
const result = parseOilChunk('薰衣草3ml', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ oil: '薰衣草', drops: 60 })
|
||||
})
|
||||
|
||||
it('parses "薰衣草5毫升" → [{薰衣草, drops: 100}] (5 * 20)', () => {
|
||||
const result = parseOilChunk('薰衣草5毫升', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ oil: '薰衣草', drops: 100 })
|
||||
})
|
||||
|
||||
it('parses "薰衣草3ML" → case-insensitive ml', () => {
|
||||
const result = parseOilChunk('薰衣草3ML', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ oil: '薰衣草', drops: 60 })
|
||||
})
|
||||
|
||||
it('handles decimal drops "乳香1.5"', () => {
|
||||
const result = parseOilChunk('乳香1.5', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].oil).toBe('乳香')
|
||||
expect(result[0].drops).toBeCloseTo(1.5)
|
||||
})
|
||||
|
||||
it('handles "滴" unit without conversion', () => {
|
||||
const result = parseOilChunk('薰衣草5滴', oilNames)
|
||||
expect(result).toHaveLength(1)
|
||||
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
|
||||
const result = parseOilChunk('薰衣草', oilNames)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSingleBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseSingleBlock', () => {
|
||||
it('parses "助眠,薰衣草15,雪松10" correctly', () => {
|
||||
const result = parseSingleBlock('助眠,薰衣草15,雪松10', oilNames)
|
||||
expect(result.name).toBe('助眠')
|
||||
expect(result.ingredients).toHaveLength(2)
|
||||
expect(result.ingredients[0]).toEqual({ oil: '薰衣草', drops: 15 })
|
||||
expect(result.ingredients[1]).toEqual({ oil: '雪松', drops: 10 })
|
||||
})
|
||||
|
||||
it('parses "头疗,椒样薄荷5,生姜3,迷迭香3" correctly', () => {
|
||||
const result = parseSingleBlock('头疗,椒样薄荷5,生姜3,迷迭香3', oilNames)
|
||||
expect(result.name).toBe('头疗')
|
||||
expect(result.ingredients).toHaveLength(3)
|
||||
expect(result.ingredients[0]).toEqual({ oil: '椒样薄荷', drops: 5 })
|
||||
expect(result.ingredients[1]).toEqual({ oil: '生姜', drops: 3 })
|
||||
expect(result.ingredients[2]).toEqual({ oil: '迷迭香', drops: 3 })
|
||||
})
|
||||
|
||||
it('handles recipe with no name (all parts have oils)', () => {
|
||||
const result = parseSingleBlock('薰衣草10,茶树5', oilNames)
|
||||
expect(result.name).toBe('未命名配方')
|
||||
expect(result.ingredients).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates ingredients (sums drops)', () => {
|
||||
const result = parseSingleBlock('测试,薰衣草5,薰衣草3', oilNames)
|
||||
expect(result.ingredients).toHaveLength(1)
|
||||
expect(result.ingredients[0]).toEqual({ oil: '薰衣草', drops: 8 })
|
||||
})
|
||||
|
||||
it('handles English commas as separator', () => {
|
||||
const result = parseSingleBlock('助眠,薰衣草15,雪松10', oilNames)
|
||||
expect(result.name).toBe('助眠')
|
||||
expect(result.ingredients).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles newlines as separator', () => {
|
||||
const result = parseSingleBlock('助眠\n薰衣草15\n雪松10', oilNames)
|
||||
expect(result.name).toBe('助眠')
|
||||
expect(result.ingredients).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('collects notFound oils', () => {
|
||||
const result = parseSingleBlock('测试,不存在的油99', oilNames)
|
||||
expect(result.notFound.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('parses complex real-world recipe', () => {
|
||||
const result = parseSingleBlock(
|
||||
'酸痛包,椒样薄荷1,舒缓2,芳香调理1,冬青1,柠檬草1,生姜2,茶树1,乳香1,椰子油10',
|
||||
oilNames
|
||||
)
|
||||
expect(result.name).toBe('酸痛包')
|
||||
expect(result.ingredients).toHaveLength(9)
|
||||
// Verify the first and last
|
||||
expect(result.ingredients[0]).toEqual({ oil: '椒样薄荷', drops: 1 })
|
||||
expect(result.ingredients[8]).toEqual({ oil: '椰子油', drops: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitRawIntoBlocks
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('splitRawIntoBlocks', () => {
|
||||
it('splits by blank lines', () => {
|
||||
const blocks = splitRawIntoBlocks('助眠,薰衣草15\n\n头疗,薄荷5', oilNames)
|
||||
expect(blocks).toHaveLength(2)
|
||||
expect(blocks[0]).toBe('助眠,薰衣草15')
|
||||
expect(blocks[1]).toBe('头疗,薄荷5')
|
||||
})
|
||||
|
||||
it('splits by semicolons', () => {
|
||||
const blocks = splitRawIntoBlocks('助眠,薰衣草15;头疗,薄荷5', oilNames)
|
||||
expect(blocks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('splits by English semicolons', () => {
|
||||
const blocks = splitRawIntoBlocks('助眠,薰衣草15;头疗,薄荷5', oilNames)
|
||||
expect(blocks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('single block stays single', () => {
|
||||
const blocks = splitRawIntoBlocks('助眠,薰衣草15,雪松10', oilNames)
|
||||
expect(blocks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters out empty blocks', () => {
|
||||
const blocks = splitRawIntoBlocks('助眠\n\n\n\n头疗', oilNames)
|
||||
expect(blocks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles mixed separators', () => {
|
||||
const blocks = splitRawIntoBlocks('A;B\n\nC', oilNames)
|
||||
expect(blocks).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIL_HOMOPHONES
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('OIL_HOMOPHONES', () => {
|
||||
it('is an object with string→string mappings', () => {
|
||||
expect(typeof OIL_HOMOPHONES).toBe('object')
|
||||
for (const [key, value] of Object.entries(OIL_HOMOPHONES)) {
|
||||
expect(typeof key).toBe('string')
|
||||
expect(typeof value).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps all aliases to oils that exist in the fixture', () => {
|
||||
for (const canonical of Object.values(OIL_HOMOPHONES)) {
|
||||
// The canonical name should exist in either the oil list or be a common base name
|
||||
// Some like 薄荷 might not be a standalone oil but it's used as a component
|
||||
expect(typeof canonical).toBe('string')
|
||||
expect(canonical.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('contains expected entries', () => {
|
||||
expect(OIL_HOMOPHONES['相貌']).toBe('香茅')
|
||||
expect(OIL_HOMOPHONES['如香']).toBe('乳香')
|
||||
expect(OIL_HOMOPHONES['博荷']).toBe('薄荷')
|
||||
expect(OIL_HOMOPHONES['永久化']).toBe('永久花')
|
||||
expect(OIL_HOMOPHONES['茶树油']).toBe('茶树')
|
||||
expect(OIL_HOMOPHONES['薰衣草油']).toBe('薰衣草')
|
||||
})
|
||||
})
|
||||
@@ -1,584 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DROPS_PER_ML, VOLUME_DROPS } from '../stores/oils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicate the volume / dilution calculation logic locally for unit testing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTotalDropsForMode(mode, customVal = 0, customUnit = 'drops') {
|
||||
if (mode === 'single') return null
|
||||
if (mode === 'custom') {
|
||||
return customUnit === 'ml' ? Math.round(customVal * 20) : Math.round(customVal)
|
||||
}
|
||||
const presets = { '5ml': 100, '10ml': 200, '30ml': 600 }
|
||||
return presets[mode] || 100
|
||||
}
|
||||
|
||||
function applyVolume(ingredients, mode, ratio, customVal, customUnit) {
|
||||
let targetEO, targetCoconut
|
||||
if (mode === 'single') {
|
||||
targetCoconut = 10
|
||||
targetEO = Math.round(targetCoconut / ratio)
|
||||
} else {
|
||||
const totalDrops = getTotalDropsForMode(mode, customVal, customUnit)
|
||||
if (!totalDrops || totalDrops <= 0) return null
|
||||
targetEO = Math.round(totalDrops / (1 + ratio))
|
||||
targetCoconut = totalDrops - targetEO
|
||||
}
|
||||
|
||||
const eos = ingredients.filter(i => i.oil !== '椰子油')
|
||||
const currentTotalEO = eos.reduce((s, i) => s + i.drops, 0)
|
||||
if (currentTotalEO === 0) return null
|
||||
|
||||
const factor = targetEO / currentTotalEO
|
||||
const scaled = eos.map(ing => ({
|
||||
oil: ing.oil,
|
||||
drops: Math.max(0.5, Math.round(ing.drops * factor * 2) / 2),
|
||||
}))
|
||||
scaled.push({ oil: '椰子油', drops: targetCoconut })
|
||||
return scaled
|
||||
}
|
||||
|
||||
function detectVolumeMode(ingredients) {
|
||||
const eos = ingredients.filter(i => i.oil !== '椰子油')
|
||||
const coconut = ingredients.find(i => i.oil === '椰子油')
|
||||
const totalEO = eos.reduce((s, i) => s + i.drops, 0)
|
||||
const cDrops = coconut ? coconut.drops : 0
|
||||
const totalAll = totalEO + cDrops
|
||||
if (totalAll === 100) return '5ml'
|
||||
if (totalAll === 200) return '10ml'
|
||||
if (totalAll === 600) return '30ml'
|
||||
if (cDrops > 0 && cDrops <= 20 && totalAll <= 40) return 'single'
|
||||
if (cDrops > 0) return 'custom'
|
||||
return 'single'
|
||||
}
|
||||
|
||||
function getDilutionRatio(ingredients) {
|
||||
const eos = ingredients.filter(i => i.oil !== '椰子油')
|
||||
const coconut = ingredients.find(i => i.oil === '椰子油')
|
||||
const totalEO = eos.reduce((s, i) => s + i.drops, 0)
|
||||
const cDrops = coconut ? coconut.drops : 0
|
||||
if (totalEO > 0 && cDrops > 0) return Math.round(cDrops / totalEO)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: sum EO drops from a result set
|
||||
// ---------------------------------------------------------------------------
|
||||
function sumEO(result) {
|
||||
return result.filter(i => i.oil !== '椰子油').reduce((s, i) => s + i.drops, 0)
|
||||
}
|
||||
|
||||
function coconutDrops(result) {
|
||||
const c = result.find(i => i.oil === '椰子油')
|
||||
return c ? c.drops : 0
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('Volume Constants', () => {
|
||||
it('DROPS_PER_ML equals 18.6', () => {
|
||||
expect(DROPS_PER_ML).toBe(18.6)
|
||||
})
|
||||
|
||||
it('VOLUME_DROPS has standard doTERRA sizes', () => {
|
||||
expect(VOLUME_DROPS).toHaveProperty('2.5')
|
||||
expect(VOLUME_DROPS).toHaveProperty('5')
|
||||
expect(VOLUME_DROPS).toHaveProperty('10')
|
||||
expect(VOLUME_DROPS).toHaveProperty('15')
|
||||
expect(VOLUME_DROPS).toHaveProperty('115')
|
||||
})
|
||||
|
||||
it('5ml bottle = 93 drops (factory standard)', () => {
|
||||
expect(VOLUME_DROPS['5']).toBe(93)
|
||||
})
|
||||
|
||||
it('15ml bottle = 280 drops', () => {
|
||||
expect(VOLUME_DROPS['15']).toBe(280)
|
||||
})
|
||||
|
||||
it('2.5ml bottle = 46 drops', () => {
|
||||
expect(VOLUME_DROPS['2.5']).toBe(46)
|
||||
})
|
||||
|
||||
it('10ml bottle = 186 drops', () => {
|
||||
expect(VOLUME_DROPS['10']).toBe(186)
|
||||
})
|
||||
|
||||
it('115ml bottle = 2146 drops', () => {
|
||||
expect(VOLUME_DROPS['115']).toBe(2146)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTotalDropsForMode', () => {
|
||||
it("'single' returns null", () => {
|
||||
expect(getTotalDropsForMode('single')).toBeNull()
|
||||
})
|
||||
|
||||
it("'5ml' returns 100", () => {
|
||||
expect(getTotalDropsForMode('5ml')).toBe(100)
|
||||
})
|
||||
|
||||
it("'10ml' returns 200", () => {
|
||||
expect(getTotalDropsForMode('10ml')).toBe(200)
|
||||
})
|
||||
|
||||
it("'30ml' returns 600", () => {
|
||||
expect(getTotalDropsForMode('30ml')).toBe(600)
|
||||
})
|
||||
|
||||
it("'custom' with 20ml returns 400", () => {
|
||||
expect(getTotalDropsForMode('custom', 20, 'ml')).toBe(400)
|
||||
})
|
||||
|
||||
it("'custom' with 15 drops returns 15", () => {
|
||||
expect(getTotalDropsForMode('custom', 15, 'drops')).toBe(15)
|
||||
})
|
||||
|
||||
it("'custom' with 0 ml returns 0", () => {
|
||||
expect(getTotalDropsForMode('custom', 0, 'ml')).toBe(0)
|
||||
})
|
||||
|
||||
it("'custom' rounds fractional ml values", () => {
|
||||
expect(getTotalDropsForMode('custom', 7.5, 'ml')).toBe(150)
|
||||
})
|
||||
|
||||
it('unknown mode falls back to 100', () => {
|
||||
expect(getTotalDropsForMode('unknown')).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - single dose', () => {
|
||||
const baseRecipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
|
||||
it('with ratio 10, coconut=10, EO=1', () => {
|
||||
const result = applyVolume(baseRecipe, 'single', 10)
|
||||
expect(coconutDrops(result)).toBe(10)
|
||||
expect(sumEO(result)).toBe(1)
|
||||
})
|
||||
|
||||
it('with ratio 5, coconut=10, EO=2', () => {
|
||||
const result = applyVolume(baseRecipe, 'single', 5)
|
||||
expect(coconutDrops(result)).toBe(10)
|
||||
expect(sumEO(result)).toBe(2)
|
||||
})
|
||||
|
||||
it('scales 3 oils proportionally', () => {
|
||||
const threeOils = [
|
||||
{ oil: '薰衣草', drops: 6 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
{ oil: '薄荷', drops: 3 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
const result = applyVolume(threeOils, 'single', 5)
|
||||
// targetEO = round(10/5) = 2
|
||||
// factor = 2/12
|
||||
const lavender = result.find(i => i.oil === '薰衣草')
|
||||
const frank = result.find(i => i.oil === '乳香')
|
||||
const mint = result.find(i => i.oil === '薄荷')
|
||||
// Lavender should get ~half of the EO, frank and mint ~quarter each
|
||||
expect(lavender.drops).toBeGreaterThanOrEqual(frank.drops)
|
||||
expect(frank.drops).toBe(mint.drops)
|
||||
})
|
||||
|
||||
it('minimum 0.5 drops per oil', () => {
|
||||
const tinyOil = [
|
||||
{ oil: '薰衣草', drops: 1 },
|
||||
{ oil: '乳香', drops: 1 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
// ratio 20 → targetEO = round(10/20) = 1, factor = 0.5
|
||||
// each oil: max(0.5, round(1*0.5*2)/2) = max(0.5, 0.5) = 0.5
|
||||
const result = applyVolume(tinyOil, 'single', 20)
|
||||
result.filter(i => i.oil !== '椰子油').forEach(i => {
|
||||
expect(i.drops).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - 5ml bottle', () => {
|
||||
it('100 total drops with ratio 10: EO~9, coconut~91', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 3 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
const totalEO = sumEO(result)
|
||||
const coco = coconutDrops(result)
|
||||
// targetEO = round(100/11) = 9, coconut = 91
|
||||
expect(totalEO).toBe(9)
|
||||
expect(coco).toBe(91)
|
||||
expect(totalEO + coco).toBe(100)
|
||||
})
|
||||
|
||||
it('scales existing recipe proportionally', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 6 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
const lav = result.find(i => i.oil === '薰衣草')
|
||||
const frank = result.find(i => i.oil === '乳香')
|
||||
// Original ratio is 2:1, scaled should preserve ~2:1
|
||||
expect(lav.drops).toBeGreaterThan(frank.drops)
|
||||
})
|
||||
|
||||
it('preserves oil ratios approximately', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '乳香', drops: 5 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
const lav = result.find(i => i.oil === '薰衣草')
|
||||
const frank = result.find(i => i.oil === '乳香')
|
||||
// ratio should be close to 2:1
|
||||
expect(lav.drops / frank.drops).toBeCloseTo(2, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - 10ml bottle', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
|
||||
it('produces 200 total drops', () => {
|
||||
const result = applyVolume(recipe, '10ml', 10)
|
||||
const total = sumEO(result) + coconutDrops(result)
|
||||
expect(total).toBe(200)
|
||||
})
|
||||
|
||||
it('ratio 5 gives ~33 EO drops', () => {
|
||||
const result = applyVolume(recipe, '10ml', 5)
|
||||
// targetEO = round(200/6) = 33
|
||||
expect(sumEO(result)).toBe(33)
|
||||
})
|
||||
|
||||
it('ratio 10 gives ~18 EO drops', () => {
|
||||
const result = applyVolume(recipe, '10ml', 10)
|
||||
// targetEO = round(200/11) = 18
|
||||
expect(sumEO(result)).toBe(18)
|
||||
})
|
||||
|
||||
it('ratio 15 gives ~13 EO drops', () => {
|
||||
const result = applyVolume(recipe, '10ml', 15)
|
||||
// targetEO = round(200/16) = 13 (12.5 rounds to 13)
|
||||
expect(sumEO(result)).toBe(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - 30ml bottle', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
|
||||
it('produces 600 total drops', () => {
|
||||
const result = applyVolume(recipe, '30ml', 10)
|
||||
const total = sumEO(result) + coconutDrops(result)
|
||||
expect(total).toBe(600)
|
||||
})
|
||||
|
||||
it('large recipe scaling preserves ratios', () => {
|
||||
const result = applyVolume(recipe, '30ml', 10)
|
||||
const lav = result.find(i => i.oil === '薰衣草')
|
||||
const frank = result.find(i => i.oil === '乳香')
|
||||
// Original ratio 5:3 ≈ 1.67
|
||||
expect(lav.drops / frank.drops).toBeCloseTo(5 / 3, 0)
|
||||
})
|
||||
|
||||
it('ratio 10 gives ~55 EO drops', () => {
|
||||
const result = applyVolume(recipe, '30ml', 10)
|
||||
// targetEO = round(600/11) = 55
|
||||
expect(sumEO(result)).toBe(55)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - custom', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
|
||||
it('custom 20ml = 400 total drops', () => {
|
||||
const result = applyVolume(recipe, 'custom', 10, 20, 'ml')
|
||||
const total = sumEO(result) + coconutDrops(result)
|
||||
expect(total).toBe(400)
|
||||
})
|
||||
|
||||
it('custom 50 drops total', () => {
|
||||
const result = applyVolume(recipe, 'custom', 10, 50, 'drops')
|
||||
const total = sumEO(result) + coconutDrops(result)
|
||||
expect(total).toBe(50)
|
||||
})
|
||||
|
||||
it('custom 0 ml returns null', () => {
|
||||
const result = applyVolume(recipe, 'custom', 10, 0, 'ml')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVolume - edge cases', () => {
|
||||
it('empty ingredients returns null', () => {
|
||||
const result = applyVolume([], '5ml', 10)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('only coconut oil (no EO) returns null', () => {
|
||||
const result = applyVolume([{ oil: '椰子油', drops: 10 }], '5ml', 10)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('single oil scales correctly', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result.filter(i => i.oil !== '椰子油')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('very small drops round to 0.5 minimum', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 100 },
|
||||
{ oil: '乳香', drops: 1 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
// Single mode ratio 50 → targetEO = round(10/50) = 0 → but round gives 0
|
||||
// Actually ratio 10 → targetEO = 1, factor = 1/101
|
||||
// 乳香: max(0.5, round(1 * (1/101) * 2)/2) = max(0.5, 0) = 0.5
|
||||
const result = applyVolume(recipe, 'single', 10)
|
||||
const frank = result.find(i => i.oil === '乳香')
|
||||
expect(frank.drops).toBe(0.5)
|
||||
})
|
||||
|
||||
it('coconut oil is always the last element', () => {
|
||||
const recipe = [
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
expect(result[result.length - 1].oil).toBe('椰子油')
|
||||
})
|
||||
|
||||
it('no coconut in input still adds coconut to output', () => {
|
||||
const recipe = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
]
|
||||
const result = applyVolume(recipe, '5ml', 10)
|
||||
expect(result.find(i => i.oil === '椰子油')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectVolumeMode', () => {
|
||||
it('100 total drops → 5ml', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '椰子油', drops: 90 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('5ml')
|
||||
})
|
||||
|
||||
it('200 total drops → 10ml', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 20 },
|
||||
{ oil: '椰子油', drops: 180 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('10ml')
|
||||
})
|
||||
|
||||
it('600 total drops → 30ml', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 50 },
|
||||
{ oil: '椰子油', drops: 550 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('30ml')
|
||||
})
|
||||
|
||||
it('small recipe with coconut → single', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 2 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('single')
|
||||
})
|
||||
|
||||
it('coconut <= 20 and total <= 40 → single', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 20 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('single')
|
||||
})
|
||||
|
||||
it('coconut > 20 but not a preset → custom', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '椰子油', drops: 40 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('custom')
|
||||
})
|
||||
|
||||
it('total > 40 but not a preset → custom', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 30 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
expect(detectVolumeMode(ing)).toBe('custom')
|
||||
})
|
||||
|
||||
it('no coconut at all → single', () => {
|
||||
const ing = [{ oil: '薰衣草', drops: 5 }]
|
||||
expect(detectVolumeMode(ing)).toBe('single')
|
||||
})
|
||||
|
||||
it('only EO totalling 100 still detects 5ml', () => {
|
||||
const ing = [{ oil: '薰衣草', drops: 100 }]
|
||||
expect(detectVolumeMode(ing)).toBe('5ml')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDilutionRatio', () => {
|
||||
it('standard 1:10 ratio', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '椰子油', drops: 100 },
|
||||
]
|
||||
expect(getDilutionRatio(ing)).toBe(10)
|
||||
})
|
||||
|
||||
it('no coconut returns 0', () => {
|
||||
const ing = [{ oil: '薰衣草', drops: 5 }]
|
||||
expect(getDilutionRatio(ing)).toBe(0)
|
||||
})
|
||||
|
||||
it('no EO returns 0', () => {
|
||||
const ing = [{ oil: '椰子油', drops: 50 }]
|
||||
expect(getDilutionRatio(ing)).toBe(0)
|
||||
})
|
||||
|
||||
it('1:5 ratio', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '椰子油', drops: 50 },
|
||||
]
|
||||
expect(getDilutionRatio(ing)).toBe(5)
|
||||
})
|
||||
|
||||
it('1:1 ratio', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 10 },
|
||||
{ oil: '椰子油', drops: 10 },
|
||||
]
|
||||
expect(getDilutionRatio(ing)).toBe(1)
|
||||
})
|
||||
|
||||
it('rounds to nearest integer', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 3 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
// 20/3 = 6.67 → rounds to 7
|
||||
expect(getDilutionRatio(ing)).toBe(7)
|
||||
})
|
||||
|
||||
it('multiple EO oils summed for ratio', () => {
|
||||
const ing = [
|
||||
{ oil: '薰衣草', drops: 5 },
|
||||
{ oil: '乳香', drops: 5 },
|
||||
{ oil: '椰子油', drops: 100 },
|
||||
]
|
||||
// 100/10 = 10
|
||||
expect(getDilutionRatio(ing)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real recipe scaling', () => {
|
||||
const baseRecipe = [
|
||||
{ oil: '薰衣草', drops: 6 },
|
||||
{ oil: '乳香', drops: 3 },
|
||||
{ oil: '薄荷', drops: 3 },
|
||||
{ oil: '椰子油', drops: 20 },
|
||||
]
|
||||
|
||||
it('scale to 5ml preserves approximate proportions', () => {
|
||||
const result = applyVolume(baseRecipe, '5ml', 10)
|
||||
const lav = result.find(i => i.oil === '薰衣草').drops
|
||||
const frank = result.find(i => i.oil === '乳香').drops
|
||||
const mint = result.find(i => i.oil === '薄荷').drops
|
||||
// Original: lav is 2x frank and 2x mint; frank == mint
|
||||
expect(frank).toBe(mint)
|
||||
expect(lav).toBeGreaterThanOrEqual(frank)
|
||||
})
|
||||
|
||||
it('scale to 10ml preserves approximate proportions', () => {
|
||||
const result = applyVolume(baseRecipe, '10ml', 10)
|
||||
const lav = result.find(i => i.oil === '薰衣草').drops
|
||||
const frank = result.find(i => i.oil === '乳香').drops
|
||||
const mint = result.find(i => i.oil === '薄荷').drops
|
||||
expect(frank).toBe(mint)
|
||||
expect(lav).toBeGreaterThanOrEqual(frank)
|
||||
})
|
||||
|
||||
it('10ml has approximately 2x the EO drops of 5ml', () => {
|
||||
const result5 = applyVolume(baseRecipe, '5ml', 10)
|
||||
const result10 = applyVolume(baseRecipe, '10ml', 10)
|
||||
const eo5 = sumEO(result5)
|
||||
const eo10 = sumEO(result10)
|
||||
// 10ml target = round(200/11) = 18, 5ml target = round(100/11) = 9
|
||||
expect(eo10 / eo5).toBeCloseTo(2, 0)
|
||||
})
|
||||
|
||||
it('30ml has approximately 3x the EO drops of 10ml', () => {
|
||||
const result10 = applyVolume(baseRecipe, '10ml', 10)
|
||||
const result30 = applyVolume(baseRecipe, '30ml', 10)
|
||||
const eo10 = sumEO(result10)
|
||||
const eo30 = sumEO(result30)
|
||||
expect(eo30 / eo10).toBeCloseTo(3, 0)
|
||||
})
|
||||
|
||||
it('scale up then scale down gives close to original EO count', () => {
|
||||
// Scale to 30ml
|
||||
const scaled30 = applyVolume(baseRecipe, '30ml', 10)
|
||||
// Now scale the 30ml result back to single
|
||||
const scaledBack = applyVolume(scaled30, 'single', 10)
|
||||
// Single: targetEO = round(10/10) = 1
|
||||
const totalEOBack = sumEO(scaledBack)
|
||||
expect(totalEOBack).toBeGreaterThanOrEqual(1)
|
||||
expect(totalEOBack).toBeLessThanOrEqual(3) // small due to rounding
|
||||
})
|
||||
|
||||
it('all EO drops are multiples of 0.5', () => {
|
||||
const result = applyVolume(baseRecipe, '5ml', 10)
|
||||
result.filter(i => i.oil !== '椰子油').forEach(i => {
|
||||
expect(i.drops * 2).toBe(Math.round(i.drops * 2))
|
||||
})
|
||||
})
|
||||
|
||||
it('coconut drops are always a whole number', () => {
|
||||
const result = applyVolume(baseRecipe, '10ml', 10)
|
||||
const coco = coconutDrops(result)
|
||||
expect(coco).toBe(Math.round(coco))
|
||||
})
|
||||
|
||||
it('total drops are within 1 drop of the volume preset (0.5 rounding)', () => {
|
||||
;['5ml', '10ml', '30ml'].forEach(mode => {
|
||||
const presets = { '5ml': 100, '10ml': 200, '30ml': 600 }
|
||||
const result = applyVolume(baseRecipe, mode, 10)
|
||||
const total = sumEO(result) + coconutDrops(result)
|
||||
// EO drops are rounded to nearest 0.5, so total may differ slightly
|
||||
expect(Math.abs(total - presets[mode])).toBeLessThanOrEqual(1.5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,15 @@
|
||||
|
||||
<!-- Card View -->
|
||||
<div v-if="viewMode === 'card'" class="detail-card-view">
|
||||
<div class="volume-controls" style="margin-bottom:12px">
|
||||
<button
|
||||
v-for="(drops, ml) in volumeOptions"
|
||||
:key="ml"
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === ml }"
|
||||
@click="selectedVolume = ml"
|
||||
>{{ ml === '单次' ? '单次' : ml + 'ml' }}</button>
|
||||
</div>
|
||||
<div ref="cardRef" class="export-card">
|
||||
<div class="export-card-name">{{ recipe.name }}</div>
|
||||
<div v-if="recipe.tags && recipe.tags.length" class="export-card-tags">
|
||||
@@ -32,7 +41,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ing, i) in recipe.ingredients" :key="i">
|
||||
<tr v-for="(ing, i) in scaledIngredients" :key="i">
|
||||
<td>{{ ing.oil }}</td>
|
||||
<td>{{ ing.drops }}</td>
|
||||
<td>{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</td>
|
||||
@@ -124,7 +133,7 @@
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === ml }"
|
||||
@click="selectedVolume = ml"
|
||||
>{{ ml }}ml</button>
|
||||
>{{ ml === '单次' ? '单次' : ml + 'ml' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,13 +184,26 @@ const ui = useUiStore()
|
||||
const viewMode = ref('card')
|
||||
const cardRef = ref(null)
|
||||
const showTagPicker = ref(false)
|
||||
const selectedVolume = ref('5')
|
||||
const selectedVolume = ref('单次')
|
||||
|
||||
const volumeOptions = VOLUME_DROPS
|
||||
|
||||
// Source recipe
|
||||
const recipe = computed(() => recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' })
|
||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(recipe.value.ingredients))
|
||||
|
||||
function scaleIngredients(ingredients, volume) {
|
||||
const targetDrops = VOLUME_DROPS[volume]
|
||||
if (!targetDrops) return ingredients // 单次:不缩放
|
||||
const totalDrops = ingredients.reduce((sum, ing) => sum + (ing.drops || 0), 0)
|
||||
if (totalDrops === 0) return ingredients
|
||||
return ingredients.map(ing => ({
|
||||
...ing,
|
||||
drops: Math.round(ing.drops * targetDrops / totalDrops),
|
||||
}))
|
||||
}
|
||||
|
||||
const scaledIngredients = computed(() => scaleIngredients(recipe.value.ingredients, selectedVolume.value))
|
||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(scaledIngredients.value))
|
||||
|
||||
// Editable copies
|
||||
const editName = ref('')
|
||||
@@ -189,7 +211,9 @@ const editNote = ref('')
|
||||
const editTags = ref([])
|
||||
const editIngredients = ref([])
|
||||
|
||||
const editPriceInfo = computed(() => oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil)))
|
||||
const editPriceInfo = computed(() =>
|
||||
oilsStore.fmtCostWithRetail(scaleIngredients(editIngredients.value.filter(i => i.oil), selectedVolume.value))
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const r = recipe.value
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../composables/useApi'
|
||||
export const DROPS_PER_ML = 18.6
|
||||
|
||||
export const VOLUME_DROPS = {
|
||||
'单次': null,
|
||||
'2.5': 46,
|
||||
'5': 93,
|
||||
'10': 186,
|
||||
@@ -13,16 +14,16 @@ export const VOLUME_DROPS = {
|
||||
}
|
||||
|
||||
export const useOilsStore = defineStore('oils', () => {
|
||||
const oils = ref({})
|
||||
const oilsMeta = ref({})
|
||||
const oils = ref(new Map())
|
||||
const oilsMeta = ref(new Map())
|
||||
|
||||
// Getters
|
||||
const oilNames = computed(() =>
|
||||
Object.keys(oils.value).sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
[...oils.value.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
)
|
||||
|
||||
function pricePerDrop(name) {
|
||||
return oils.value[name] || 0
|
||||
return oils.value.get(name) || 0
|
||||
}
|
||||
|
||||
function calcCost(ingredients) {
|
||||
@@ -33,7 +34,7 @@ export const useOilsStore = defineStore('oils', () => {
|
||||
|
||||
function calcRetailCost(ingredients) {
|
||||
return ingredients.reduce((sum, ing) => {
|
||||
const meta = oilsMeta.value[ing.oil]
|
||||
const meta = oilsMeta.value.get(ing.oil)
|
||||
if (meta && meta.retailPrice && meta.dropCount) {
|
||||
return sum + (meta.retailPrice / meta.dropCount) * ing.drops
|
||||
}
|
||||
@@ -58,17 +59,17 @@ export const useOilsStore = defineStore('oils', () => {
|
||||
// Actions
|
||||
async function loadOils() {
|
||||
const data = await api.get('/api/oils')
|
||||
const newOils = {}
|
||||
const newMeta = {}
|
||||
const newOils = new Map()
|
||||
const newMeta = new Map()
|
||||
for (const oil of data) {
|
||||
const ppd = oil.drop_count ? oil.bottle_price / oil.drop_count : 0
|
||||
newOils[oil.name] = ppd
|
||||
newMeta[oil.name] = {
|
||||
newOils.set(oil.name, ppd)
|
||||
newMeta.set(oil.name, {
|
||||
bottlePrice: oil.bottle_price,
|
||||
dropCount: oil.drop_count,
|
||||
retailPrice: oil.retail_price ?? null,
|
||||
isActive: oil.is_active ?? true,
|
||||
}
|
||||
})
|
||||
}
|
||||
oils.value = newOils
|
||||
oilsMeta.value = newMeta
|
||||
@@ -86,8 +87,8 @@ export const useOilsStore = defineStore('oils', () => {
|
||||
|
||||
async function deleteOil(name) {
|
||||
await api.delete(`/api/oils/${encodeURIComponent(name)}`)
|
||||
delete oils.value[name]
|
||||
delete oilsMeta.value[name]
|
||||
oils.value.delete(name)
|
||||
oilsMeta.value.delete(name)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||
note: r.note ?? '',
|
||||
tags: r.tags ?? [],
|
||||
ingredients: (r.ingredients ?? []).map((ing) => ({
|
||||
oil: ing.oil_name ?? ing.oil ?? ing.name,
|
||||
oil: ing.oil ?? ing.name,
|
||||
drops: ing.drops,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -247,7 +247,7 @@ const recipesWithOil = computed(() => {
|
||||
})
|
||||
|
||||
function getMeta(name) {
|
||||
return oils.oilsMeta[name]
|
||||
return oils.oilsMeta.get(name)
|
||||
}
|
||||
|
||||
function getDropsForOil(recipe, oilName) {
|
||||
@@ -280,7 +280,7 @@ async function addOil() {
|
||||
|
||||
function editOil(name) {
|
||||
editingOilName.value = name
|
||||
const meta = oils.oilsMeta[name]
|
||||
const meta = oils.oilsMeta.get(name)
|
||||
editBottlePrice.value = meta?.bottlePrice || 0
|
||||
editDropCount.value = meta?.dropCount || 0
|
||||
editRetailPrice.value = meta?.retailPrice || null
|
||||
|
||||
@@ -11,9 +11,5 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
}
|
||||
})
|
||||
|
||||
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
@@ -1 +0,0 @@
|
||||
{"version":"4.1.2","results":[[":frontend/src/__tests__/volumeDilution.test.js",{"duration":5.926774999999992,"failed":false}],[":frontend/src/__tests__/smartPaste.test.js",{"duration":16.112632000000005,"failed":false}],[":frontend/src/__tests__/oilCalculations.test.js",{"duration":11.990026,"failed":false}],[":frontend/src/__tests__/dialog.test.js",{"duration":4.135876999999994,"failed":false}],[":frontend/src/__tests__/oilTranslation.test.js",{"duration":4.413353999999998,"failed":false}]]}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Setup Gitea Act Runner on this machine (host mode)
|
||||
# Usage: bash scripts/setup-runner.sh <registration-token>
|
||||
|
||||
set -e
|
||||
|
||||
TOKEN="${1:?Usage: $0 <registration-token>}"
|
||||
INSTANCE="https://git.euphon.cloud"
|
||||
RUNNER_NAME="hera-runner"
|
||||
RUNNER_BIN="$HOME/bin/act_runner"
|
||||
VERSION="v0.2.11"
|
||||
|
||||
echo "=== Installing act_runner ${VERSION} ==="
|
||||
mkdir -p "$HOME/bin"
|
||||
curl -L "https://gitea.com/gitea/act_runner/releases/download/${VERSION}/act_runner-${VERSION}-linux-amd64" \
|
||||
-o "$RUNNER_BIN"
|
||||
chmod +x "$RUNNER_BIN"
|
||||
echo "Installed: $($RUNNER_BIN --version)"
|
||||
|
||||
echo ""
|
||||
echo "=== Registering runner ==="
|
||||
cd "$HOME"
|
||||
$RUNNER_BIN register --no-interactive \
|
||||
--instance "$INSTANCE" \
|
||||
--token "$TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "ubuntu-latest:host"
|
||||
|
||||
echo ""
|
||||
echo "=== Setting up systemd user service ==="
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
cat > "$HOME/.config/systemd/user/act-runner.service" << EOF
|
||||
[Unit]
|
||||
Description=Gitea Act Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h
|
||||
ExecStart=%h/bin/act_runner daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable act-runner
|
||||
systemctl --user start act-runner
|
||||
|
||||
echo ""
|
||||
echo "=== Done! ==="
|
||||
systemctl --user status act-runner --no-pager | head -8
|
||||
echo ""
|
||||
echo "Check Gitea → Settings → Actions → Runners to verify."
|
||||
Reference in New Issue
Block a user