diff --git a/.gitignore b/.gitignore index 8e3ebf1..27784f6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 74b94aa..b41966b 100644 --- a/README.md +++ b/README.md @@ -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 - -## 联系方式 - -如有问题或建议,请联系开发团队。 diff --git a/dogfood-output/report.md b/dogfood-output/report.md deleted file mode 100644 index 8eaf1da..0000000 --- a/dogfood-output/report.md +++ /dev/null @@ -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 - diff --git a/dogfood-output/screenshots/initial.png b/dogfood-output/screenshots/initial.png deleted file mode 100644 index da25381..0000000 Binary files a/dogfood-output/screenshots/initial.png and /dev/null differ diff --git a/everything-is-suitable-uniapp/.github/workflows/e2e-test.yml b/everything-is-suitable-uniapp/.github/workflows/e2e-test.yml deleted file mode 100644 index b4fdc0d..0000000 --- a/everything-is-suitable-uniapp/.github/workflows/e2e-test.yml +++ /dev/null @@ -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 diff --git a/everything-is-suitable-uniapp/.github/workflows/miniprogram-test.yml b/everything-is-suitable-uniapp/.github/workflows/miniprogram-test.yml deleted file mode 100644 index 1b322a4..0000000 --- a/everything-is-suitable-uniapp/.github/workflows/miniprogram-test.yml +++ /dev/null @@ -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 diff --git a/everything-is-suitable-uniapp/.github/workflows/mobile-test.yml b/everything-is-suitable-uniapp/.github/workflows/mobile-test.yml deleted file mode 100644 index 3140b2e..0000000 --- a/everything-is-suitable-uniapp/.github/workflows/mobile-test.yml +++ /dev/null @@ -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 diff --git a/everything-is-suitable-uniapp/.github/workflows/performance-test.yml b/everything-is-suitable-uniapp/.github/workflows/performance-test.yml deleted file mode 100644 index fcb5843..0000000 --- a/everything-is-suitable-uniapp/.github/workflows/performance-test.yml +++ /dev/null @@ -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: 实现性能对比逻辑" diff --git a/everything-is-suitable-uniapp/.github/workflows/unit-test.yml b/everything-is-suitable-uniapp/.github/workflows/unit-test.yml deleted file mode 100644 index c9126fe..0000000 --- a/everything-is-suitable-uniapp/.github/workflows/unit-test.yml +++ /dev/null @@ -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 diff --git a/everything-is-suitable-uniapp/Jenkinsfile b/everything-is-suitable-uniapp/Jenkinsfile index 3518b9a..458986b 100644 --- a/everything-is-suitable-uniapp/Jenkinsfile +++ b/everything-is-suitable-uniapp/Jenkinsfile @@ -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 { diff --git a/everything-is-suitable-uniapp/nginx.conf b/everything-is-suitable-uniapp/nginx.conf index e1274dc..dd4e9f2 100644 --- a/everything-is-suitable-uniapp/nginx.conf +++ b/everything-is-suitable-uniapp/nginx.conf @@ -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"; diff --git a/everything-is-suitable-uniapp/package-lock.json b/everything-is-suitable-uniapp/package-lock.json index b259631..2c03a02 100644 --- a/everything-is-suitable-uniapp/package-lock.json +++ b/everything-is-suitable-uniapp/package-lock.json @@ -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", diff --git a/everything-is-suitable-uniapp/package.json b/everything-is-suitable-uniapp/package.json index 0a2fb16..5c0abea 100644 --- a/everything-is-suitable-uniapp/package.json +++ b/everything-is-suitable-uniapp/package.json @@ -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", diff --git a/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-app.ts b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-app.ts new file mode 100644 index 0000000..28af77e --- /dev/null +++ b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-app.ts @@ -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() diff --git a/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-components.ts b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-components.ts new file mode 100644 index 0000000..b1c6ea4 --- /dev/null +++ b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-components.ts @@ -0,0 +1 @@ +export default {} diff --git a/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-h5.ts b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-h5.ts new file mode 100644 index 0000000..b1c6ea4 --- /dev/null +++ b/everything-is-suitable-uniapp/src/__mocks__/dcloudio/uni-h5.ts @@ -0,0 +1 @@ +export default {} diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/almanac.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/almanac.test.ts new file mode 100644 index 0000000..6dee534 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/almanac.test.ts @@ -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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/calendar.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/calendar.test.ts new file mode 100644 index 0000000..5a6dea8 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/calendar.test.ts @@ -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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/almanac-cross-validation.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/almanac-cross-validation.test.ts new file mode 100644 index 0000000..0bb08ba --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/almanac-cross-validation.test.ts @@ -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 = { + '子': '午', '丑': '未', '寅': '申', '卯': '酉', + '辰': '戌', '巳': '亥', '午': '子', '未': '丑', + '申': '寅', '酉': '卯', '戌': '辰', '亥': '巳', + } + + 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 = {} + 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() + const allUnsuitable = new Set() + 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() + }) + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/ziwei-cross-validation.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/ziwei-cross-validation.test.ts new file mode 100644 index 0000000..4691dc1 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/cross-validation/ziwei-cross-validation.test.ts @@ -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) + }) + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/enums.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/enums.test.ts new file mode 100644 index 0000000..4136baf --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/enums.test.ts @@ -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('吉星') + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/fortune.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/fortune.test.ts new file mode 100644 index 0000000..2363e65 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/fortune.test.ts @@ -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() + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/fortuneStrategy.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/fortuneStrategy.test.ts new file mode 100644 index 0000000..1d9eef3 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/fortuneStrategy.test.ts @@ -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 = { + 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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/sanFangSiZheng.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/sanFangSiZheng.test.ts new file mode 100644 index 0000000..88fff5b --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/sanFangSiZheng.test.ts @@ -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('四正平均分') + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/types.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/types.test.ts new file mode 100644 index 0000000..3c95606 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/types.test.ts @@ -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('吉格') + }) +}) diff --git a/everything-is-suitable-uniapp/src/algorithms/__tests__/ziweiAlgorithm.test.ts b/everything-is-suitable-uniapp/src/algorithms/__tests__/ziweiAlgorithm.test.ts new file mode 100644 index 0000000..2a75a59 --- /dev/null +++ b/everything-is-suitable-uniapp/src/algorithms/__tests__/ziweiAlgorithm.test.ts @@ -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('凶') + }) +}) diff --git a/everything-is-suitable-uniapp/src/pages/fortune/__tests__/index.test.ts b/everything-is-suitable-uniapp/src/pages/fortune/__tests__/index.test.ts new file mode 100644 index 0000000..71afe10 --- /dev/null +++ b/everything-is-suitable-uniapp/src/pages/fortune/__tests__/index.test.ts @@ -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') + }) +}) diff --git a/everything-is-suitable-uniapp/src/pages/ziwei/__tests__/index.test.ts b/everything-is-suitable-uniapp/src/pages/ziwei/__tests__/index.test.ts new file mode 100644 index 0000000..7f45290 --- /dev/null +++ b/everything-is-suitable-uniapp/src/pages/ziwei/__tests__/index.test.ts @@ -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() + }) +}) diff --git a/everything-is-suitable-uniapp/src/services/__tests__/almanacService.test.ts b/everything-is-suitable-uniapp/src/services/__tests__/almanacService.test.ts new file mode 100644 index 0000000..cb480f8 --- /dev/null +++ b/everything-is-suitable-uniapp/src/services/__tests__/almanacService.test.ts @@ -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() + }) +}) diff --git a/everything-is-suitable-uniapp/src/services/__tests__/fortuneService.test.ts b/everything-is-suitable-uniapp/src/services/__tests__/fortuneService.test.ts new file mode 100644 index 0000000..7b923d4 --- /dev/null +++ b/everything-is-suitable-uniapp/src/services/__tests__/fortuneService.test.ts @@ -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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/services/__tests__/searchService.test.ts b/everything-is-suitable-uniapp/src/services/__tests__/searchService.test.ts new file mode 100644 index 0000000..7e829ae --- /dev/null +++ b/everything-is-suitable-uniapp/src/services/__tests__/searchService.test.ts @@ -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 = {} + +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 { + 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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/services/__tests__/templateService.test.ts b/everything-is-suitable-uniapp/src/services/__tests__/templateService.test.ts new file mode 100644 index 0000000..ca360ac --- /dev/null +++ b/everything-is-suitable-uniapp/src/services/__tests__/templateService.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { TemplateService } from '../templateService' +import type { ReportTemplate } from '../templateService' + +const mockStorage: Record = {} + +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() + }) +}) diff --git a/everything-is-suitable-uniapp/src/services/__tests__/ziweiService.test.ts b/everything-is-suitable-uniapp/src/services/__tests__/ziweiService.test.ts new file mode 100644 index 0000000..08220bf --- /dev/null +++ b/everything-is-suitable-uniapp/src/services/__tests__/ziweiService.test.ts @@ -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) + } + }) +}) diff --git a/everything-is-suitable-uniapp/src/utils/__tests__/exportImport.test.ts b/everything-is-suitable-uniapp/src/utils/__tests__/exportImport.test.ts new file mode 100644 index 0000000..d35665e --- /dev/null +++ b/everything-is-suitable-uniapp/src/utils/__tests__/exportImport.test.ts @@ -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) + }) +}) diff --git a/everything-is-suitable-uniapp/src/utils/__tests__/storage.test.ts b/everything-is-suitable-uniapp/src/utils/__tests__/storage.test.ts new file mode 100644 index 0000000..69889dc --- /dev/null +++ b/everything-is-suitable-uniapp/src/utils/__tests__/storage.test.ts @@ -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).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).mockReturnValue('"hello"') + + const result = storage.get('strKey') + + expect(result).toBe('hello') + }) + + it('应返回存储的数字值', () => { + ;(mockUni.getStorageSync as ReturnType).mockReturnValue('42') + + const result = storage.get('numKey') + + expect(result).toBe(42) + }) + + it('应在键不存在时返回 null(空字符串)', () => { + ;(mockUni.getStorageSync as ReturnType).mockReturnValue('') + + const result = storage.get('nonexistent') + + expect(result).toBeNull() + }) + + it('应在解析异常时返回 null', () => { + ;(mockUni.getStorageSync as ReturnType).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).mockReturnValue('{}') + + expect(storage.has('existing')).toBe(true) + }) + + it('键不存在时应返回 false', () => { + ;(mockUni.getStorageSync as ReturnType).mockReturnValue('') + + expect(storage.has('missing')).toBe(false) + }) + }) +}) diff --git a/everything-is-suitable-uniapp/vitest.config.ts b/everything-is-suitable-uniapp/vitest.config.ts index eff470b..6e0ba39 100644 --- a/everything-is-suitable-uniapp/vitest.config.ts +++ b/everything-is-suitable-uniapp/vitest.config.ts @@ -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'), } } }) diff --git a/everything-is-suitable-uniapp/vitest.setup.ts b/everything-is-suitable-uniapp/vitest.setup.ts new file mode 100644 index 0000000..53a49b1 --- /dev/null +++ b/everything-is-suitable-uniapp/vitest.setup.ts @@ -0,0 +1,38 @@ +import { vi } from 'vitest' + +const uni = { + getStorageSync: vi.fn((key: string) => { + const store: Record = (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__ = {} +})