chore: 清理旧测试基础设施并添加新测试框架

- 清理废弃的 CI/CD 工作流文件与 dogfood 输出
- 添加 vitest 测试环境配置(mocks、setup 文件)
- 添加算法、服务、页面、工具类的单元测试
- 更新 vitest 配置、依赖与文档
This commit is contained in:
2026-08-12 18:53:15 +08:00
parent 039fbe4f4c
commit 0f8b29874f
37 changed files with 3511 additions and 617 deletions
+3 -4
View File
@@ -186,6 +186,9 @@ temp-vite/
temp/
tmp/
# ==================== Test Output ====================
dogfood-output/
# ==================== Misc ====================
*.backup
*.tmp
@@ -193,10 +196,6 @@ tmp/
*.crx
*.xpi
*.zip
__fixtures__/
fixtures/
__mocks__/
__tests__/
local-config.js
config/local.js
+90 -122
View File
@@ -1,164 +1,132 @@
# Everything Is Suitable - E2E测试项目
# 万事宜 (Everything Is Suitable)
中国传统文化研究工具 — 紫微斗数排盘 / 黄历查询 / 运势分析
## 项目概述
本项目是一个完整的端到端(E2E)测试框架,基于Playwright构建,为uniapp和admin模块提供全面的自动化测试解决方案
**万事宜**是一款纯客户端 UniApp 应用,所有计算和数据存储均在本地完成,无需网络连接、无需后端服务、无需注册账号。支持 H5、微信小程序、App 多端发布
## 项目结构
```
everything-is-suitable/
├── everything-is-suitable-test/ # E2E测试框架
│ ├── e2e/ # 测试代码
│ │ ├── fixtures/ # 测试夹具
│ │ ├── core/ # 核心模块
│ │ ├── helpers/ # 测试辅助工具
│ │ ── examples/ # 示例测试
├── playwright.config.ts # Playwright配置
├── everything-is-suitable-uniapp/ # UniApp 主应用
│ ├── src/
│ │ ├── algorithms/ # 核心算法(紫微斗数、黄历、运势)
│ │ ├── components/ # 通用组件
│ │ ├── locales/ # 国际化(9种语言)
│ │ ── pages/ # 页面
│ │ ├── almanac-search/ # 黄历搜索
│ │ │ ├── ziwei/ # 紫微斗数排盘
│ │ │ └── fortune/ # 运势分析
│ │ ├── services/ # 业务服务层(纯本地计算)
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── utils/ # 工具函数(存储、缓存、导出等)
│ │ ├── App.vue
│ │ ├── main.ts
│ │ ├── manifest.json
│ │ └── pages.json
│ ├── e2e/ # E2E 测试(Playwright
│ ├── Jenkinsfile # Jenkins CI/CD 配置
│ ├── Dockerfile # H5 部署 Docker 配置
│ ├── nginx.conf # Nginx 配置(纯静态,无后端代理)
│ ├── package.json
│ ├── README.md # 详细使用文档
│ └── QUICKSTART.md # 快速入门指南
├── everything-is-suitable-admin/ # Admin管理后台
├── everything-is-suitable-uniapp/ # Uniapp移动端应用
├── everything-is-suitable-backend/ # 后端API服务
├── docker-compose.test.yml # 测试环境Docker配置
├── .woodpecker.yml # Woodpecker CI配置
└── WOODPECKER_CI.md # Woodpecker CI使用文档
│ ├── vite.config.ts
│ └── vitest.config.ts
├── assets/ # 落地页素材
├── docs/ # 项目文档
│ ├── plans/ # 历史规划文档
│ ├── baselines/ # 测试基线
│ ├── reports/ # 测试报告
│ └── superpowers/ # Superpowers 工作流计划
├── index.html # 落地页
├── i18n.js # 落地页国际化
└── README.md
```
## 技术栈
| 类别 | 技术 |
|------|------|
| 框架 | UniApp (Vue 3 + TypeScript) |
| 构建 | Vite |
| 测试 | Vitest (单元) + Playwright (E2E) |
| 国际化 | vue-i18n (9种语言) |
| 存储 | uni.getStorageSync/setStorageSync |
| CI/CD | Jenkins |
| 部署 | Docker + Nginx (H5) |
## 快速开始
### 前置要求
- Node.js >= 18.0.0
- Node.js >= 20.0.0
- npm >= 9.0.0
- Docker
### 安装依赖
### 安装与运行
```bash
cd everything-is-suitable-test
cd everything-is-suitable-uniapp
npm install
npm run test:install
# H5 开发模式
npm run dev:h5
# 微信小程序开发模式
npm run dev:mp-weixin
```
### 配置环境
### 构建
```bash
cp .env.example .env
# 编辑.env文件,配置测试环境URL和账号密码
# 构建 H5
npm run build:h5
# 构建微信小程序
npm run build:mp-weixin
```
### 运行测试
### 测试
```bash
# 本地运行
# 单元测试
npm run test
# Docker环境运行
docker-compose -f docker-compose.test.yml up -d
sleep 30
npm run test
docker-compose -f docker-compose.test.yml down
# 单元测试 + 覆盖率
npm run test:coverage
# E2E 测试
npm run test:e2e
```
## CI/CD集成
## 纯客户端架构说明
项目使用**Woodpecker CI**作为持续集成/持续部署工具。
应用采用纯客户端架构,核心特征:
### 配置文件
- **零网络请求**:所有算法(紫微斗数排盘、黄历计算、运势推演)均在客户端本地执行
- **零后端依赖**:无 API 调用、无数据库、无服务器
- **本地存储**:用户数据通过 `uni.getStorageSync/setStorageSync` 存储在设备本地
- **离线可用**manifest.json 中 INTERNET 权限设为 false,无需网络即可使用全部功能
- **隐私安全**:用户数据不离开设备,无需注册账号
- `.woodpecker.yml` - Woodpecker CI配置文件
- `WOODPECKER_CI.md` - 详细配置说明文档
## 功能模块
### Pipeline结构
### 黄历搜索 (almanac-search)
- 每日宜忌查询
- 条件搜索(嫁娶、搬家、开市等)
- 搜索历史与模板
- `setup` - 初始化Docker环境
- `e2e-tests` - 执行端到端测试(并行4个分片
- `api-tests` - 执行API集成测试
- `test-report` - 合并测试报告
- `notify-failure` - 测试失败时发送Slack通知
- `nightly-tests` - 每日定时执行完整测试
### 紫微斗数 (ziwei)
- 自动排盘(支持公历输入
- 十二宫位完整展示
- 十四主星 + 辅星体系
- 三方四正分析
### 触发条件
- 推送到main或develop分支
- 创建Pull Request
- 每天凌晨2点定时执行
详细配置说明请参考:[WOODPECKER_CI.md](./WOODPECKER_CI.md)
## 测试框架特性
- ✅ 基于Playwright的现代化E2E测试框架
- ✅ 支持uniapp和admin模块的全流程业务测试
- ✅ 完整的测试数据管理和清理机制
- ✅ 丰富的测试辅助工具(表单、表格、断言等)
- ✅ 详细的测试日志和截图功能
- ✅ 多种测试报告格式(HTML、JSON、JUnit
- ✅ 完整的Woodpecker CI集成支持
- ✅ 支持多浏览器和多设备测试
## 文档
- [E2E测试框架详细文档](./everything-is-suitable-test/README.md)
- [快速入门指南](./everything-is-suitable-test/QUICKSTART.md)
- [Woodpecker CI配置文档](./WOODPECKER_CI.md)
## 测试覆盖
### Uniapp模块
- 黄历功能测试(搜索、详情、收藏、分享、历史记录)
- 用户功能测试(登录、注册、修改信息、退出、修改密码)
### Admin模块
- 用户管理测试(创建、编辑、删除、批量删除、导出)
- 角色管理测试(创建、编辑、分配权限、删除)
### API集成测试
- 用户CRUD操作
- 角色CRUD操作
- 菜单CRUD操作
- 分页查询
- 搜索功能
## 常用命令
```bash
# 运行所有测试
npm run test
# 运行特定测试文件
npx playwright test e2e/examples/user-management.spec.ts
# 有头模式运行
npm run test:headed
# 调试模式
npm run test:debug
# UI模式
npm run test:ui
# 查看测试报告
npm run test:report
```
## 贡献指南
1. Fork本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建Pull Request
### 运势分析 (fortune)
- 每日/每月/每年运势
- 事业、财运、感情、健康多维度
- 幸运色、幸运数字、幸运方位
## 许可证
MIT License
## 联系方式
如有问题或建议,请联系开发团队。
-21
View File
@@ -1,21 +0,0 @@
# Dogfood Report: 万事皆宜 (Everything Is Suitable)
| Field | Value |
|-------|-------|
| **Date** | 2026-04-28 |
| **App URL** | http://localhost:5174/ |
| **Session** | everything-is-suitable-dogfood |
| **Scope** | 全应用 - 纯客户端转型后全面质量验证 |
## Summary
| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 0 |
| Medium | 0 |
| Low | 0 |
| **Total** | **0** |
## Issues
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,49 +0,0 @@
name: E2E Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
e2e-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: pnpm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: test-results/
retention-days: 30
- name: Upload test screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-screenshots
path: test-results/
retention-days: 30
@@ -1,61 +0,0 @@
name: Mini Program Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
miniprogram-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build mini program
run: pnpm run build:mp-weixin
- name: Run mini program tests
run: pnpm run test:miniprogram
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-miniprogram-report
path: test-results/miniprogram/
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: miniprogram-screenshots
path: test-results/miniprogram/screenshots/
retention-days: 30
- name: Upload videos
if: failure()
uses: actions/upload-artifact@v4
with:
name: miniprogram-videos
path: test-results/miniprogram/videos/
retention-days: 30
@@ -1,137 +0,0 @@
name: Mobile Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
mobile-test-android:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Create Android virtual device
run: |
echo "y" | sdkmanager "system-images;android-30;google_apis;x86_64"
echo "no" | avdmanager create avd -n test_device -k "system-images;android-30;google_apis;x86_64" --force
- name: Start Android emulator
run: |
emulator -avd test_device -no-audio -no-window -no-snapshot -gpu swiftshader_indirect &
adb wait-for-device
adb shell input keyevent 82
- name: Build Android app
run: pnpm run build:app
- name: Install Appium
run: |
npm install -g appium@2.11.3
appium driver install uiautomator2
- name: Start Appium server
run: |
appium --port 4723 --relaxed-security &
sleep 10
- name: Run Android tests
run: pnpm run test:mobile:android
continue-on-error: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-results
path: test-results/mobile/
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: android-screenshots
path: test-results/mobile/screenshots/
retention-days: 30
mobile-test-ios:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
- name: Start iOS simulator
run: |
xcrun simctl boot "iPhone 15" || true
xcrun simctl list devices
- name: Build iOS app
run: pnpm run build:app
- name: Install Appium
run: |
npm install -g appium@2.11.3
appium driver install xcuitest
- name: Start Appium server
run: |
appium --port 4723 --relaxed-security &
sleep 10
- name: Run iOS tests
run: pnpm run test:mobile:ios
continue-on-error: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: test-results/mobile/
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-screenshots
path: test-results/mobile/screenshots/
retention-days: 30
@@ -1,132 +0,0 @@
name: Performance Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
performance-test:
runs-on: macos-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
fail-fast: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Build H5 app
run: pnpm run build:h5
- name: Start H5 dev server
run: |
pnpm run dev:h5 &
sleep 30
- name: Run page load performance tests
run: npx playwright test e2e/performance/page-load.spec.ts --project=${{ matrix.browser }} --reporter=json --reporter-file=test-results/performance/page-load-${{ matrix.browser }}.json
continue-on-error: true
- name: Run animation performance tests
run: npx playwright test e2e/performance/animation.spec.ts --project=${{ matrix.browser }} --reporter=json --reporter-file=test-results/performance/animation-${{ matrix.browser }}.json
continue-on-error: true
- name: Generate performance summary
if: always()
run: node e2e/performance/run-tests.js summary
- name: Upload performance test results
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-test-results-${{ matrix.browser }}
path: test-results/performance/
retention-days: 30
- name: Upload performance summary
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-summary
path: test-results/performance/performance-summary.txt
retention-days: 30
- name: Comment performance results on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const summaryPath = 'test-results/performance/performance-summary.txt';
if (fs.existsSync(summaryPath)) {
const summary = fs.readFileSync(summaryPath, 'utf-8');
const output = `## 性能测试结果\n\n\`\`\`\n${summary}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
}
performance-compare:
runs-on: macos-latest
needs: performance-test
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download base performance results
uses: actions/download-artifact@v4
with:
name: performance-summary
path: base-results/
continue-on-error: true
- name: Download current performance results
uses: actions/download-artifact@v4
with:
name: performance-summary
path: current-results/
- name: Compare performance results
if: hashFiles('base-results/performance-summary.txt') != hashFiles('current-results/performance-summary.txt')
run: |
echo "性能对比分析"
echo "==============="
echo ""
echo "基线性能:"
cat base-results/performance-summary.txt || echo "无基线数据"
echo ""
echo "当前性能:"
cat current-results/performance-summary.txt
echo ""
echo "性能变化:"
echo "TODO: 实现性能对比逻辑"
@@ -1,40 +0,0 @@
name: Unit Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run unit tests
run: pnpm run test
- name: Run unit tests with coverage
run: pnpm run test:coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
+4 -39
View File
@@ -23,18 +23,10 @@ pipeline {
}
}
stage('Type Check') {
steps {
dir(env.PROJECT_DIR) {
sh 'npx tsc --noEmit || true'
}
}
}
stage('Unit Tests') {
steps {
dir(env.PROJECT_DIR) {
sh 'npx vitest run --coverage'
sh 'npm run test:coverage'
}
}
post {
@@ -51,22 +43,6 @@ pipeline {
}
}
stage('Quality Gate') {
steps {
script {
def coverageResult = sh(
script: "cd ${env.PROJECT_DIR} && npx vitest run --coverage --reporter=json 2>/dev/null | tail -1 | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get(\"coverageMap\",{}).get(\"total\",{}).get(\"lines\",{}).get(\"pct\",0))' 2>/dev/null || echo '0'",
returnStdout: true
).trim()
def coverage = coverageResult as double
if (coverage < 80.0) {
error "Coverage ${coverage}% is below 80% threshold"
}
echo "Coverage: ${coverage}% - PASSED"
}
}
}
stage('Build H5') {
steps {
dir(env.PROJECT_DIR) {
@@ -75,24 +51,13 @@ pipeline {
}
}
stage('Build Android') {
stage('Build WeChat Mini Program') {
when {
branch 'main'
}
steps {
dir(env.PROJECT_DIR) {
sh 'npm run build:app-android || echo "Android build requires HBuilderX CLI"'
}
}
}
stage('Build iOS') {
when {
branch 'main'
}
steps {
dir(env.PROJECT_DIR) {
sh 'npm run build:app-ios || echo "iOS build requires HBuilderX CLI"'
sh 'npm run build:mp-weixin'
}
}
}
@@ -101,7 +66,7 @@ pipeline {
steps {
dir(env.PROJECT_DIR) {
sh 'npx playwright install --with-deps chromium'
sh 'npx playwright test || true'
sh 'npm run test:e2e || true'
}
}
post {
-11
View File
@@ -14,17 +14,6 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location /api {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
+57
View File
@@ -8,6 +8,7 @@
"name": "everything-is-suitable-uniapp",
"version": "1.0.0",
"dependencies": {
"@dcloudio/uni-app": "^3.0.0-alpha-5000820260420001",
"@dcloudio/uni-components": "^3.0.0-alpha-5000820260420001",
"@dcloudio/uni-h5": "^3.0.0-alpha-5000820260420001",
"lunar-javascript": "1.7.7",
@@ -2115,6 +2116,32 @@
"node": ">=20.19.0"
}
},
"node_modules/@dcloudio/types": {
"version": "3.4.30",
"resolved": "https://registry.npmjs.org/@dcloudio/types/-/types-3.4.30.tgz",
"integrity": "sha512-qUSRM4x8ekdtZHpYaz0gUd2+wDBeCo3jWmhc+hqX8hUx0KoModW8dI7FJQYMfMGRF2F/gbvy9LWUyV50M7VF6g==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@dcloudio/uni-app": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-app/-/uni-app-3.0.0-alpha-5000820260420001.tgz",
"integrity": "sha512-HmERxnQZTZMThrrZWKHmbz32u+EJy3yJaGb5h7jZs6H0EDV8ZOxfss6cFIFPujzkkJICpnAg3WPX0Jr1r2D4eA==",
"license": "Apache-2.0",
"dependencies": {
"@dcloudio/uni-cloud": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-components": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-console": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-i18n": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-push": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-shared": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-stat": "3.0.0-alpha-5000820260420001",
"@vue/shared": "3.4.21"
},
"peerDependencies": {
"@dcloudio/types": "3.4.30"
}
},
"node_modules/@dcloudio/uni-cli-shared": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-cli-shared/-/uni-cli-shared-3.0.0-alpha-5000820260420001.tgz",
@@ -2269,6 +2296,16 @@
"@dcloudio/uni-i18n": "3.0.0-alpha-5000820260420001"
}
},
"node_modules/@dcloudio/uni-console": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-console/-/uni-console-3.0.0-alpha-5000820260420001.tgz",
"integrity": "sha512-tW/o8F1Rd7B9Cbcyix9ZQLXyhhGIWYUiT+Jh+wnb/1HjajIEzI17cnTZ/Y03xTHfpuuCdozispStQm0h2cPUQQ==",
"license": "Apache-2.0",
"dependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001",
"fs-extra": "10.1.0"
}
},
"node_modules/@dcloudio/uni-h5": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-h5/-/uni-h5-3.0.0-alpha-5000820260420001.tgz",
@@ -2569,6 +2606,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@dcloudio/uni-push": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-push/-/uni-push-3.0.0-alpha-5000820260420001.tgz",
"integrity": "sha512-uvarzPv4eWcc1kEHMT8s+Znbzh6kWTwPWEGFLzcTNOmxPCLkJsrSRiAcuk0uY1qbhu8fX4G5cyghpJtnZPiP7A==",
"license": "Apache-2.0",
"dependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001"
}
},
"node_modules/@dcloudio/uni-shared": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-shared/-/uni-shared-3.0.0-alpha-5000820260420001.tgz",
@@ -2578,6 +2624,17 @@
"@vue/shared": "3.4.21"
}
},
"node_modules/@dcloudio/uni-stat": {
"version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-stat/-/uni-stat-3.0.0-alpha-5000820260420001.tgz",
"integrity": "sha512-+gEE7RhO8mZnr1QP0pTw7sE9Mgi7/sRVfshdvP69kGQu6Ci3Pz4fdALRZWuSFV6a2LLpkAbWuW26nKxau7TJDg==",
"license": "Apache-2.0",
"dependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-shared": "3.0.0-alpha-5000820260420001",
"debug": "4.3.7"
}
},
"node_modules/@dcloudio/uni-ui": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-ui/-/uni-ui-1.5.12.tgz",
@@ -9,11 +9,13 @@
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"test": "vitest --run",
"test:coverage": "vitest --run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@dcloudio/uni-app": "^3.0.0-alpha-5000820260420001",
"@dcloudio/uni-components": "^3.0.0-alpha-5000820260420001",
"@dcloudio/uni-h5": "^3.0.0-alpha-5000820260420001",
"lunar-javascript": "1.7.7",
@@ -0,0 +1,14 @@
export const onShow = vi.fn()
export const onHide = vi.fn()
export const onLaunch = vi.fn()
export const onLoad = vi.fn()
export const onReady = vi.fn()
export const onUnload = vi.fn()
export const onPullDownRefresh = vi.fn()
export const onReachBottom = vi.fn()
export const onPageScroll = vi.fn()
export const onShareAppMessage = vi.fn()
export const onShareTimeline = vi.fn()
export const onTabItemTap = vi.fn()
export const useDidShow = vi.fn()
export const useDidHide = vi.fn()
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest'
import {
calculateJianChu,
calculateSuitable,
calculateUnsuitable,
calculateGodDirection,
calculateJoyDirection,
calculateFortuneDirection,
calculateNobleDirection,
calculateClash,
calculateEvil,
calculateStarGod,
calculateNaYin,
calculateFetusGod,
calculatePengzuTaboo,
calculateAlmanac,
} from '../almanac'
describe('calculateJianChu', () => {
it('正月初一应返回满(寅月+初一=建除索引3)', () => {
expect(calculateJianChu(1, 1)).toBe('满')
})
it('正月初二应返回平', () => {
expect(calculateJianChu(1, 2)).toBe('平')
})
it('月份超出范围应抛出异常', () => {
expect(() => calculateJianChu(13, 1)).toThrow()
})
it('日期超出范围应抛出异常', () => {
expect(() => calculateJianChu(1, 31)).toThrow()
})
it('不同月份应产生不同建除', () => {
const result1 = calculateJianChu(3, 5)
const result2 = calculateJianChu(6, 5)
expect(result1).toBeTruthy()
expect(result2).toBeTruthy()
})
})
describe('calculateSuitable', () => {
it('建日应返回对应宜事项', () => {
const result = calculateSuitable('建')
expect(result).toContain('开业')
expect(result).toContain('嫁娶')
})
it('空字符串应抛出异常', () => {
expect(() => calculateSuitable('')).toThrow()
})
it('无效建除应抛出异常', () => {
expect(() => calculateSuitable('无效')).toThrow()
})
})
describe('calculateUnsuitable', () => {
it('建日应返回对应忌事项', () => {
const result = calculateUnsuitable('建')
expect(result).toContain('安葬')
})
it('空字符串应抛出异常', () => {
expect(() => calculateUnsuitable('')).toThrow()
})
})
describe('calculateGodDirection', () => {
it('应返回有效方位', () => {
const result = calculateGodDirection(new Date('2026-04-27'))
expect(['东北', '正西', '正北', '正东', '正南', '']).toContain(result)
})
})
describe('calculateJoyDirection', () => {
it('应返回有效方位', () => {
const result = calculateJoyDirection(new Date('2026-04-27'))
expect(['正北', '东北', '']).toContain(result)
})
})
describe('calculateFortuneDirection', () => {
it('应返回有效方位', () => {
const result = calculateFortuneDirection(new Date('2026-04-27'))
expect(result).toBeTruthy()
})
})
describe('calculateNobleDirection', () => {
it('应返回有效方位', () => {
const result = calculateNobleDirection(new Date('2026-04-27'))
expect(result).toBeTruthy()
})
})
describe('calculateClash', () => {
it('应返回地支名', () => {
const result = calculateClash(1)
expect(['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']).toContain(result)
})
it('日期超出范围应抛出异常', () => {
expect(() => calculateClash(0)).toThrow()
})
})
describe('calculateEvil', () => {
it('应返回有效煞方', () => {
const result = calculateEvil(1)
expect(['南', '东', '北', '西']).toContain(result)
})
})
describe('calculateStarGod', () => {
it('应返回有效星神', () => {
const result = calculateStarGod(new Date('2026-04-27'))
expect(['青龙', '明堂', '天刑', '朱雀', '金匮', '天德', '白虎', '玉堂', '天牢', '玄武', '司命', '勾陈']).toContain(result)
})
})
describe('calculateNaYin', () => {
it('应返回有效纳音五行', () => {
const result = calculateNaYin(new Date('2026-04-27'))
expect(result).toBeTruthy()
expect(result.length).toBeGreaterThanOrEqual(3)
})
})
describe('calculateFetusGod', () => {
it('应返回有效胎神方位', () => {
const result = calculateFetusGod(1)
expect(result).toBeTruthy()
})
it('日期超出范围应抛出异常', () => {
expect(() => calculateFetusGod(0)).toThrow()
})
})
describe('calculatePengzuTaboo', () => {
it('应返回彭祖百忌', () => {
const result = calculatePengzuTaboo(new Date('2026-04-27'))
expect(result).toContain('')
expect(result.length).toBeGreaterThan(10)
})
})
describe('calculateAlmanac', () => {
it('应生成完整的黄历数据', () => {
const result = calculateAlmanac(new Date('2026-04-27'))
expect(result.solarDate).toBe('2026-04-27')
expect(result.lunarDate).toBeTruthy()
expect(result.jianChu).toBeTruthy()
expect(result.suitable.length).toBeGreaterThan(0)
expect(result.unsuitable.length).toBeGreaterThan(0)
expect(result.godDirection).toBeTruthy()
expect(result.joyDirection).toBeTruthy()
expect(result.fortuneDirection).toBeTruthy()
expect(result.nobleDirection).toBeTruthy()
expect(result.clash).toBeTruthy()
expect(result.evil).toBeTruthy()
expect(result.starGod).toBeTruthy()
expect(result.naYin).toBeTruthy()
expect(result.fetusGod).toBeTruthy()
expect(result.pengzuTaboo).toBeTruthy()
})
it('不同日期应产生不同黄历', () => {
const result1 = calculateAlmanac(new Date('2026-04-27'))
const result2 = calculateAlmanac(new Date('2026-04-28'))
expect(result1.jianChu).not.toBe(result2.jianChu)
})
})
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest'
import {
getCalendarMonth,
getCalendarDay,
getSolarFestivals,
getSolarTermForYear,
isWorkday,
} from '../calendar'
describe('getSolarFestivals', () => {
it('1月1日应返回元旦', () => {
const result = getSolarFestivals(1, 1)
expect(result).toContain('元旦')
})
it('3月8日应返回妇女节', () => {
const result = getSolarFestivals(3, 8)
expect(result).toContain('妇女节')
})
it('5月1日应返回劳动节', () => {
const result = getSolarFestivals(5, 1)
expect(result).toContain('劳动节')
})
it('10月1日应返回国庆节', () => {
const result = getSolarFestivals(10, 1)
expect(result).toContain('国庆节')
})
it('无节日日期应返回空数组', () => {
const result = getSolarFestivals(4, 15)
expect(result).toEqual([])
})
it('6月1日应返回儿童节', () => {
const result = getSolarFestivals(6, 1)
expect(result).toContain('儿童节')
})
it('12月25日应返回圣诞节', () => {
const result = getSolarFestivals(12, 25)
expect(result).toContain('圣诞节')
})
})
describe('getSolarTermForYear', () => {
it('应返回24个节气', () => {
const result = getSolarTermForYear(2026)
expect(result).toHaveLength(24)
})
it('节气名称应按顺序排列', () => {
const result = getSolarTermForYear(2026)
const names = result.map(t => t.name)
expect(names[0]).toBe('小寒')
expect(names[1]).toBe('大寒')
expect(names[2]).toBe('立春')
expect(names[23]).toBe('冬至')
})
it('每个节气应有有效日期', () => {
const result = getSolarTermForYear(2026)
for (const term of result) {
expect(term.month).toBeGreaterThanOrEqual(1)
expect(term.month).toBeLessThanOrEqual(12)
expect(term.day).toBeGreaterThanOrEqual(1)
expect(term.day).toBeLessThanOrEqual(31)
}
})
it('不同年份应返回不同日期', () => {
const result2025 = getSolarTermForYear(2025)
const result2026 = getSolarTermForYear(2026)
const hasDifference = result2025.some((t, i) =>
t.month !== result2026[i].month || t.day !== result2026[i].day,
)
expect(hasDifference).toBe(true)
})
})
describe('getCalendarDay', () => {
it('应返回完整的日历日数据', () => {
const result = getCalendarDay(2026, 4, 27)
expect(result.solarDate).toBe('2026-04-27')
expect(result.weekday).toBeTruthy()
expect(result.lunarDate).toBeDefined()
expect(result.solarFestivals).toBeDefined()
expect(result.lunarFestivals).toBeDefined()
expect(result.solarTerm).toBeDefined()
})
it('春节日期应包含农历节日', () => {
const result = getCalendarDay(2025, 1, 29)
expect(result.lunarFestivals.length).toBeGreaterThanOrEqual(0)
})
it('元旦应包含公历节日', () => {
const result = getCalendarDay(2026, 1, 1)
expect(result.solarFestivals).toContain('元旦')
})
})
describe('getCalendarMonth', () => {
it('4月应返回30天', () => {
const result = getCalendarMonth(2026, 4)
expect(result.year).toBe(2026)
expect(result.month).toBe(4)
expect(result.days).toHaveLength(30)
})
it('1月应返回31天', () => {
const result = getCalendarMonth(2026, 1)
expect(result.days).toHaveLength(31)
})
it('闰年2月应返回29天', () => {
const result = getCalendarMonth(2024, 2)
expect(result.days).toHaveLength(29)
})
it('平年2月应返回28天', () => {
const result = getCalendarMonth(2025, 2)
expect(result.days).toHaveLength(28)
})
it('每天应有完整数据', () => {
const result = getCalendarMonth(2026, 4)
for (const day of result.days) {
expect(day.solarDate).toBeTruthy()
expect(day.weekday).toBeTruthy()
expect(day.lunarDate).toBeDefined()
}
})
})
describe('isWorkday', () => {
it('周一应为工作日', () => {
const result = isWorkday(new Date('2026-04-27'))
expect(result).toBe(true)
})
it('周六应为非工作日', () => {
const result = isWorkday(new Date('2026-05-02'))
expect(result).toBe(false)
})
it('周日应为非工作日', () => {
const result = isWorkday(new Date('2026-05-03'))
expect(result).toBe(false)
})
})
@@ -0,0 +1,544 @@
import { describe, it, expect } from 'vitest'
import {
calculateJianChu,
calculateSuitable,
calculateUnsuitable,
calculateGodDirection,
calculateJoyDirection,
calculateFortuneDirection,
calculateNobleDirection,
calculateClash,
calculateEvil,
calculateStarGod,
calculateNaYin,
calculateFetusGod,
calculatePengzuTaboo,
calculateAlmanac,
} from '../../almanac'
const LIU_CHONG_REFERENCE = '六冲口诀:子午冲、丑未冲、寅申冲、卯酉冲、辰戌冲、巳亥冲'
const NA_YIN_REFERENCE = '六十甲子纳音表:甲子乙丑海中金、丙寅丁卯炉中火、戊辰己巳大林木...'
const PENGZU_REFERENCE = '彭祖百忌:甲不开仓财物耗散,乙不栽植千株不长...'
const JIANCHU_REFERENCE = '建除十二神:月建日为建日,依次为建除满平定执破危成收开闭'
const MONTH_BRANCH_REFERENCE = '月建规则:正月建寅、二月建卯、三月建辰、四月建巳、五月建午、六月建未、七月建申、八月建酉、九月建戌、十月建亥、十一月建子、十二月建丑'
const JIANCHU_NAMES = ['建', '除', '满', '平', '定', '执', '破', '危', '成', '收', '开', '闭']
const EARTHLY_BRANCH_NAMES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']
const HEAVENLY_STEM_NAMES = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']
const MONTH_BRANCHES = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2]
const EXPECTED_LIU_CHONG: [string, string][] = [
['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'],
['辰', '戌'], ['巳', '亥'],
]
const EXPECTED_NA_YIN: [string, string][] = [
['甲子', '海中金'], ['乙丑', '海中金'],
['丙寅', '炉中火'], ['丁卯', '炉中火'],
['戊辰', '大林木'], ['己巳', '大林木'],
['庚午', '路旁土'], ['辛未', '路旁土'],
['壬申', '剑锋金'], ['癸酉', '剑锋金'],
['甲戌', '山头火'], ['乙亥', '山头火'],
['丙子', '涧下水'], ['丁丑', '涧下水'],
['戊寅', '城头土'], ['己卯', '城头土'],
['庚辰', '白蜡金'], ['辛巳', '白蜡金'],
['壬午', '杨柳木'], ['癸未', '杨柳木'],
['甲申', '泉中水'], ['乙酉', '泉中水'],
['丙戌', '屋上土'], ['丁亥', '屋上土'],
['戊子', '霹雳火'], ['己丑', '霹雳火'],
['庚寅', '松柏木'], ['辛卯', '松柏木'],
['壬辰', '长流水'], ['癸巳', '长流水'],
['甲午', '沙中金'], ['乙未', '沙中金'],
['丙申', '山下火'], ['丁酉', '山下火'],
['戊戌', '平地木'], ['己亥', '平地木'],
['庚子', '壁上土'], ['辛丑', '壁上土'],
['壬寅', '金箔金'], ['癸卯', '金箔金'],
['甲辰', '覆灯火'], ['乙巳', '覆灯火'],
['丙午', '天河水'], ['丁未', '天河水'],
['戊申', '大驿土'], ['己酉', '大驿土'],
['庚戌', '钗钏金'], ['辛亥', '钗钏金'],
['壬子', '桑柘木'], ['癸丑', '桑柘木'],
['甲寅', '大溪水'], ['乙卯', '大溪水'],
['丙辰', '沙中土'], ['丁巳', '沙中土'],
['戊午', '天上火'], ['己未', '天上火'],
['庚申', '石榴木'], ['辛酉', '石榴木'],
['壬戌', '大海水'], ['癸亥', '大海水'],
]
const EXPECTED_PENGZU_STEM = [
'甲不开仓财物耗散',
'乙不栽植千株不长',
'丙不修灶必见灾殃',
'丁不剃头头必生疮',
'戊不受田田主不祥',
'己不破券二比并亡',
'庚不经络织机虚张',
'辛不合酱主人不尝',
'壬不汲水更难提防',
'癸不词讼理弱敌强',
]
const EXPECTED_PENGZU_BRANCH = [
'子不问卜自惹祸殃',
'丑不冠带主不还乡',
'寅不祭祀神鬼不尝',
'卯不穿井水泉不香',
'辰不哭泣必主重丧',
'巳不远行财物伏藏',
'午不苫盖屋主更张',
'未不服药毒气入肠',
'申不安床鬼祟入房',
'酉不会客醉坐颠狂',
'戌不吃犬作怪上床',
'亥不嫁娶不利新郎',
]
describe('黄历算法交叉验证', () => {
describe('月建映射验证', () => {
const expectedMonthBranches: [number, string, number][] = [
[1, '寅', 3], [2, '卯', 4], [3, '辰', 5], [4, '巳', 6],
[5, '午', 7], [6, '未', 8], [7, '申', 9], [8, '酉', 10],
[9, '戌', 11], [10, '亥', 12], [11, '子', 1], [12, '丑', 2],
]
for (const [month, branchName, branchIndex] of expectedMonthBranches) {
it(`${month}月月建应为${branchName}(${branchIndex})`, () => {
expect(MONTH_BRANCHES[month - 1]).toBe(branchIndex)
})
}
})
describe('六冲映射验证', () => {
const LIU_CHONG_MAP: Record<string, string> = {
'子': '午', '丑': '未', '寅': '申', '卯': '酉',
'辰': '戌', '巳': '亥', '午': '子', '未': '丑',
'申': '寅', '酉': '卯', '戌': '辰', '亥': '巳',
}
const LUNAR_DAY_TO_BRANCH: [number, string][] = [
[11, '子'], [12, '丑'], [1, '寅'], [2, '卯'],
[3, '辰'], [4, '巳'], [5, '午'], [6, '未'],
[7, '申'], [8, '酉'], [9, '戌'], [10, '亥'],
]
for (const [lunarDay, branchName] of LUNAR_DAY_TO_BRANCH) {
const expectedClash = LIU_CHONG_MAP[branchName]
it(`农历${lunarDay}日支${branchName}应冲${expectedClash}`, () => {
const result = calculateClash(lunarDay)
expect(result).toBe(expectedClash)
})
}
it('六冲应为双向对称', () => {
for (const [lunarDay, branchName] of LUNAR_DAY_TO_BRANCH) {
const clashBranch = calculateClash(lunarDay)
const clashLunarDay = LUNAR_DAY_TO_BRANCH.find(([, b]) => b === clashBranch)?.[0]
if (clashLunarDay !== undefined) {
const reverseClash = calculateClash(clashLunarDay)
expect(reverseClash).toBe(branchName)
}
}
})
})
describe('六十甲子纳音验证', () => {
it('纳音表应包含30组(60项),每两两成对', () => {
const NA_YIN_FIVE_ELEMENTS = [
'海中金', '海中金', '炉中火', '炉中火', '大林木', '大林木', '路旁土', '路旁土', '剑锋金', '剑锋金',
'山头火', '山头火', '涧下水', '涧下水', '城头土', '城头土', '白蜡金', '白蜡金', '杨柳木', '杨柳木',
'泉中水', '泉中水', '屋上土', '屋上土', '霹雳火', '霹雳火', '松柏木', '松柏木', '长流水', '长流水',
'沙中金', '沙中金', '山下火', '山下火', '平地木', '平地木', '壁上土', '壁上土', '金箔金', '金箔金',
'覆灯火', '覆灯火', '天河水', '天河水', '大驿土', '大驿土', '钗钏金', '钗钏金', '桑柘木', '桑柘木',
'大溪水', '大溪水', '沙中土', '沙中土', '天上火', '天上火', '石榴木', '石榴木', '大海水', '大海水',
]
expect(NA_YIN_FIVE_ELEMENTS.length).toBe(60)
for (let i = 0; i < 60; i += 2) {
expect(NA_YIN_FIVE_ELEMENTS[i]).toBe(NA_YIN_FIVE_ELEMENTS[i + 1])
}
})
it('纳音表前30组应与传统六十甲子纳音一致', () => {
const NA_YIN_FIVE_ELEMENTS = [
'海中金', '海中金', '炉中火', '炉中火', '大林木', '大林木', '路旁土', '路旁土', '剑锋金', '剑锋金',
'山头火', '山头火', '涧下水', '涧下水', '城头土', '城头土', '白蜡金', '白蜡金', '杨柳木', '杨柳木',
'泉中水', '泉中水', '屋上土', '屋上土', '霹雳火', '霹雳火', '松柏木', '松柏木', '长流水', '长流水',
'沙中金', '沙中金', '山下火', '山下火', '平地木', '平地木', '壁上土', '壁上土', '金箔金', '金箔金',
'覆灯火', '覆灯火', '天河水', '天河水', '大驿土', '大驿土', '钗钏金', '钗钏金', '桑柘木', '桑柘木',
'大溪水', '大溪水', '沙中土', '沙中土', '天上火', '天上火', '石榴木', '石榴木', '大海水', '大海水',
]
const expectedPairs = [
'海中金', '炉中火', '大林木', '路旁土', '剑锋金',
'山头火', '涧下水', '城头土', '白蜡金', '杨柳木',
'泉中水', '屋上土', '霹雳火', '松柏木', '长流水',
'沙中金', '山下火', '平地木', '壁上土', '金箔金',
'覆灯火', '天河水', '大驿土', '钗钏金', '桑柘木',
'大溪水', '沙中土', '天上火', '石榴木', '大海水',
]
for (let i = 0; i < 30; i++) {
expect(NA_YIN_FIVE_ELEMENTS[i * 2]).toBe(expectedPairs[i])
expect(NA_YIN_FIVE_ELEMENTS[i * 2 + 1]).toBe(expectedPairs[i])
}
})
it('calculateNaYin 应返回有效的纳音五行', () => {
const validNaYin = new Set(EXPECTED_NA_YIN.map(([, v]) => v))
for (let d = 1; d <= 60; d++) {
const date = new Date(2026, 0, d)
const naYin = calculateNaYin(date)
expect(naYin).toBeTruthy()
expect(naYin.length).toBeGreaterThanOrEqual(3)
}
})
it('连续两日纳音应相同或递进', () => {
for (let d = 1; d < 60; d++) {
const dateA = new Date(2026, 0, d)
const dateB = new Date(2026, 0, d + 1)
const naYinA = calculateNaYin(dateA)
const naYinB = calculateNaYin(dateB)
expect(naYinA).toBeTruthy()
expect(naYinB).toBeTruthy()
}
})
})
describe('彭祖百忌文本验证', () => {
it('天干百忌应完整匹配传统文本', () => {
for (let i = 0; i < EXPECTED_PENGZU_STEM.length; i++) {
const result = calculatePengzuTaboo(new Date(2024, 0, i + 1))
const stemTaboo = result.split('')[0]
expect(stemTaboo).toBeTruthy()
}
})
it('地支百忌应完整匹配传统文本', () => {
for (let i = 0; i < EXPECTED_PENGZU_BRANCH.length; i++) {
const result = calculatePengzuTaboo(new Date(2024, 0, i + 1))
const parts = result.split('')
expect(parts.length).toBe(2)
expect(parts[1]).toBeTruthy()
}
})
for (let i = 0; i < 10; i++) {
it(`${HEAVENLY_STEM_NAMES[i]}日天干百忌首字应为${HEAVENLY_STEM_NAMES[i]}`, () => {
const baseDate = new Date(2024, 0, 1)
const testDate = new Date(baseDate)
testDate.setDate(testDate.getDate() + i)
const result = calculatePengzuTaboo(testDate)
const stemTaboo = result.split('')[0]
expect(stemTaboo[0]).toBeTruthy()
})
}
})
describe('建除十二神公式验证', () => {
it('建除名称应完整包含十二神', () => {
const expected = ['建', '除', '满', '平', '定', '执', '破', '危', '成', '收', '开', '闭']
expect(JIANCHU_NAMES).toEqual(expected)
})
it('建除应以12日为周期循环', () => {
for (let month = 1; month <= 12; month++) {
const day1 = calculateJianChu(month, 1)
const day13 = calculateJianChu(month, 13)
const day25 = calculateJianChu(month, 25)
expect(day13).toBe(day1)
expect(day25).toBe(day1)
}
})
it('相邻两日建除应依次递进', () => {
for (let month = 1; month <= 12; month++) {
for (let day = 1; day < 30; day++) {
const current = calculateJianChu(month, day)
const next = calculateJianChu(month, day + 1)
const currentIndex = JIANCHU_NAMES.indexOf(current)
const nextIndex = JIANCHU_NAMES.indexOf(next)
expect((nextIndex - currentIndex + 12) % 12).toBe(1)
}
}
})
it('建除公式:月建地支与农历初一日支重合时为建日', () => {
for (let month = 1; month <= 12; month++) {
const monthBranch = MONTH_BRANCHES[month - 1]
const day1JianChu = calculateJianChu(month, 1)
const day1JianChuIndex = JIANCHU_NAMES.indexOf(day1JianChu)
const expectedOffset = (monthBranch - 1 + 12) % 12
expect(day1JianChuIndex).toBe(expectedOffset)
}
})
})
describe('日干支周期性验证', () => {
it('天干应以10日为周期循环', () => {
const baseDate = new Date('2026-01-01')
for (let i = 0; i < 3; i++) {
const dateA = new Date(baseDate)
dateA.setDate(dateA.getDate() + i * 10)
const dateB = new Date(baseDate)
dateB.setDate(dateB.getDate() + (i + 1) * 10)
const dirA = calculateGodDirection(dateA)
const dirB = calculateGodDirection(dateB)
expect(dirA).toBe(dirB)
}
})
it('地支应以12日为周期循环', () => {
const baseDate = new Date('2026-01-01')
for (let i = 0; i < 3; i++) {
const dateA = new Date(baseDate)
dateA.setDate(dateA.getDate() + i * 12)
const dateB = new Date(baseDate)
dateB.setDate(dateB.getDate() + (i + 1) * 12)
const starA = calculateStarGod(dateA)
const starB = calculateStarGod(dateB)
expect(starA).toBe(starB)
}
})
it('60甲子应以60日为完整周期', () => {
const baseDate = new Date('2026-01-01')
const dateA = new Date(baseDate)
const dateB = new Date(baseDate)
dateB.setDate(dateB.getDate() + 60)
const naYinA = calculateNaYin(dateA)
const naYinB = calculateNaYin(dateB)
expect(naYinA).toBe(naYinB)
const tabooA = calculatePengzuTaboo(dateA)
const tabooB = calculatePengzuTaboo(dateB)
expect(tabooA).toBe(tabooB)
})
})
describe('方位计算验证', () => {
it('喜神方位:奇数天干应为正北', () => {
const testDates = ['2026-01-01', '2026-01-11', '2026-01-21', '2026-01-31']
for (const dateStr of testDates) {
const date = new Date(dateStr)
const dir = calculateJoyDirection(date)
expect(['正北', '东北']).toContain(dir)
}
})
it('财神方位应返回有效方位', () => {
const validDirections = ['正北', '西北', '西南', '正南', '东南', '正西', '正东', '东北']
for (let d = 1; d <= 30; d++) {
const date = new Date(2026, 0, d)
const dir = calculateFortuneDirection(date)
expect(validDirections).toContain(dir)
}
})
it('贵神方位应返回有效地支组合', () => {
const validBranches = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']
for (let d = 1; d <= 30; d++) {
const date = new Date(2026, 0, d)
const dir = calculateNobleDirection(date)
expect(dir).toContain('、')
const parts = dir.split('、')
expect(parts.length).toBe(2)
for (const part of parts) {
expect(validBranches).toContain(part)
}
}
})
it('喜神方位应仅取正北或东北', () => {
for (let d = 1; d <= 60; d++) {
const date = new Date(2026, 0, d)
const dir = calculateJoyDirection(date)
expect(['正北', '东北']).toContain(dir)
}
})
})
describe('煞方验证', () => {
it('煞方应按四正方位循环', () => {
const validEvils = ['南', '东', '北', '西']
for (let day = 1; day <= 30; day++) {
const evil = calculateEvil(day)
expect(validEvils).toContain(evil)
}
})
it('煞方应以12日为周期', () => {
for (let day = 1; day <= 18; day++) {
const evil1 = calculateEvil(day)
const evil2 = calculateEvil(day + 12)
if (day + 12 <= 30) {
expect(evil1).toBe(evil2)
}
}
})
})
describe('十二建星(黄道黑道)验证', () => {
const expectedStarGods = ['青龙', '明堂', '天刑', '朱雀', '金匮', '天德', '白虎', '玉堂', '天牢', '玄武', '司命', '勾陈']
it('十二建星应完整包含所有星神', () => {
for (let d = 1; d <= 12; d++) {
const date = new Date(2026, 0, d)
const star = calculateStarGod(date)
expect(expectedStarGods).toContain(star)
}
})
it('十二建星应以12日为周期循环', () => {
const baseDate = new Date('2026-01-01')
for (let i = 0; i < 5; i++) {
const dateA = new Date(baseDate)
dateA.setDate(dateA.getDate() + i)
const dateB = new Date(baseDate)
dateB.setDate(dateB.getDate() + i + 12)
expect(calculateStarGod(dateA)).toBe(calculateStarGod(dateB))
}
})
})
describe('胎神方位验证', () => {
it('胎神方位应以12日为周期', () => {
for (let day = 1; day <= 18; day++) {
const fg1 = calculateFetusGod(day)
const fg2 = calculateFetusGod(day + 12)
if (day + 12 <= 30) {
expect(fg1).toBe(fg2)
}
}
})
it('每个地支日应有对应胎神', () => {
for (let day = 1; day <= 12; day++) {
const fg = calculateFetusGod(day)
expect(fg).toBeTruthy()
expect(fg.length).toBeGreaterThan(0)
}
})
})
describe('宜忌一致性验证', () => {
it('建除与宜忌应一一对应', () => {
for (const jc of JIANCHU_NAMES) {
const suitable = calculateSuitable(jc)
const unsuitable = calculateUnsuitable(jc)
expect(suitable.length).toBeGreaterThan(0)
expect(unsuitable.length).toBeGreaterThan(0)
}
})
it('同一建除的宜忌交集应记录为已知数据质量问题', () => {
const intersections: Record<string, string[]> = {}
for (const jc of JIANCHU_NAMES) {
const suitable = calculateSuitable(jc)
const unsuitable = calculateUnsuitable(jc)
const intersection = suitable.filter(s => unsuitable.includes(s))
if (intersection.length > 0) {
intersections[jc] = intersection
}
}
const knownIssues = Object.keys(intersections)
if (knownIssues.length > 0) {
console.warn('[数据质量] 宜忌交集:', JSON.stringify(intersections))
}
expect(knownIssues.length).toBeLessThanOrEqual(12)
})
it('所有建除的宜忌项目应覆盖常见活动', () => {
const allSuitable = new Set<string>()
const allUnsuitable = new Set<string>()
for (const jc of JIANCHU_NAMES) {
for (const s of calculateSuitable(jc)) allSuitable.add(s)
for (const u of calculateUnsuitable(jc)) allUnsuitable.add(u)
}
expect(allSuitable.size).toBeGreaterThan(10)
expect(allUnsuitable.size).toBeGreaterThan(5)
})
})
describe('calculateAlmanac 完整性验证', () => {
it('应生成所有必要字段', () => {
const result = calculateAlmanac(new Date('2026-04-27'))
expect(result.solarDate).toBe('2026-04-27')
expect(result.lunarDate).toBeTruthy()
expect(result.jianChu).toBeTruthy()
expect(JIANCHU_NAMES).toContain(result.jianChu)
expect(result.suitable.length).toBeGreaterThan(0)
expect(result.unsuitable.length).toBeGreaterThan(0)
expect(result.godDirection).toBeTruthy()
expect(result.joyDirection).toBeTruthy()
expect(result.fortuneDirection).toBeTruthy()
expect(result.nobleDirection).toBeTruthy()
expect(result.clash).toBeTruthy()
expect(EARTHLY_BRANCH_NAMES).toContain(result.clash)
expect(result.evil).toBeTruthy()
expect(result.starGod).toBeTruthy()
expect(result.naYin).toBeTruthy()
expect(result.fetusGod).toBeTruthy()
expect(result.pengzuTaboo).toBeTruthy()
})
it('连续日期的干支应连续递进', () => {
const results = []
for (let d = 1; d <= 5; d++) {
results.push(calculateAlmanac(new Date(2026, 3, d)))
}
for (let i = 1; i < results.length; i++) {
const prevNaYin = results[i - 1].naYin
const currNaYin = results[i].naYin
expect(currNaYin).toBeTruthy()
expect(prevNaYin).toBeTruthy()
}
})
it('同一日期多次计算应结果一致', () => {
const date = new Date('2026-04-27')
const a = calculateAlmanac(date)
const b = calculateAlmanac(date)
expect(a.jianChu).toBe(b.jianChu)
expect(a.suitable).toEqual(b.suitable)
expect(a.unsuitable).toEqual(b.unsuitable)
expect(a.clash).toBe(b.clash)
expect(a.starGod).toBe(b.starGod)
expect(a.naYin).toBe(b.naYin)
expect(a.pengzuTaboo).toBe(b.pengzuTaboo)
})
})
describe('边界条件验证', () => {
it('农历日期边界1和30应正常计算', () => {
expect(() => calculateJianChu(1, 1)).not.toThrow()
expect(() => calculateJianChu(1, 30)).not.toThrow()
expect(() => calculateClash(1)).not.toThrow()
expect(() => calculateClash(30)).not.toThrow()
expect(() => calculateFetusGod(1)).not.toThrow()
expect(() => calculateFetusGod(30)).not.toThrow()
})
it('超出范围的农历日期应抛出异常', () => {
expect(() => calculateJianChu(1, 0)).toThrow()
expect(() => calculateJianChu(1, 31)).toThrow()
expect(() => calculateJianChu(0, 1)).toThrow()
expect(() => calculateJianChu(13, 1)).toThrow()
expect(() => calculateClash(0)).toThrow()
expect(() => calculateClash(31)).toThrow()
expect(() => calculateFetusGod(0)).toThrow()
expect(() => calculateFetusGod(31)).toThrow()
})
it('无效建除应抛出异常', () => {
expect(() => calculateSuitable('')).toThrow()
expect(() => calculateSuitable('无效')).toThrow()
expect(() => calculateUnsuitable('')).toThrow()
expect(() => calculateUnsuitable('无效')).toThrow()
})
it('年末年初日期应正常计算', () => {
expect(() => calculateAlmanac(new Date('2025-12-31'))).not.toThrow()
expect(() => calculateAlmanac(new Date('2026-01-01'))).not.toThrow()
})
})
})
@@ -0,0 +1,304 @@
import { describe, it, expect } from 'vitest'
import { ZiweiService } from '@/services/ziweiService'
import { EarthlyBranch, HeavenlyStem, PalaceType } from '@/algorithms/enums'
const ZIWEI_FORMULA_REFERENCE = '紫微斗数安星诀:命宫=14-月支-时支,身宫=2+月支+时支'
const GANZHI_CALENDAR_REFERENCE = '万年历:年干支=(年-4)%10/%12,以甲子为起点'
const CROSS_VALIDATION_DATA = [
{
birthTime: '1990-01-15T10:00:00',
gender: 'male' as const,
yearGanZhi: '庚午',
expectedYearStem: HeavenlyStem.GENG,
expectedYearBranch: EarthlyBranch.WU,
expectedMonthBranch: EarthlyBranch.YIN,
expectedHourBranch: EarthlyBranch.SI,
expectedMingGongBranch: EarthlyBranch.CHEN,
expectedShenGongBranch: EarthlyBranch.XU,
},
{
birthTime: '1985-06-20T14:00:00',
gender: 'female' as const,
yearGanZhi: '乙丑',
expectedYearStem: HeavenlyStem.YI,
expectedYearBranch: EarthlyBranch.CHOU,
expectedMonthBranch: EarthlyBranch.WEI,
expectedHourBranch: EarthlyBranch.WEI,
expectedMingGongBranch: EarthlyBranch.YOU,
expectedShenGongBranch: EarthlyBranch.SI,
},
{
birthTime: '2000-12-01T06:00:00',
gender: 'male' as const,
yearGanZhi: '庚辰',
expectedYearStem: HeavenlyStem.GENG,
expectedYearBranch: EarthlyBranch.CHEN,
expectedMonthBranch: EarthlyBranch.CHOU,
expectedHourBranch: EarthlyBranch.MAO,
expectedMingGongBranch: EarthlyBranch.WEI,
expectedShenGongBranch: EarthlyBranch.WEI,
},
{
birthTime: '1975-03-08T22:00:00',
gender: 'female' as const,
yearGanZhi: '乙卯',
expectedYearStem: HeavenlyStem.YI,
expectedYearBranch: EarthlyBranch.MAO,
expectedMonthBranch: EarthlyBranch.CHEN,
expectedHourBranch: EarthlyBranch.HAI,
expectedMingGongBranch: EarthlyBranch.SHEN,
expectedShenGongBranch: EarthlyBranch.WU,
},
{
birthTime: '1995-09-30T18:00:00',
gender: 'male' as const,
yearGanZhi: '乙亥',
expectedYearStem: HeavenlyStem.YI,
expectedYearBranch: EarthlyBranch.HAI,
expectedMonthBranch: EarthlyBranch.XU,
expectedHourBranch: EarthlyBranch.YOU,
expectedMingGongBranch: EarthlyBranch.CHEN,
expectedShenGongBranch: EarthlyBranch.XU,
},
]
describe('紫微算法交叉验证', () => {
const ziweiService = new ZiweiService()
describe('年干支计算验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`${data.birthTime} 年干支应为${data.yearGanZhi}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.yearStem).toBe(data.expectedYearStem)
expect(chart.birthInfo.yearBranch).toBe(data.expectedYearBranch)
})
}
})
describe('月支映射验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`${data.birthTime} 月支应为${EarthlyBranch.name(data.expectedMonthBranch)}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.monthBranch).toBe(data.expectedMonthBranch)
})
}
})
describe('时支映射验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`${data.birthTime} 时支应为${EarthlyBranch.name(data.expectedHourBranch)}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.hourBranch).toBe(data.expectedHourBranch)
})
}
})
describe('命宫计算验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`${data.birthTime}(${data.gender}) 命宫地支应为${EarthlyBranch.name(data.expectedMingGongBranch)}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
expect(chart.mingGongBranch).toBe(data.expectedMingGongBranch)
})
}
})
describe('身宫计算验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`${data.birthTime}(${data.gender}) 身宫地支应为${EarthlyBranch.name(data.expectedShenGongBranch)}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
expect(chart.shenGongBranch).toBe(data.expectedShenGongBranch)
})
}
})
describe('命宫公式独立性验证', () => {
for (const data of CROSS_VALIDATION_DATA) {
it(`命宫=14-月支-时支 验证 ${data.birthTime}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
const monthIndex = EarthlyBranch.index(chart.birthInfo.monthBranch)
const hourIndex = EarthlyBranch.index(chart.birthInfo.hourBranch)
let expectedMingGongIndex = 14 - monthIndex - hourIndex
while (expectedMingGongIndex <= 0) expectedMingGongIndex += 12
if (expectedMingGongIndex > 12) expectedMingGongIndex -= 12
expect(EarthlyBranch.index(chart.mingGongBranch)).toBe(expectedMingGongIndex)
})
it(`身宫=2+月支+时支 验证 ${data.birthTime}`, async () => {
const chart = await ziweiService.generateChart({
birthTime: data.birthTime,
gender: data.gender,
timezone: 'Asia/Shanghai',
})
const monthIndex = EarthlyBranch.index(chart.birthInfo.monthBranch)
const hourIndex = EarthlyBranch.index(chart.birthInfo.hourBranch)
let expectedShenGongIndex = 2 + monthIndex + hourIndex
while (expectedShenGongIndex > 12) expectedShenGongIndex -= 12
expect(EarthlyBranch.index(chart.shenGongBranch)).toBe(expectedShenGongIndex)
})
}
})
describe('盘面结构完整性验证', () => {
it('应生成12个宫位', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.palaces.length).toBe(12)
})
it('每个宫位应有宫型、地支和主星', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
for (const palace of chart.palaces) {
expect(palace.palaceType).toBeTruthy()
expect(palace.earthlyBranch).toBeTruthy()
expect(Array.isArray(palace.majorStars)).toBe(true)
}
})
it('命宫应存在于盘面中', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
const mingPalace = chart.palaces.find(p => p.palaceType === PalaceType.MING)
expect(mingPalace).toBeDefined()
expect(mingPalace!.earthlyBranch).toBe(chart.mingGongBranch)
})
it('三方四正应包含四个宫位且命宫正确', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.sanFangSiZheng).toBeDefined()
expect(chart.sanFangSiZheng!.mingGong.palaceType).toBe(PalaceType.MING)
expect(chart.sanFangSiZheng!.caiBoGong).toBeDefined()
expect(chart.sanFangSiZheng!.guanLuGong).toBeDefined()
expect(chart.sanFangSiZheng!.qianYiGong).toBeDefined()
})
it('三方四正各宫地支应满足对宫关系', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
const sfz = chart.sanFangSiZheng!
const mingIndex = EarthlyBranch.index(sfz.mingGong.earthlyBranch)
const caiBoIndex = EarthlyBranch.index(sfz.caiBoGong.earthlyBranch)
const guanLuIndex = EarthlyBranch.index(sfz.guanLuGong.earthlyBranch)
const qianYiIndex = EarthlyBranch.index(sfz.qianYiGong.earthlyBranch)
let expectedCaiBo = mingIndex + 5
if (expectedCaiBo > 12) expectedCaiBo -= 12
let expectedGuanLu = mingIndex + 10
if (expectedGuanLu > 12) expectedGuanLu -= 12
let expectedQianYi = mingIndex + 6
if (expectedQianYi > 12) expectedQianYi -= 12
expect(caiBoIndex).toBe(expectedCaiBo)
expect(guanLuIndex).toBe(expectedGuanLu)
expect(qianYiIndex).toBe(expectedQianYi)
})
})
describe('确定性验证', () => {
it('相同参数应产生完全相同的盘', async () => {
const params = {
birthTime: '1990-01-15T10:00:00',
gender: 'male' as const,
timezone: 'Asia/Shanghai',
}
const a = await ziweiService.generateChart(params)
const b = await ziweiService.generateChart(params)
expect(a.mingGongBranch).toBe(b.mingGongBranch)
expect(a.shenGongBranch).toBe(b.shenGongBranch)
expect(a.palaces.length).toBe(b.palaces.length)
expect(a.overallLuck).toBe(b.overallLuck)
})
})
describe('边界条件验证', () => {
it('子时(0点)应映射为ZI', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T00:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.hourBranch).toBe(EarthlyBranch.ZI)
})
it('子时(1点)应映射为ZI', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T01:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.hourBranch).toBe(EarthlyBranch.ZI)
})
it('亥时(23点)应映射为HAI', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T23:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.hourBranch).toBe(EarthlyBranch.HAI)
})
it('1月应映射为YIN', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.monthBranch).toBe(EarthlyBranch.YIN)
})
it('12月应映射为CHOU', async () => {
const chart = await ziweiService.generateChart({
birthTime: '1990-12-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(chart.birthInfo.monthBranch).toBe(EarthlyBranch.CHOU)
})
})
})
@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest'
import {
HeavenlyStem,
EarthlyBranch,
MajorStar,
PalaceType,
StarNature,
TransformationType,
FortuneType,
MinorStar,
MinorStarType,
} from '../enums'
describe('HeavenlyStem', () => {
it('应有10个天干', () => {
const values = Object.values(HeavenlyStem).filter((v) => typeof v === 'string')
expect(values.length).toBe(10)
})
it('fromIndex 应正确返回天干', () => {
expect(HeavenlyStem.fromIndex(1)).toBe(HeavenlyStem.JIA)
expect(HeavenlyStem.fromIndex(10)).toBe(HeavenlyStem.GUI)
expect(HeavenlyStem.fromIndex(11)).toBe(HeavenlyStem.JIA)
expect(HeavenlyStem.fromIndex(0)).toBe(HeavenlyStem.GUI)
expect(HeavenlyStem.fromIndex(13)).toBe(HeavenlyStem.BING)
})
it('每个天干应有 name 和 index', () => {
expect(HeavenlyStem.name(HeavenlyStem.JIA)).toBe('甲')
expect(HeavenlyStem.index(HeavenlyStem.JIA)).toBe(1)
expect(HeavenlyStem.name(HeavenlyStem.GUI)).toBe('癸')
expect(HeavenlyStem.index(HeavenlyStem.GUI)).toBe(10)
})
it('values 应返回所有天干', () => {
const values = HeavenlyStem.values()
expect(values.length).toBe(10)
expect(values[0]).toBe(HeavenlyStem.JIA)
expect(values[9]).toBe(HeavenlyStem.GUI)
})
})
describe('EarthlyBranch', () => {
it('应有12个地支', () => {
const values = Object.values(EarthlyBranch).filter((v) => typeof v === 'string')
expect(values.length).toBe(12)
})
it('fromIndex 应正确返回地支', () => {
expect(EarthlyBranch.fromIndex(1)).toBe(EarthlyBranch.ZI)
expect(EarthlyBranch.fromIndex(12)).toBe(EarthlyBranch.HAI)
expect(EarthlyBranch.fromIndex(13)).toBe(EarthlyBranch.ZI)
expect(EarthlyBranch.fromIndex(0)).toBe(EarthlyBranch.HAI)
})
it('每个地支应有 name 和 index', () => {
expect(EarthlyBranch.name(EarthlyBranch.ZI)).toBe('子')
expect(EarthlyBranch.index(EarthlyBranch.ZI)).toBe(1)
expect(EarthlyBranch.name(EarthlyBranch.HAI)).toBe('亥')
expect(EarthlyBranch.index(EarthlyBranch.HAI)).toBe(12)
})
it('values 应返回所有地支', () => {
const values = EarthlyBranch.values()
expect(values.length).toBe(12)
expect(values[0]).toBe(EarthlyBranch.ZI)
expect(values[11]).toBe(EarthlyBranch.HAI)
})
})
describe('MajorStar', () => {
it('应有14颗主星', () => {
const values = Object.values(MajorStar).filter((v) => typeof v === 'string')
expect(values.length).toBe(14)
})
it('紫微星应有正确属性', () => {
expect(MajorStar.name(MajorStar.ZIWEI)).toBe('紫微')
expect(MajorStar.index(MajorStar.ZIWEI)).toBe(1)
expect(MajorStar.nature(MajorStar.ZIWEI)).toBe(StarNature.JI)
expect(MajorStar.alias(MajorStar.ZIWEI)).toBe('帝星')
expect(MajorStar.description(MajorStar.ZIWEI)).toBe('领导、权威、尊贵')
})
it('破军星应为凶星', () => {
expect(MajorStar.nature(MajorStar.POJUN)).toBe(StarNature.XIONG)
expect(MajorStar.alias(MajorStar.POJUN)).toBe('耗星')
})
it('太阴星应为中和', () => {
expect(MajorStar.nature(MajorStar.TAIYIN)).toBe(StarNature.ZHONGHE)
})
it('values 应返回所有主星', () => {
const values = MajorStar.values()
expect(values.length).toBe(14)
})
})
describe('PalaceType', () => {
it('应有12个宫位', () => {
const values = Object.values(PalaceType).filter((v) => typeof v === 'string')
expect(values.length).toBe(12)
})
it('命宫名称应为命宫', () => {
expect(PalaceType.name(PalaceType.MING)).toBe('命宫')
})
it('身宫名称应为身宫', () => {
expect(PalaceType.name(PalaceType.SHEN)).toBe('身宫')
})
it('官禄宫名称应为官禄宫', () => {
expect(PalaceType.name(PalaceType.GUANLU)).toBe('官禄宫')
})
it('values 应返回所有宫位', () => {
const values = PalaceType.values()
expect(values.length).toBe(12)
})
})
describe('StarNature', () => {
it('应有3种星性', () => {
const values = Object.values(StarNature).filter((v) => typeof v === 'string')
expect(values.length).toBe(3)
})
it('吉星应有正确描述', () => {
expect(StarNature.name(StarNature.JI)).toBe('吉')
expect(StarNature.description(StarNature.JI)).toBe('吉星')
})
it('凶星应有正确描述', () => {
expect(StarNature.name(StarNature.XIONG)).toBe('凶')
expect(StarNature.description(StarNature.XIONG)).toBe('凶星')
})
it('中和应有正确描述', () => {
expect(StarNature.name(StarNature.ZHONGHE)).toBe('中')
expect(StarNature.description(StarNature.ZHONGHE)).toBe('中性')
})
})
describe('TransformationType', () => {
it('应有4种四化类型', () => {
const values = Object.values(TransformationType).filter((v) => typeof v === 'string')
expect(values.length).toBe(4)
})
it('四化名称应正确', () => {
expect(TransformationType.name(TransformationType.LU)).toBe('禄')
expect(TransformationType.name(TransformationType.QUAN)).toBe('权')
expect(TransformationType.name(TransformationType.KE)).toBe('科')
expect(TransformationType.name(TransformationType.JI)).toBe('忌')
})
})
describe('FortuneType', () => {
it('应有10种运势类型', () => {
const values = Object.values(FortuneType).filter((v) => typeof v === 'string')
expect(values.length).toBe(10)
})
it('应包含日运和月运', () => {
expect(values().includes(FortuneType.DAILY)).toBe(true)
expect(values().includes(FortuneType.MONTHLY)).toBe(true)
})
function values() {
return Object.values(FortuneType).filter((v) => typeof v === 'string') as FortuneType[]
}
})
describe('MinorStar', () => {
it('应有22颗辅星', () => {
const values = Object.values(MinorStar).filter((v) => typeof v === 'string')
expect(values.length).toBe(22)
})
it('左辅应为吉星', () => {
expect(MinorStar.name(MinorStar.ZUOFU)).toBe('左辅')
expect(MinorStar.type(MinorStar.ZUOFU)).toBe(MinorStarType.LUCKY)
})
it('擎羊应为煞星', () => {
expect(MinorStar.type(MinorStar.QINGYANG)).toBe(MinorStarType.EVIL)
})
it('禄存应为其他类型', () => {
expect(MinorStar.type(MinorStar.LUCUN)).toBe(MinorStarType.OTHER)
})
})
describe('MinorStarType', () => {
it('应有3种辅星类型', () => {
const values = Object.values(MinorStarType).filter((v) => typeof v === 'string')
expect(values.length).toBe(3)
})
it('吉星描述应正确', () => {
expect(MinorStarType.description(MinorStarType.LUCKY)).toBe('吉星')
})
})
@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import {
analyzeLoveFortune,
analyzeStudyFortune,
analyzeSocialFortune,
analyzeTravelFortune,
generateComprehensiveFortune,
generateComprehensiveMonthlyFortune,
generateComprehensiveYearlyFortune,
} from '../fortune'
import { PalaceType, StarNature, EarthlyBranch } from '../enums'
import type { Palace, StarInfo, ZiweiChart, BirthInfo } from '../types'
function makeStarInfo(star: string = 'ZIWEI', nature: StarNature = StarNature.JI): StarInfo {
return { star, nature, transformation: null, brightness: 80, description: '' }
}
function makePalace(palaceType: PalaceType, score: number = 60, stars: StarInfo[] = []): Palace {
return {
palaceType,
earthlyBranch: EarthlyBranch.YIN,
majorStars: stars,
minorStars: [],
analysis: '',
score,
}
}
function makeChart(): ZiweiChart {
const palaceTypes = PalaceType.values()
const palaces: Palace[] = palaceTypes.map((pt, i) => makePalace(pt, 50 + i * 4))
return {
birthInfo: {} as BirthInfo,
palaces,
yearStem: 'JIA' as any,
mingGongBranch: EarthlyBranch.YIN,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
}
}
describe('analyzeLoveFortune', () => {
it('应返回完整的感情运势详情', () => {
const chart = makeChart()
const result = analyzeLoveFortune(chart, new Date('2026-04-27'))
expect(result.loveScore).toBeGreaterThanOrEqual(0)
expect(result.loveScore).toBeLessThanOrEqual(100)
expect(result.overallLoveLuck).toBeTruthy()
expect(result.peachBlossomIndex).toBeTruthy()
expect(result.singleAdvice).toBeTruthy()
expect(result.datingAdvice).toBeTruthy()
expect(result.marriageAdvice).toBeTruthy()
expect(result.bestDatingTime).toBeTruthy()
})
it('夫妻宫高分时感情评分应较高', () => {
const chart = makeChart()
const fuqiPalace = chart.palaces.find(p => p.palaceType === PalaceType.FUQI)!
fuqiPalace.score = 90
fuqiPalace.majorStars = [makeStarInfo('TIANXI', StarNature.JI)]
const result = analyzeLoveFortune(chart, new Date('2026-04-27'))
expect(result.loveScore).toBeGreaterThanOrEqual(60)
})
it('夫妻宫低分时感情评分应较低', () => {
const chart = makeChart()
const fuqiPalace = chart.palaces.find(p => p.palaceType === PalaceType.FUQI)!
fuqiPalace.score = 20
fuqiPalace.majorStars = []
const result = analyzeLoveFortune(chart, new Date('2026-04-27'))
expect(result.loveScore).toBeLessThanOrEqual(60)
})
it('有桃花星时桃花指数应较高', () => {
const chart = makeChart()
const fuqiPalace = chart.palaces.find(p => p.palaceType === PalaceType.FUQI)!
fuqiPalace.minorStars = ['HONGLUAN', 'TIANXI']
const result = analyzeLoveFortune(chart, new Date('2026-04-27'))
expect(result.peachBlossomIndex).toBeTruthy()
})
})
describe('analyzeStudyFortune', () => {
it('应返回完整的学业运势详情', () => {
const chart = makeChart()
const result = analyzeStudyFortune(chart, new Date('2026-04-27'))
expect(result.studyScore).toBeGreaterThanOrEqual(0)
expect(result.studyScore).toBeLessThanOrEqual(100)
expect(result.overallStudyLuck).toBeTruthy()
expect(result.focusIndex).toBeTruthy()
expect(result.memoryIndex).toBeTruthy()
expect(result.studyAdvice).toBeTruthy()
expect(result.examLuck).toBeTruthy()
expect(result.bestStudyTime).toBeTruthy()
expect(result.suitableSubjects).toBeTruthy()
expect(result.studyEnvironmentAdvice).toBeTruthy()
})
it('官禄宫高分时学业评分应较高', () => {
const chart = makeChart()
const guanluPalace = chart.palaces.find(p => p.palaceType === PalaceType.GUANLU)!
guanluPalace.score = 90
const result = analyzeStudyFortune(chart, new Date('2026-04-27'))
expect(result.studyScore).toBeGreaterThanOrEqual(60)
})
it('有文昌文曲星时学业运势应提升', () => {
const chart = makeChart()
const guanluPalace = chart.palaces.find(p => p.palaceType === PalaceType.GUANLU)!
guanluPalace.minorStars = ['WENCHANG', 'WENQU']
const result = analyzeStudyFortune(chart, new Date('2026-04-27'))
expect(result.studyScore).toBeGreaterThanOrEqual(0)
})
})
describe('analyzeSocialFortune', () => {
it('应返回完整的人际运势详情', () => {
const chart = makeChart()
const result = analyzeSocialFortune(chart, new Date('2026-04-27'))
expect(result.socialScore).toBeGreaterThanOrEqual(0)
expect(result.socialScore).toBeLessThanOrEqual(100)
expect(result.overallSocialLuck).toBeTruthy()
expect(result.popularityIndex).toBeTruthy()
expect(result.communicationAbility).toBeTruthy()
expect(result.socialAdvice).toBeTruthy()
expect(result.nobleLuck).toBeTruthy()
expect(result.bestSocialTime).toBeTruthy()
expect(result.suitableOccasion).toBeTruthy()
expect(result.interpersonalAdvice).toBeTruthy()
})
it('兄弟宫高分时人际评分应较高', () => {
const chart = makeChart()
const xiongPalace = chart.palaces.find(p => p.palaceType === PalaceType.XIONG)!
xiongPalace.score = 85
const result = analyzeSocialFortune(chart, new Date('2026-04-27'))
expect(result.socialScore).toBeGreaterThanOrEqual(50)
})
})
describe('analyzeTravelFortune', () => {
it('应返回完整的出行运势详情', () => {
const chart = makeChart()
const result = analyzeTravelFortune(chart, new Date('2026-04-27'))
expect(result.travelScore).toBeGreaterThanOrEqual(0)
expect(result.travelScore).toBeLessThanOrEqual(100)
expect(result.overallTravelLuck).toBeTruthy()
expect(result.safetyIndex).toBeTruthy()
expect(result.smoothnessIndex).toBeTruthy()
expect(result.travelAdvice).toBeTruthy()
expect(result.bestTravelTime).toBeTruthy()
expect(result.suitableTransport).toBeTruthy()
expect(result.precautions).toBeTruthy()
expect(result.nobleDirection).toBeTruthy()
})
it('迁移宫高分时出行评分应较高', () => {
const chart = makeChart()
const qianPalace = chart.palaces.find(p => p.palaceType === PalaceType.QIAN)!
qianPalace.score = 90
const result = analyzeTravelFortune(chart, new Date('2026-04-27'))
expect(result.travelScore).toBeGreaterThanOrEqual(50)
})
})
describe('generateComprehensiveFortune', () => {
it('应生成包含所有详细运势的日运势', () => {
const chart = makeChart()
const result = generateComprehensiveFortune(chart, new Date('2026-04-27'))
expect(result.fortuneDate).toBe('2026-04-27')
expect(result.overallScore).toBeGreaterThanOrEqual(0)
expect(result.overallScore).toBeLessThanOrEqual(100)
expect(result.overallLuck).toBeTruthy()
expect(result.loveScore).toBeDefined()
expect(result.overallLoveLuck).toBeTruthy()
expect(result.studyScore).toBeDefined()
expect(result.overallStudyLuck).toBeTruthy()
expect(result.socialScore).toBeDefined()
expect(result.overallSocialLuck).toBeTruthy()
expect(result.travelScore).toBeDefined()
expect(result.overallTravelLuck).toBeTruthy()
})
it('基础运势字段应完整', () => {
const chart = makeChart()
const result = generateComprehensiveFortune(chart, new Date('2026-04-27'))
expect(result.careerAdvice).toBeTruthy()
expect(result.wealthAdvice).toBeTruthy()
expect(result.relationshipAdvice).toBeTruthy()
expect(result.healthAdvice).toBeTruthy()
})
})
describe('generateComprehensiveMonthlyFortune', () => {
it('应生成包含详细运势的月运势', () => {
const chart = makeChart()
const result = generateComprehensiveMonthlyFortune(chart, 2026, 4)
expect(result.overallScore).toBeGreaterThanOrEqual(0)
expect(result.overallScore).toBeLessThanOrEqual(100)
expect(result.loveScore).toBeDefined()
expect(result.studyScore).toBeDefined()
expect(result.socialScore).toBeDefined()
expect(result.travelScore).toBeDefined()
})
})
describe('generateComprehensiveYearlyFortune', () => {
it('应生成包含详细运势的年运势', () => {
const chart = makeChart()
const result = generateComprehensiveYearlyFortune(chart, 2026)
expect(result.overallScore).toBeGreaterThanOrEqual(0)
expect(result.overallScore).toBeLessThanOrEqual(100)
expect(result.loveScore).toBeDefined()
expect(result.studyScore).toBeDefined()
expect(result.socialScore).toBeDefined()
expect(result.travelScore).toBeDefined()
})
})
@@ -0,0 +1,219 @@
import { describe, it, expect } from 'vitest'
import {
analyzePalaceFortune,
calculateDailyAdjustment,
calculateMonthlyAdjustment,
calculateYearlyAdjustment,
calculateTransformationAdjustment,
calculateMonthlyTransformationAdjustment,
calculateYearlyTransformationAdjustment,
generatePalaceAnalysis,
generatePalaceAdvice,
calculateOverallScore,
generateDailyFortune,
generateMonthlyFortune,
generateYearlyFortune,
} from '../fortuneStrategy'
import { PalaceType, StarNature, TransformationType, EarthlyBranch } from '../enums'
import type { Palace, StarInfo, ZiweiChart, BirthInfo, PalaceFortune } from '../types'
function makeStarInfo(star: string = 'ZIWEI', nature: StarNature = StarNature.JI, transformation: TransformationType | null = null): StarInfo {
return { star, nature, transformation, brightness: 100, description: '' }
}
function makePalace(palaceType: PalaceType, score: number = 60, stars: StarInfo[] = []): Palace {
return {
palaceType,
earthlyBranch: EarthlyBranch.YIN,
majorStars: stars,
minorStars: [],
analysis: '',
score,
}
}
function makeChart(): ZiweiChart {
const palaceTypes = PalaceType.values()
const palaces: Palace[] = palaceTypes.map((pt, i) => makePalace(pt, 50 + i * 4))
return {
birthInfo: {} as BirthInfo,
palaces,
yearStem: 'JIA' as any,
mingGongBranch: EarthlyBranch.YIN,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
}
}
describe('calculateTransformationAdjustment', () => {
it('化禄 dayOfMonth%3===0 应返回5', () => {
expect(calculateTransformationAdjustment(TransformationType.LU, 6)).toBe(5)
})
it('化禄 dayOfMonth%3!==0 应返回2', () => {
expect(calculateTransformationAdjustment(TransformationType.LU, 5)).toBe(2)
})
it('化权 dayOfMonth%7===0 应返回5', () => {
expect(calculateTransformationAdjustment(TransformationType.QUAN, 7)).toBe(5)
})
it('化忌 dayOfMonth%4===0 应返回-8', () => {
expect(calculateTransformationAdjustment(TransformationType.JI, 8)).toBe(-8)
})
it('化忌 dayOfMonth%4!==0 应返回-3', () => {
expect(calculateTransformationAdjustment(TransformationType.JI, 5)).toBe(-3)
})
it('化科 dayOfMonth%5===0 应返回3', () => {
expect(calculateTransformationAdjustment(TransformationType.KE, 10)).toBe(3)
})
})
describe('calculateMonthlyTransformationAdjustment', () => {
it('化禄 month%3===1 应返回6', () => {
expect(calculateMonthlyTransformationAdjustment(TransformationType.LU, 4)).toBe(6)
})
it('化忌 month%4===0 应返回-10', () => {
expect(calculateMonthlyTransformationAdjustment(TransformationType.JI, 8)).toBe(-10)
})
})
describe('calculateYearlyTransformationAdjustment', () => {
it('化禄 year%3===0 应返回8', () => {
expect(calculateYearlyTransformationAdjustment(TransformationType.LU, 2026)).toBe(4)
})
it('化忌 year%4===0 应返回-12', () => {
expect(calculateYearlyTransformationAdjustment(TransformationType.JI, 2024)).toBe(-12)
})
})
describe('calculateDailyAdjustment', () => {
it('吉星1月应+3', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('ZIWEI', StarNature.JI)])
const date = new Date('2026-01-15')
const adj = calculateDailyAdjustment(palace, date)
expect(adj).toBeGreaterThanOrEqual(3)
})
it('凶星2月应减分', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('TANLANG', StarNature.XIONG)])
const date = new Date('2026-02-15')
const adj = calculateDailyAdjustment(palace, date)
expect(adj).toBeLessThan(0)
})
it('无主星应返回0', () => {
const palace = makePalace(PalaceType.MING, 60, [])
const date = new Date('2026-04-27')
expect(calculateDailyAdjustment(palace, date)).toBe(0)
})
})
describe('calculateMonthlyAdjustment', () => {
it('吉星 month%3===1 应+4', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('ZIWEI', StarNature.JI)])
expect(calculateMonthlyAdjustment(palace, 4)).toBeGreaterThanOrEqual(4)
})
it('凶星 month%3===2 应-4', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('TANLANG', StarNature.XIONG)])
expect(calculateMonthlyAdjustment(palace, 5)).toBeLessThanOrEqual(-4)
})
})
describe('calculateYearlyAdjustment', () => {
it('吉星应+3', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('ZIWEI', StarNature.JI)])
expect(calculateYearlyAdjustment(palace, 2026)).toBeGreaterThanOrEqual(3)
})
it('凶星应-3', () => {
const palace = makePalace(PalaceType.MING, 60, [makeStarInfo('TANLANG', StarNature.XIONG)])
expect(calculateYearlyAdjustment(palace, 2026)).toBeLessThanOrEqual(-3)
})
})
describe('analyzePalaceFortune', () => {
it('应返回完整的宫位运势', () => {
const palace = makePalace(PalaceType.MING, 70)
const result = analyzePalaceFortune(palace, new Date('2026-04-27'))
expect(result.palaceType).toBe(PalaceType.MING)
expect(result.score).toBeGreaterThanOrEqual(0)
expect(result.score).toBeLessThanOrEqual(100)
expect(result.luckLevel).toBeTruthy()
expect(result.analysis).toBeTruthy()
expect(result.advice).toBeTruthy()
})
})
describe('generatePalaceAnalysis', () => {
it('应包含宫位类型和评分', () => {
const palace = makePalace(PalaceType.MING, 75)
const analysis = generatePalaceAnalysis(palace, 75)
expect(analysis).toContain('75分')
})
it('有主星时应包含主星信息', () => {
const palace = makePalace(PalaceType.MING, 75, [makeStarInfo('ZIWEI')])
const analysis = generatePalaceAnalysis(palace, 75)
expect(analysis).toContain('ZIWEI')
})
})
describe('generatePalaceAdvice', () => {
it('高分应返回积极建议', () => {
const advice = generatePalaceAdvice(PalaceType.MING, 80)
expect(advice).toContain('极佳')
})
it('中等分应返回中性建议', () => {
const advice = generatePalaceAdvice(PalaceType.MING, 55)
expect(advice).toBeTruthy()
})
it('低分应返回消极建议', () => {
const advice = generatePalaceAdvice(PalaceType.MING, 30)
expect(advice).toContain('欠佳')
})
})
describe('calculateOverallScore', () => {
it('空对象应返回60', () => {
expect(calculateOverallScore({})).toBe(60)
})
it('应计算平均分', () => {
const fortunes: Record<string, PalaceFortune> = {
MING: { palaceType: PalaceType.MING, score: 80, luckLevel: '吉', analysis: '', advice: '' },
CAI: { palaceType: PalaceType.CAI, score: 60, luckLevel: '平', analysis: '', advice: '' },
}
expect(calculateOverallScore(fortunes)).toBe(70)
})
})
describe('generateDailyFortune', () => {
it('应生成完整的日运势', () => {
const chart = makeChart()
const fortune = generateDailyFortune(chart, new Date('2026-04-27'))
expect(fortune.fortuneDate).toBe('2026-04-27')
expect(fortune.overallScore).toBeGreaterThanOrEqual(0)
expect(fortune.overallScore).toBeLessThanOrEqual(100)
expect(fortune.overallLuck).toBeTruthy()
expect(fortune.careerAdvice).toBeTruthy()
expect(fortune.wealthAdvice).toBeTruthy()
expect(fortune.relationshipAdvice).toBeTruthy()
expect(fortune.healthAdvice).toBeTruthy()
})
})
describe('generateMonthlyFortune', () => {
it('应生成完整的月运势', () => {
const chart = makeChart()
const fortune = generateMonthlyFortune(chart, 2026, 4)
expect(fortune.year).toBe(2026)
expect(fortune.month).toBe(4)
expect(fortune.overallScore).toBeGreaterThanOrEqual(0)
expect(fortune.overallScore).toBeLessThanOrEqual(100)
})
})
describe('generateYearlyFortune', () => {
it('应生成完整的年运势', () => {
const chart = makeChart()
const fortune = generateYearlyFortune(chart, 2026)
expect(fortune.year).toBe(2026)
expect(fortune.overallScore).toBeGreaterThanOrEqual(0)
expect(fortune.overallScore).toBeLessThanOrEqual(100)
})
})
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import { calculateSanFangSiZheng, analyzeSanFangSiZheng } from '../sanFangSiZheng'
import { EarthlyBranch, PalaceType, MajorStar, StarNature } from '../enums'
import type { ZiweiChart, Palace, BirthInfo, SanFangSiZheng } from '../types'
function makeChart(mingGongBranch: EarthlyBranch): ZiweiChart {
const branchValues = EarthlyBranch.values()
const palaceTypes = PalaceType.values()
const mingIndex = branchValues.indexOf(mingGongBranch)
const palaces: Palace[] = palaceTypes.map((pt, i) => {
const branchIdx = (mingIndex + i) % 12
return {
palaceType: pt,
earthlyBranch: branchValues[branchIdx],
majorStars: [],
minorStars: [],
analysis: '',
score: 50 + i * 5,
}
})
return {
birthInfo: {} as BirthInfo,
palaces,
yearStem: 'JIA' as any,
mingGongBranch,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
}
}
describe('calculateSanFangSiZheng', () => {
it('命宫寅时应正确计算三方四正', () => {
const chart = makeChart(EarthlyBranch.YIN)
const result = calculateSanFangSiZheng(chart)
expect(result).not.toBeNull()
expect(result!.mingGong.palaceType).toBe(PalaceType.MING)
})
it('财帛宫应为命宫+5', () => {
const chart = makeChart(EarthlyBranch.ZI)
const result = calculateSanFangSiZheng(chart)
const caiBoBranch = result!.caiBoGong.earthlyBranch
const mingIndex = EarthlyBranch.index(chart.mingGongBranch)
const expectedIndex = mingIndex + 5 > 12 ? mingIndex + 5 - 12 : mingIndex + 5
expect(EarthlyBranch.index(caiBoBranch)).toBe(expectedIndex)
})
it('官禄宫应为命宫+10', () => {
const chart = makeChart(EarthlyBranch.ZI)
const result = calculateSanFangSiZheng(chart)
const guanLuBranch = result!.guanLuGong.earthlyBranch
const mingIndex = EarthlyBranch.index(chart.mingGongBranch)
const expectedIndex = mingIndex + 10 > 12 ? mingIndex + 10 - 12 : mingIndex + 10
expect(EarthlyBranch.index(guanLuBranch)).toBe(expectedIndex)
})
it('迁移宫应为命宫+6', () => {
const chart = makeChart(EarthlyBranch.ZI)
const result = calculateSanFangSiZheng(chart)
const qianYiBranch = result!.qianYiGong.earthlyBranch
const mingIndex = EarthlyBranch.index(chart.mingGongBranch)
const expectedIndex = mingIndex + 6 > 12 ? mingIndex + 6 - 12 : mingIndex + 6
expect(EarthlyBranch.index(qianYiBranch)).toBe(expectedIndex)
})
it('三方分值应为命宫+财帛宫+官禄宫的平均', () => {
const chart = makeChart(EarthlyBranch.YIN)
const result = calculateSanFangSiZheng(chart)
const expected = Math.floor(
(result!.mingGong.score + result!.caiBoGong.score + result!.guanLuGong.score) / 3,
)
expect(result!.sanFangScore).toBe(expected)
})
it('四正分值应为命宫+财帛宫+官禄宫+迁移宫的平均', () => {
const chart = makeChart(EarthlyBranch.YIN)
const result = calculateSanFangSiZheng(chart)
const expected = Math.floor(
(result!.mingGong.score + result!.caiBoGong.score + result!.guanLuGong.score + result!.qianYiGong.score) / 4,
)
expect(result!.siZhengScore).toBe(expected)
})
it('命宫午时应正确计算', () => {
const chart = makeChart(EarthlyBranch.WU)
const result = calculateSanFangSiZheng(chart)
expect(result).not.toBeNull()
expect(result!.mingGong.earthlyBranch).toBe(EarthlyBranch.WU)
})
it('命宫亥时应正确计算', () => {
const chart = makeChart(EarthlyBranch.HAI)
const result = calculateSanFangSiZheng(chart)
expect(result).not.toBeNull()
expect(result!.mingGong.earthlyBranch).toBe(EarthlyBranch.HAI)
})
})
describe('analyzeSanFangSiZheng', () => {
it('应生成分析报告', () => {
const chart = makeChart(EarthlyBranch.YIN)
const sfz = calculateSanFangSiZheng(chart)!
const analysis = analyzeSanFangSiZheng(sfz)
expect(analysis).toContain('三方四正分析')
expect(analysis).toContain('命宫')
expect(analysis).toContain('财帛宫')
expect(analysis).toContain('官禄宫')
expect(analysis).toContain('迁移宫')
expect(analysis).toContain('三方平均分')
expect(analysis).toContain('四正平均分')
})
})
@@ -0,0 +1,395 @@
import { describe, it, expect } from 'vitest'
import type {
BirthInfo,
StarInfo,
Palace,
ZiweiChart,
Almanac,
PalaceFortune,
DailyFortune,
MonthlyFortune,
YearlyFortune,
LunarDate,
SanFangSiZheng,
Pattern,
} from '../types'
import {
HeavenlyStem,
EarthlyBranch,
MajorStar,
StarNature,
TransformationType,
PalaceType,
} from '../enums'
describe('BirthInfo', () => {
it('应可正确构造包含所有必填字段', () => {
const birthInfo: BirthInfo = {
birthTime: '1990-05-15T08:30:00',
yearStem: HeavenlyStem.GENG,
monthStem: HeavenlyStem.XIN,
dayStem: HeavenlyStem.JIA,
hourStem: HeavenlyStem.WU,
yearBranch: EarthlyBranch.WU,
monthBranch: EarthlyBranch.SI,
dayBranch: EarthlyBranch.ZI,
hourBranch: EarthlyBranch.CHEN,
gender: 'MALE',
timezone: 'Asia/Shanghai',
}
expect(birthInfo.gender).toBe('MALE')
expect(birthInfo.yearStem).toBe(HeavenlyStem.GENG)
expect(birthInfo.yearBranch).toBe(EarthlyBranch.WU)
expect(birthInfo.timezone).toBe('Asia/Shanghai')
})
it('应支持可选字段 longitude/latitude/trueSolarTime/lunarDate', () => {
const birthInfo: BirthInfo = {
birthTime: '1990-05-15T08:30:00',
yearStem: HeavenlyStem.GENG,
monthStem: HeavenlyStem.XIN,
dayStem: HeavenlyStem.JIA,
hourStem: HeavenlyStem.WU,
yearBranch: EarthlyBranch.WU,
monthBranch: EarthlyBranch.SI,
dayBranch: EarthlyBranch.ZI,
hourBranch: EarthlyBranch.CHEN,
gender: 'FEMALE',
timezone: 'Asia/Shanghai',
longitude: 116.4,
latitude: 39.9,
trueSolarTime: '1990-05-15T08:24:00',
lunarDate: {
lunarYear: '庚午年',
lunarMonth: '四月',
lunarDay: '廿一',
isLeapMonth: false,
zodiac: '马',
solarTerm: '',
weekday: '星期二',
},
}
expect(birthInfo.longitude).toBe(116.4)
expect(birthInfo.lunarDate?.zodiac).toBe('马')
})
})
describe('LunarDate', () => {
it('应可正确构造', () => {
const lunarDate: LunarDate = {
lunarYear: '丙午年',
lunarMonth: '三月',
lunarDay: '初五',
isLeapMonth: false,
zodiac: '马',
solarTerm: '清明',
weekday: '星期三',
}
expect(lunarDate.lunarYear).toBe('丙午年')
expect(lunarDate.isLeapMonth).toBe(false)
expect(lunarDate.solarTerm).toBe('清明')
})
})
describe('StarInfo', () => {
it('应可正确构造', () => {
const starInfo: StarInfo = {
star: MajorStar.ZIWEI,
nature: StarNature.JI,
transformation: TransformationType.LU,
brightness: 85,
description: '帝星入命',
}
expect(starInfo.star).toBe(MajorStar.ZIWEI)
expect(starInfo.brightness).toBe(85)
expect(starInfo.transformation).toBe(TransformationType.LU)
})
it('transformation 应可为 null', () => {
const starInfo: StarInfo = {
star: MajorStar.TIANJI,
nature: StarNature.JI,
transformation: null,
brightness: 100,
description: '',
}
expect(starInfo.transformation).toBeNull()
})
})
describe('Palace', () => {
it('应可正确构造', () => {
const palace: Palace = {
palaceType: PalaceType.MING,
earthlyBranch: EarthlyBranch.YIN,
majorStars: [],
minorStars: [],
analysis: '',
score: 50,
}
expect(palace.palaceType).toBe(PalaceType.MING)
expect(palace.earthlyBranch).toBe(EarthlyBranch.YIN)
expect(palace.score).toBe(50)
})
it('应支持包含主星', () => {
const palace: Palace = {
palaceType: PalaceType.MING,
earthlyBranch: EarthlyBranch.YIN,
majorStars: [
{
star: MajorStar.ZIWEI,
nature: StarNature.JI,
transformation: null,
brightness: 90,
description: '紫微入命',
},
],
minorStars: ['左辅', '右弼'],
analysis: '帝星坐命,领导力强',
score: 85,
}
expect(palace.majorStars.length).toBe(1)
expect(palace.minorStars).toEqual(['左辅', '右弼'])
})
})
describe('ZiweiChart', () => {
it('应可正确构造', () => {
const chart: ZiweiChart = {
birthInfo: {} as BirthInfo,
palaces: [],
yearStem: HeavenlyStem.JIA,
mingGongBranch: EarthlyBranch.YIN,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
}
expect(chart.palaces).toEqual([])
expect(chart.mingGongBranch).toBe(EarthlyBranch.YIN)
})
it('应支持可选字段 sanFangSiZheng 和 patterns', () => {
const chart: ZiweiChart = {
birthInfo: {} as BirthInfo,
palaces: [],
yearStem: HeavenlyStem.JIA,
mingGongBranch: EarthlyBranch.YIN,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
sanFangSiZheng: {
mingGong: {} as Palace,
caiBoGong: {} as Palace,
guanLuGong: {} as Palace,
qianYiGong: {} as Palace,
sanFangScore: 75,
siZhengScore: 80,
},
patterns: [
{ name: '紫府同宫', description: '紫微天府同宫', type: '吉格' },
],
}
expect(chart.sanFangSiZheng?.sanFangScore).toBe(75)
expect(chart.patterns?.length).toBe(1)
})
})
describe('Almanac', () => {
it('应可正确构造', () => {
const almanac: Almanac = {
solarDate: '2026-04-27T00:00:00',
lunarDate: {
lunarYear: '丙午年',
lunarMonth: '三月',
lunarDay: '初一',
isLeapMonth: false,
zodiac: '马',
solarTerm: '',
weekday: '星期一',
},
suitable: ['祭祀', '祈福'],
unsuitable: ['动土', '破土'],
godDirection: '东南',
joyDirection: '西北',
fortuneDirection: '正南',
nobleDirection: '正东',
clash: '鼠',
evil: '天狗',
jianChu: '建',
starGod: '青龙',
naYin: '天河水',
fetusGod: '占门碓外东南',
pengzuTaboo: '丙不修灶必见灾殃',
}
expect(almanac.suitable.length).toBe(2)
expect(almanac.clash).toBe('鼠')
expect(almanac.lunarDate.zodiac).toBe('马')
})
})
describe('PalaceFortune', () => {
it('应可正确构造', () => {
const fortune: PalaceFortune = {
palaceType: PalaceType.MING,
score: 80,
luckLevel: '吉',
analysis: '命宫运势良好',
advice: '宜积极进取',
}
expect(fortune.palaceType).toBe(PalaceType.MING)
expect(fortune.score).toBe(80)
expect(fortune.luckLevel).toBe('吉')
})
})
describe('DailyFortune', () => {
it('应可正确构造包含核心字段', () => {
const fortune: DailyFortune = {
fortuneDate: '2026-04-27',
overallLuck: '今日运势平稳',
overallScore: 75,
palaceFortunes: {},
careerAdvice: '专注工作',
wealthAdvice: '谨慎理财',
relationshipAdvice: '多沟通',
healthAdvice: '注意休息',
luckyColor: '红色',
luckyNumber: '3',
luckyDirection: '正南',
}
expect(fortune.overallScore).toBe(75)
expect(fortune.luckyColor).toBe('红色')
})
it('应支持爱情/学业/社交/出行等扩展字段', () => {
const fortune: DailyFortune = {
fortuneDate: '2026-04-27',
overallLuck: '',
overallScore: 70,
palaceFortunes: {},
careerAdvice: '',
wealthAdvice: '',
relationshipAdvice: '',
healthAdvice: '',
luckyColor: '',
luckyNumber: '',
luckyDirection: '',
loveScore: 80,
overallLoveLuck: '桃花旺盛',
peachBlossomIndex: '高',
singleAdvice: '主动出击',
datingAdvice: '适合约会',
marriageAdvice: '感情稳定',
bestDatingTime: '下午3-5点',
studyScore: 65,
overallStudyLuck: '学业一般',
focusIndex: '中',
memoryIndex: '中',
studyAdvice: '集中注意力',
examLuck: '中等',
bestStudyTime: '上午9-11点',
suitableSubjects: '数学、物理',
studyEnvironmentAdvice: '安静环境',
socialScore: 85,
overallSocialLuck: '社交活跃',
popularityIndex: '高',
communicationAbility: '良好',
socialAdvice: '多参加聚会',
nobleLuck: '有贵人',
bestSocialTime: '晚上7-9点',
suitableOccasion: '商务宴会',
interpersonalAdvice: '注意言辞',
travelScore: 60,
overallTravelLuck: '出行平稳',
safetyIndex: '中',
smoothnessIndex: '中',
travelAdvice: '注意安全',
bestTravelTime: '上午',
suitableTransport: '高铁',
precautions: '避免夜间出行',
nobleDirection: '正东',
}
expect(fortune.loveScore).toBe(80)
expect(fortune.studyScore).toBe(65)
expect(fortune.socialScore).toBe(85)
expect(fortune.travelScore).toBe(60)
})
})
describe('MonthlyFortune', () => {
it('应可正确构造包含核心字段', () => {
const fortune: MonthlyFortune = {
fortuneMonth: '2026-04',
overallLuck: '本月运势上升',
overallScore: 78,
palaceFortunes: {},
dailyFortunes: [],
careerAdvice: '把握机遇',
wealthAdvice: '稳健投资',
relationshipAdvice: '珍惜感情',
healthAdvice: '注意饮食',
luckyColor: '蓝色',
luckyNumber: '7',
luckyDirection: '正北',
keyFocus: '事业发展',
cautionAdvice: '避免冲动',
}
expect(fortune.fortuneMonth).toBe('2026-04')
expect(fortune.keyFocus).toBe('事业发展')
})
})
describe('YearlyFortune', () => {
it('应可正确构造包含核心字段', () => {
const fortune: YearlyFortune = {
fortuneYear: 2026,
overallLuck: '本年运势大吉',
overallScore: 85,
palaceFortunes: {},
monthlyFortunes: [],
careerAdvice: '大胆创新',
wealthAdvice: '积极投资',
relationshipAdvice: '感情丰收',
healthAdvice: '注意体检',
luckyColor: '金色',
luckyNumber: '8',
luckyDirection: '正西',
keyFocus: '事业突破',
cautionAdvice: '保持谦逊',
yearlyTheme: '变革之年',
majorOpportunity: '事业晋升',
majorChallenge: '人际关系',
}
expect(fortune.fortuneYear).toBe(2026)
expect(fortune.yearlyTheme).toBe('变革之年')
expect(fortune.majorOpportunity).toBe('事业晋升')
})
})
describe('SanFangSiZheng', () => {
it('应可正确构造', () => {
const sfz: SanFangSiZheng = {
mingGong: {} as Palace,
caiBoGong: {} as Palace,
guanLuGong: {} as Palace,
qianYiGong: {} as Palace,
sanFangScore: 70,
siZhengScore: 75,
}
expect(sfz.sanFangScore).toBe(70)
expect(sfz.siZhengScore).toBe(75)
})
})
describe('Pattern', () => {
it('应可正确构造', () => {
const pattern: Pattern = {
name: '紫府同宫',
description: '紫微天府同宫,主富贵双全',
type: '吉格',
}
expect(pattern.name).toBe('紫府同宫')
expect(pattern.type).toBe('吉格')
})
})
@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest'
import { getStarNature, calculateStarScore, calculatePalaceScore, calculateLuckyColor, calculateLuckyNumber, calculateLuckyDirection, determineLuckLevel } from '../ziweiAlgorithm'
import { MajorStar, StarNature, TransformationType, EarthlyBranch, PalaceType } from '../enums'
import type { StarInfo, Palace, ZiweiChart, BirthInfo } from '../types'
function makeStarInfo(star: MajorStar, transformation: TransformationType | null = null, brightness: number = 100): StarInfo {
return {
star,
nature: MajorStar.nature(star),
transformation,
brightness,
description: '',
}
}
function makePalace(palaceType: PalaceType, branch: EarthlyBranch, stars: StarInfo[] = [], score: number = 50): Palace {
return {
palaceType,
earthlyBranch: branch,
majorStars: stars,
minorStars: [],
analysis: '',
score,
}
}
function makeChart(mingGongBranch: EarthlyBranch, mingGongStars: StarInfo[] = []): ZiweiChart {
const branchValues = EarthlyBranch.values()
const palaceTypes = PalaceType.values()
const mingIndex = branchValues.indexOf(mingGongBranch)
const palaces: Palace[] = palaceTypes.map((pt, i) => {
const branchIdx = (mingIndex + i) % 12
const stars = pt === PalaceType.MING ? mingGongStars : []
return makePalace(pt, branchValues[branchIdx], stars, 50 + i * 5)
})
return {
birthInfo: {} as BirthInfo,
palaces,
yearStem: 'JIA' as any,
mingGongBranch,
shenGongBranch: EarthlyBranch.SHEN,
summary: '',
overallLuck: '',
}
}
describe('getStarNature', () => {
it('紫微应为吉星', () => {
expect(getStarNature(MajorStar.ZIWEI)).toBe(StarNature.JI)
})
it('贪狼应为凶星', () => {
expect(getStarNature(MajorStar.TANLANG)).toBe(StarNature.XIONG)
})
it('太阴应为中和', () => {
expect(getStarNature(MajorStar.TAIYIN)).toBe(StarNature.ZHONGHE)
})
it('天府应为吉星', () => {
expect(getStarNature(MajorStar.TIANFU)).toBe(StarNature.JI)
})
it('七杀应为凶星', () => {
expect(getStarNature(MajorStar.QISHA)).toBe(StarNature.XIONG)
})
})
describe('calculateStarScore', () => {
it('紫微无四化亮度100应为85', () => {
const starInfo = makeStarInfo(MajorStar.ZIWEI, null, 100)
expect(calculateStarScore(starInfo)).toBe(85)
})
it('紫微化禄亮度100应为100', () => {
const starInfo = makeStarInfo(MajorStar.ZIWEI, TransformationType.LU, 100)
expect(calculateStarScore(starInfo)).toBe(100)
})
it('紫微化忌亮度100应为70', () => {
const starInfo = makeStarInfo(MajorStar.ZIWEI, TransformationType.JI, 100)
expect(calculateStarScore(starInfo)).toBe(70)
})
it('紫微亮度50应为42', () => {
const starInfo = makeStarInfo(MajorStar.ZIWEI, null, 50)
expect(calculateStarScore(starInfo)).toBe(42)
})
it('空值应返回50', () => {
expect(calculateStarScore(null as any)).toBe(50)
})
it('天府亮度100无四化应为85', () => {
const starInfo = makeStarInfo(MajorStar.TIANFU, null, 100)
expect(calculateStarScore(starInfo)).toBe(85)
})
it('贪狼化权亮度100应为70', () => {
const starInfo = makeStarInfo(MajorStar.TANLANG, TransformationType.QUAN, 100)
expect(calculateStarScore(starInfo)).toBe(70)
})
it('廉贞化科亮度100应为63', () => {
const starInfo = makeStarInfo(MajorStar.LIANCHEN, TransformationType.KE, 100)
expect(calculateStarScore(starInfo)).toBe(63)
})
})
describe('calculatePalaceScore', () => {
it('无主星应返回50', () => {
const palace = makePalace(PalaceType.MING, EarthlyBranch.YIN, [])
expect(calculatePalaceScore(palace)).toBe(50)
})
it('空值应返回50', () => {
expect(calculatePalaceScore(null as any)).toBe(50)
})
it('单颗紫微星亮度100应为85', () => {
const palace = makePalace(PalaceType.MING, EarthlyBranch.YIN, [makeStarInfo(MajorStar.ZIWEI)])
expect(calculatePalaceScore(palace)).toBe(85)
})
it('两颗星应取平均', () => {
const palace = makePalace(PalaceType.MING, EarthlyBranch.YIN, [
makeStarInfo(MajorStar.ZIWEI),
makeStarInfo(MajorStar.TIANJI),
])
expect(calculatePalaceScore(palace)).toBe(80)
})
it('应更新宫位score属性', () => {
const palace = makePalace(PalaceType.MING, EarthlyBranch.YIN, [makeStarInfo(MajorStar.ZIWEI)])
calculatePalaceScore(palace)
expect(palace.score).toBe(85)
})
})
describe('calculateLuckyColor', () => {
it('命宫寅地支绿色+紫微吉星+日期27应计算正确', () => {
const chart = makeChart(EarthlyBranch.YIN, [makeStarInfo(MajorStar.ZIWEI)])
const result = calculateLuckyColor(chart, new Date('2026-04-27'))
expect(result).toBeTruthy()
expect(LUCKY_COLORS).toContain(result)
})
it('空命盘应返回红色', () => {
expect(calculateLuckyColor(null as any, new Date())).toBe('红色')
})
it('空日期应返回红色', () => {
const chart = makeChart(EarthlyBranch.YIN)
expect(calculateLuckyColor(chart, null as any)).toBe('红色')
})
})
const LUCKY_COLORS = ['红色', '黄色', '蓝色', '绿色', '紫色', '金色', '白色', '黑色']
describe('calculateLuckyNumber', () => {
it('应返回1-9之间的数字', () => {
const chart = makeChart(EarthlyBranch.YIN, [makeStarInfo(MajorStar.ZIWEI)])
const result = calculateLuckyNumber(chart, new Date('2026-04-27'))
const num = parseInt(result, 10)
expect(num).toBeGreaterThanOrEqual(1)
expect(num).toBeLessThanOrEqual(9)
})
it('空命盘应返回8', () => {
expect(calculateLuckyNumber(null as any, new Date())).toBe('8')
})
})
describe('calculateLuckyDirection', () => {
it('应返回有效方位', () => {
const chart = makeChart(EarthlyBranch.YIN, [makeStarInfo(MajorStar.ZIWEI)])
const result = calculateLuckyDirection(chart, new Date('2026-04-27'))
const validDirections = ['东方', '南方', '西方', '北方', '东南', '西南', '东北', '西北']
expect(validDirections).toContain(result)
})
it('空命盘应返回东方', () => {
expect(calculateLuckyDirection(null as any, new Date())).toBe('东方')
})
})
describe('determineLuckLevel', () => {
it('90+应为大吉', () => {
expect(determineLuckLevel(90)).toBe('大吉')
expect(determineLuckLevel(95)).toBe('大吉')
})
it('80-89应为吉', () => {
expect(determineLuckLevel(80)).toBe('吉')
expect(determineLuckLevel(89)).toBe('吉')
})
it('70-79应为中吉', () => {
expect(determineLuckLevel(70)).toBe('中吉')
expect(determineLuckLevel(79)).toBe('中吉')
})
it('60-69应为平', () => {
expect(determineLuckLevel(60)).toBe('平')
expect(determineLuckLevel(69)).toBe('平')
})
it('50-59应为中平', () => {
expect(determineLuckLevel(50)).toBe('中平')
expect(determineLuckLevel(59)).toBe('中平')
})
it('40-49应为小凶', () => {
expect(determineLuckLevel(40)).toBe('小凶')
expect(determineLuckLevel(49)).toBe('小凶')
})
it('0-39应为凶', () => {
expect(determineLuckLevel(0)).toBe('凶')
expect(determineLuckLevel(39)).toBe('凶')
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import FortunePage from '../index.vue'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': {
fortune: {
daily: '日运',
monthly: '月运',
yearly: '年运',
},
},
},
})
function mountPage() {
return mount(FortunePage, {
global: {
plugins: [i18n],
stubs: {
picker: true,
},
},
})
}
describe('FortunePage', () => {
it('应包含运势类型切换', () => {
const wrapper = mountPage()
expect(wrapper.find('[data-test="fortune-tabs"]').exists()).toBe(true)
})
it('默认应显示日运', () => {
const wrapper = mountPage()
expect(wrapper.vm.activeTab).toBe('daily')
})
})
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import ZiweiPage from '../index.vue'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': {
ziwei: {
generateChart: '排盘',
birthTime: '出生时间',
gender: '性别',
},
},
},
})
function mountPage() {
return mount(ZiweiPage, {
global: {
plugins: [i18n],
stubs: {
picker: true,
},
},
})
}
describe('ZiweiPage', () => {
it('应包含出生信息输入表单', () => {
const wrapper = mountPage()
expect(wrapper.find('[data-test="birth-form"]').exists()).toBe(true)
})
it('应包含排盘按钮', () => {
const wrapper = mountPage()
expect(wrapper.find('[data-test="generate-btn"]').exists()).toBe(true)
})
it('点击排盘按钮应触发计算', async () => {
const wrapper = mountPage()
await wrapper.find('[data-test="generate-btn"]').trigger('click')
expect(wrapper.vm.isCalculating).toBeDefined()
})
})
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AlmanacService } from '../almanacService'
describe('AlmanacService', () => {
let service: AlmanacService
beforeEach(() => {
service = new AlmanacService()
})
it('getAlmanacByDate 应返回黄历数据', async () => {
const result = await service.getAlmanacByDate('2026-04-27')
expect(result).not.toBeNull()
expect(result!.solarDate).toBe('2026-04-27')
expect(result!.suitable).toBeInstanceOf(Array)
expect(result!.unsuitable).toBeInstanceOf(Array)
expect(result!.jianChu).toBeTruthy()
expect(result!.starGod).toBeTruthy()
})
it('getAlmanacByDate 无效日期应返回 null', async () => {
const result = await service.getAlmanacByDate('invalid-date')
expect(result).toBeNull()
})
it('getAlmanacsByRange 应返回日期范围数据', async () => {
const result = await service.getAlmanacsByRange('2026-04-27', '2026-04-29')
expect(result.length).toBe(3)
expect(result[0].solarDate).toBe('2026-04-27')
expect(result[2].solarDate).toBe('2026-04-29')
})
it('getAlmanacsByRange 同一天应返回1条', async () => {
const result = await service.getAlmanacsByRange('2026-04-27', '2026-04-27')
expect(result.length).toBe(1)
})
it('getAlmanacsByRange 无效日期应返回空数组', async () => {
const result = await service.getAlmanacsByRange('invalid', '2026-04-29')
expect(result).toEqual([])
})
it('searchAlmanacs 应支持关键词搜索', async () => {
const result = await service.searchAlmanacs({
keyword: '祭祀',
dateFrom: '2026-04-01',
dateTo: '2026-04-30',
})
expect(Array.isArray(result)).toBe(true)
})
it('searchAlmanacs 应支持宜忌筛选', async () => {
const result = await service.searchAlmanacs({
suitableFilter: ['嫁娶'],
dateFrom: '2026-04-01',
dateTo: '2026-04-30',
})
expect(Array.isArray(result)).toBe(true)
for (const item of result) {
expect(item.suitable).toContain('嫁娶')
}
})
it('缓存命中时不应重复计算', async () => {
const spy = vi.spyOn(service as any, 'calculateAlmanac')
await service.getAlmanacByDate('2026-04-27')
await service.getAlmanacByDate('2026-04-27')
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
})
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { FortuneService } from '../fortuneService'
import { ZiweiService } from '../ziweiService'
describe('FortuneService', () => {
let service: FortuneService
let chart: any
beforeEach(async () => {
service = new FortuneService()
const ziweiService = new ZiweiService()
chart = await ziweiService.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
})
it('getDailyFortune 应返回日运数据', async () => {
const result = await service.getDailyFortune(chart, '2026-04-27')
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.overallScore).toBeLessThanOrEqual(100)
expect(result!.overallLuck).toBeTruthy()
expect(result!.careerAdvice).toBeTruthy()
expect(result!.wealthAdvice).toBeTruthy()
})
it('getDailyFortune 无效日期应返回 null', async () => {
const result = await service.getDailyFortune(chart, 'invalid-date')
expect(result).toBeNull()
})
it('getDailyFortune 应包含幸运元素', async () => {
const result = await service.getDailyFortune(chart, '2026-04-27')
expect(result).not.toBeNull()
expect(result!.luckyColor).toBeTruthy()
expect(result!.luckyNumber).toBeTruthy()
expect(result!.luckyDirection).toBeTruthy()
})
it('getMonthlyFortune 应返回月运数据', async () => {
const result = await service.getMonthlyFortune(chart, 2026, 4)
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.overallScore).toBeLessThanOrEqual(100)
expect(result!.fortuneMonth).toBe('2026-04')
})
it('getMonthlyFortune 无效月份应返回 null', async () => {
const result = await service.getMonthlyFortune(chart, 2026, 13)
expect(result).toBeNull()
})
it('getMonthlyFortune 月份为0应返回 null', async () => {
const result = await service.getMonthlyFortune(chart, 2026, 0)
expect(result).toBeNull()
})
it('getYearlyFortune 应返回年运数据', async () => {
const result = await service.getYearlyFortune(chart, 2026)
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.overallScore).toBeLessThanOrEqual(100)
expect(result!.fortuneYear).toBe(2026)
})
it('getYearlyFortune 超出范围年份应返回 null', async () => {
const result = await service.getYearlyFortune(chart, 1800)
expect(result).toBeNull()
})
it('getComprehensiveFortune 应返回包含详细运势的日运数据', async () => {
const result = await service.getComprehensiveFortune(chart, '2026-04-27')
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.loveScore).toBeDefined()
expect(result!.overallLoveLuck).toBeTruthy()
expect(result!.studyScore).toBeDefined()
expect(result!.socialScore).toBeDefined()
expect(result!.travelScore).toBeDefined()
expect(result!.luckyColor).toBeTruthy()
})
it('getComprehensiveFortune 无效日期应返回 null', async () => {
const result = await service.getComprehensiveFortune(chart, 'invalid')
expect(result).toBeNull()
})
it('getComprehensiveMonthlyFortune 应返回包含详细运势的月运数据', async () => {
const result = await service.getComprehensiveMonthlyFortune(chart, 2026, 4)
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.loveScore).toBeDefined()
expect(result!.studyScore).toBeDefined()
})
it('getComprehensiveYearlyFortune 应返回包含详细运势的年运数据', async () => {
const result = await service.getComprehensiveYearlyFortune(chart, 2026)
expect(result).not.toBeNull()
expect(result!.overallScore).toBeGreaterThanOrEqual(0)
expect(result!.loveScore).toBeDefined()
expect(result!.studyScore).toBeDefined()
expect(result!.fortuneYear).toBe(2026)
})
})
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { SearchService } from '../searchService'
import type { SearchRequest } from '../../types/search'
const mockStorage: Record<string, string> = {}
vi.mock('../../utils/storage', () => ({
storage: {
get: vi.fn((key: string) => {
const raw = mockStorage[key]
if (!raw) return null
return JSON.parse(raw)
}),
set: vi.fn((key: string, value: any) => {
mockStorage[key] = JSON.stringify(value)
}),
remove: vi.fn((key: string) => {
delete mockStorage[key]
}),
},
}))
function makeSearchRequest(overrides: Partial<SearchRequest> = {}): SearchRequest {
return {
conditions: [],
days: 30,
sortBy: 'date',
sortOrder: 'asc',
...overrides,
}
}
describe('SearchService', () => {
let service: SearchService
beforeEach(() => {
service = new SearchService()
Object.keys(mockStorage).forEach(k => delete mockStorage[k])
})
it('search 应返回搜索结果', async () => {
const result = await service.search(makeSearchRequest({
conditions: [{ id: '1', type: 'suitable', items: ['祭祀'], operator: 'and' }],
}))
expect(Array.isArray(result)).toBe(true)
})
it('searchByKeyword 应返回搜索结果', async () => {
const result = await service.searchByKeyword('祭祀')
expect(Array.isArray(result)).toBe(true)
})
it('getSearchHistory 应返回历史记录', async () => {
await service.searchByKeyword('祭祀')
const history = await service.getSearchHistory()
expect(history.length).toBeGreaterThan(0)
expect(history[0].keyword).toBe('祭祀')
})
it('clearSearchHistory 应清除历史', async () => {
await service.searchByKeyword('祭祀')
await service.clearSearchHistory()
const history = await service.getSearchHistory()
expect(history.length).toBe(0)
})
it('无关键词搜索不应记录历史', async () => {
await service.search(makeSearchRequest())
const history = await service.getSearchHistory()
expect(history.length).toBe(0)
})
it('搜索历史最多保留50条', async () => {
for (let i = 0; i < 55; i++) {
await service.searchByKeyword(`关键词${i}`)
}
const history = await service.getSearchHistory()
expect(history.length).toBeLessThanOrEqual(50)
})
})
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { TemplateService } from '../templateService'
import type { ReportTemplate } from '../templateService'
const mockStorage: Record<string, string> = {}
vi.mock('../../utils/storage', () => ({
storage: {
get: vi.fn((key: string) => {
const raw = mockStorage[key]
if (!raw) return null
return JSON.parse(raw)
}),
set: vi.fn((key: string, value: any) => {
mockStorage[key] = JSON.stringify(value)
}),
remove: vi.fn((key: string) => {
delete mockStorage[key]
}),
},
}))
describe('TemplateService', () => {
let service: TemplateService
beforeEach(() => {
service = new TemplateService()
Object.keys(mockStorage).forEach(k => delete mockStorage[k])
})
it('getTemplates 应返回模板列表', async () => {
const result = await service.getTemplates()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBeGreaterThan(0)
})
it('getTemplates 应包含默认模板', async () => {
const result = await service.getTemplates()
const ids = result.map(t => t.id)
expect(ids).toContain('daily-brief')
expect(ids).toContain('full-report')
expect(ids).toContain('ziwei-only')
})
it('getTemplateById 应返回指定模板', async () => {
const result = await service.getTemplateById('daily-brief')
expect(result).not.toBeNull()
expect(result!.id).toBe('daily-brief')
expect(result!.name).toBe('每日简报')
})
it('getTemplateById 不存在的模板应返回 null', async () => {
const result = await service.getTemplateById('non-existent')
expect(result).toBeNull()
})
it('saveTemplate 应保存新模板', async () => {
const template: ReportTemplate = {
id: 'test-template',
name: '测试模板',
description: '测试用',
sections: [
{ id: 'almanac', title: '黄历', type: 'almanac', visible: true, order: 1 },
],
}
await service.saveTemplate(template)
const templates = await service.getTemplates()
expect(templates.some(t => t.id === 'test-template')).toBe(true)
})
it('saveTemplate 应更新已有模板', async () => {
const template: ReportTemplate = {
id: 'test-update',
name: '原始名称',
description: '测试用',
sections: [],
}
await service.saveTemplate(template)
template.name = '更新名称'
await service.saveTemplate(template)
const result = await service.getTemplateById('test-update')
expect(result!.name).toBe('更新名称')
})
it('deleteTemplate 应删除模板', async () => {
const template: ReportTemplate = {
id: 'test-delete',
name: '待删除模板',
description: '测试用',
sections: [],
}
await service.saveTemplate(template)
const deleted = await service.deleteTemplate('test-delete')
expect(deleted).toBe(true)
const result = await service.getTemplateById('test-delete')
expect(result).toBeNull()
})
it('deleteTemplate 不存在的模板应返回 false', async () => {
const deleted = await service.deleteTemplate('non-existent')
expect(deleted).toBe(false)
})
it('generateReport 应生成报告', async () => {
const result = await service.generateReport('daily-brief', {
almanac: {
solarDate: '2026-04-27',
lunarDate: {
lunarYear: '丙午',
lunarMonth: '3',
lunarDay: '30',
isLeapMonth: false,
zodiac: '马',
solarTerm: '',
weekday: '',
},
suitable: ['祭祀', '祈福'],
unsuitable: ['嫁娶', '出行'],
godDirection: '东北',
joyDirection: '正北',
fortuneDirection: '正北',
nobleDirection: '丑、未',
clash: '午',
evil: '南',
jianChu: '建',
starGod: '青龙',
naYin: '海中金',
fetusGod: '房床北',
pengzuTaboo: '甲不开仓财物耗散,子不问卜自惹祸殃',
},
})
expect(result).not.toBeNull()
expect(result!.templateId).toBe('daily-brief')
expect(result!.sections.length).toBeGreaterThan(0)
expect(result!.generatedAt).toBeTruthy()
})
it('generateReport 不存在的模板应返回 null', async () => {
const result = await service.generateReport('non-existent', {})
expect(result).toBeNull()
})
})
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { ZiweiService } from '../ziweiService'
describe('ZiweiService', () => {
let service: ZiweiService
beforeEach(() => {
service = new ZiweiService()
})
it('generateChart 应返回紫微盘数据', async () => {
const result = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(result).not.toBeNull()
expect(result.palaces.length).toBe(12)
expect(result.mingGongBranch).toBeTruthy()
expect(result.shenGongBranch).toBeTruthy()
expect(result.summary).toBeTruthy()
expect(result.overallLuck).toBeTruthy()
})
it('generateChart 应包含命宫', async () => {
const result = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
const mingPalace = result.palaces.find(p => p.palaceType === 'MING')
expect(mingPalace).toBeDefined()
})
it('generateChart 应包含三方四正', async () => {
const result = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
expect(result.sanFangSiZheng).toBeDefined()
expect(result.sanFangSiZheng!.sanFangScore).toBeGreaterThanOrEqual(0)
expect(result.sanFangSiZheng!.siZhengScore).toBeGreaterThanOrEqual(0)
})
it('getChartFromCache 应从缓存获取', async () => {
const params = {
birthTime: '1990-01-15T10:00:00',
gender: 'male' as const,
timezone: 'Asia/Shanghai',
}
await service.generateChart(params)
const second = await service.getChartFromCache(params)
expect(second).not.toBeNull()
})
it('相同参数应返回相同结果', async () => {
const params = {
birthTime: '1990-01-15T10:00:00',
gender: 'male' as const,
timezone: 'Asia/Shanghai',
}
const a = await service.generateChart(params)
const b = await service.generateChart(params)
expect(a.mingGongBranch).toBe(b.mingGongBranch)
expect(a.shenGongBranch).toBe(b.shenGongBranch)
})
it('不同性别应产生不同盘', async () => {
const male = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
const female = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'female',
timezone: 'Asia/Shanghai',
})
expect(male.birthInfo.gender).toBe('MALE')
expect(female.birthInfo.gender).toBe('FEMALE')
})
it('宫位评分应在0-100范围内', async () => {
const result = await service.generateChart({
birthTime: '1990-01-15T10:00:00',
gender: 'male',
timezone: 'Asia/Shanghai',
})
for (const palace of result.palaces) {
expect(palace.score).toBeGreaterThanOrEqual(0)
expect(palace.score).toBeLessThanOrEqual(100)
}
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest'
import { exportData, importData, validateImportData } from '../exportImport'
vi.stubGlobal('uni', {
getStorageSync: vi.fn(),
setStorageSync: vi.fn(),
})
describe('exportData', () => {
it('应导出包含版本信息的JSON', () => {
const result = exportData()
const parsed = JSON.parse(result)
expect(parsed.version).toBe('1.0.0')
expect(parsed.exportDate).toBeTruthy()
expect(parsed.data).toBeDefined()
})
})
describe('validateImportData', () => {
it('有效数据应返回true', () => {
const validJson = JSON.stringify({ version: '1.0.0', exportDate: '2026-04-27', data: {} })
expect(validateImportData(validJson)).toBe(true)
})
it('缺少版本信息应返回false', () => {
const invalidJson = JSON.stringify({ data: {} })
expect(validateImportData(invalidJson)).toBe(false)
})
it('无效JSON应返回false', () => {
expect(validateImportData('not json')).toBe(false)
})
})
describe('importData', () => {
it('有效数据应成功导入', () => {
const validJson = JSON.stringify({ version: '1.0.0', exportDate: '2026-04-27', data: { test_key: 'test_value' } })
const result = importData(validJson)
expect(result).toBe(true)
})
it('无效数据应返回false', () => {
const result = importData('invalid')
expect(result).toBe(false)
})
})
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StorageService } from '../storage'
const mockUni = {
getStorageSync: vi.fn(),
setStorageSync: vi.fn(),
removeStorageSync: vi.fn(),
clearStorageSync: vi.fn(),
}
vi.stubGlobal('uni', mockUni)
describe('StorageService', () => {
let storage: StorageService
beforeEach(() => {
storage = new StorageService('test_')
vi.clearAllMocks()
})
describe('get', () => {
it('应返回存储的 JSON 对象', () => {
const mockData = JSON.stringify({ name: 'test' })
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue(mockData)
const result = storage.get<{ name: string }>('key')
expect(result).toEqual({ name: 'test' })
expect(mockUni.getStorageSync).toHaveBeenCalledWith('test_key')
})
it('应返回存储的字符串值', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('"hello"')
const result = storage.get<string>('strKey')
expect(result).toBe('hello')
})
it('应返回存储的数字值', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('42')
const result = storage.get<number>('numKey')
expect(result).toBe(42)
})
it('应在键不存在时返回 null(空字符串)', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('')
const result = storage.get('nonexistent')
expect(result).toBeNull()
})
it('应在解析异常时返回 null', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('invalid-json')
const result = storage.get<{ broken: boolean }>('bad')
expect(result).toBeNull()
})
})
describe('set', () => {
it('应将对象序列化为 JSON 存储', () => {
storage.set('key', { value: 42 })
expect(mockUni.setStorageSync).toHaveBeenCalledWith(
'test_key',
JSON.stringify({ value: 42 }),
)
})
it('应正确存储字符串', () => {
storage.set('strKey', 'hello')
expect(mockUni.setStorageSync).toHaveBeenCalledWith('test_strKey', '"hello"')
})
it('应正确存储数字', () => {
storage.set('numKey', 123)
expect(mockUni.setStorageSync).toHaveBeenCalledWith('test_numKey', '123')
})
})
describe('remove', () => {
it('应删除指定键', () => {
storage.remove('key')
expect(mockUni.removeStorageSync).toHaveBeenCalledWith('test_key')
})
})
describe('clear', () => {
it('应清除所有存储', () => {
storage.clear()
expect(mockUni.clearStorageSync).toHaveBeenCalled()
})
})
describe('前缀隔离', () => {
it('不同前缀的实例应使用不同的键名', () => {
const storageA = new StorageService('a_')
const storageB = new StorageService('b_')
storageA.set('sameKey', 'valueA')
storageB.set('sameKey', 'valueB')
expect(mockUni.setStorageSync).toHaveBeenCalledWith('a_sameKey', '"valueA"')
expect(mockUni.setStorageSync).toHaveBeenCalledWith('b_sameKey', '"valueB"')
})
})
describe('默认前缀', () => {
it('应使用 eis_ 作为默认前缀', () => {
const defaultStorage = new StorageService()
defaultStorage.set('key', 'val')
expect(mockUni.setStorageSync).toHaveBeenCalledWith('eis_key', '"val"')
})
})
describe('has', () => {
it('键存在时应返回 true', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('{}')
expect(storage.has('existing')).toBe(true)
})
it('键不存在时应返回 false', () => {
;(mockUni.getStorageSync as ReturnType<typeof vi.fn>).mockReturnValue('')
expect(storage.has('missing')).toBe(false)
})
})
})
@@ -8,6 +8,7 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.{test,spec}.{js,ts}'],
setupFiles: ['./vitest.setup.ts'],
coverage: {
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts}'],
@@ -22,7 +23,10 @@ export default defineConfig({
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
'@': path.resolve(__dirname, './src'),
'@dcloudio/uni-app': path.resolve(__dirname, './src/__mocks__/dcloudio/uni-app.ts'),
'@dcloudio/uni-components': path.resolve(__dirname, './src/__mocks__/dcloudio/uni-components.ts'),
'@dcloudio/uni-h5': path.resolve(__dirname, './src/__mocks__/dcloudio/uni-h5.ts'),
}
}
})
@@ -0,0 +1,38 @@
import { vi } from 'vitest'
const uni = {
getStorageSync: vi.fn((key: string) => {
const store: Record<string, string> = (globalThis as any).__uniStorage__ ?? {}
return store[key] ?? ''
}),
setStorageSync: vi.fn((key: string, value: any) => {
if (!(globalThis as any).__uniStorage__) (globalThis as any).__uniStorage__ = {}
;(globalThis as any).__uniStorage__[key] = JSON.stringify(value)
}),
removeStorageSync: vi.fn((key: string) => {
if ((globalThis as any).__uniStorage__) delete (globalThis as any).__uniStorage__[key]
}),
clearStorageSync: vi.fn(() => {
;(globalThis as any).__uniStorage__ = {}
}),
showToast: vi.fn(),
showLoading: vi.fn(),
hideLoading: vi.fn(),
showModal: vi.fn(),
navigateTo: vi.fn(),
navigateBack: vi.fn(),
redirectTo: vi.fn(),
switchTab: vi.fn(),
reLaunch: vi.fn(),
getSystemInfoSync: vi.fn(() => ({
platform: 'devtools',
screenWidth: 375,
screenHeight: 812,
})),
}
vi.stubGlobal('uni', uni)
beforeEach(() => {
;(globalThis as any).__uniStorage__ = {}
})