feat: 完善 TDD 开发环境配置
- 配置 Jest 测试框架,支持 TypeScript 和 React 组件测试 - 添加 Testing Library 相关依赖用于 React 组件测试 - 配置 Babel 预设支持 Jest 和 React 测试环境 - 添加 TDD 开发工作流脚本 (test, test:watch, test:coverage) - 创建完整的 TDD 编码规范文档 (CLAUDE.md) - 添加自定义 hooks 和多平台支持目录结构 - 配置 TypeScript 严格模式和 ESLint 规范 - 添加全局类型定义文件支持
This commit is contained in:
368
CLAUDE.md
Normal file
368
CLAUDE.md
Normal file
@@ -0,0 +1,368 @@
|
||||
本文档展示如何在 Claude 环境中严格遵循 **测试驱动开发 (TDD)** 规范。
|
||||
|
||||
## 必须使用简体中文(严格遵守)
|
||||
## 界面(pages/components等)不写测试
|
||||
## 依赖网络请求、文件请求等外部环境不写测试(mock替代)
|
||||
|
||||
## 🎯 TDD 核心原则 (严格遵守)
|
||||
|
||||
### 🔴 红阶段:编写失败测试
|
||||
```typescript
|
||||
// ❌ 必须先写失败的测试
|
||||
describe('Calculator', () => {
|
||||
it('should add two numbers', () => {
|
||||
const calc = new Calculator(); // 这里会失败 - 正确的!
|
||||
expect(calc.add(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 🟢 绿阶段:最小实现
|
||||
```typescript
|
||||
// ✅ 只写刚好让测试通过的代码
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔄 重构阶段:优化代码
|
||||
```typescript
|
||||
// ✅ 在绿灯状态下安全重构
|
||||
export class Calculator {
|
||||
/**
|
||||
* 两数相加
|
||||
* @param a 第一个数
|
||||
* @param b 第二个数
|
||||
* @returns 和
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚫 TDD 强制规则 (绝不违反)
|
||||
|
||||
### ❌ 禁止行为
|
||||
- **绝对不允许**在没有测试的情况下编写生产代码
|
||||
- **绝对不允许**跳过红阶段直接写实现
|
||||
- **绝对不允许**在红灯状态下进行重构
|
||||
- **绝对不允许**同时处理多个失败测试
|
||||
- **绝对不允许**写example示例代码
|
||||
- **绝对不允许**业务中写测试代码
|
||||
- **绝对不允许**业务中写简化或mock代码
|
||||
|
||||
### ✅ 必须行为
|
||||
- **必须切换新分支** 切换新的分支后开始新功能开发
|
||||
- **必须确认**测试在功能实现前是失败的
|
||||
- **必须编写**刚好让测试通过的最小代码
|
||||
- **必须在**所有测试通过的绿灯状态下进行重构
|
||||
- **必须保持**每个测试的独立性
|
||||
- **必须遵守**单个文件代码行数不超过500行
|
||||
- **必须遵守**环境依赖性测试(文件系统监控、网络连接、性能测试),使用模拟对象(Mock)替代真实环境依赖,确保测试的稳定性
|
||||
|
||||
## 🛠️ Claude TDD 工作流程
|
||||
|
||||
### Step 1: 需求分析 → 测试设计
|
||||
```bash
|
||||
# 在 Claude 中的对话流程
|
||||
用户: "我需要一个计算器功能"
|
||||
Claude: "让我们从测试开始。首先写一个失败的测试..."
|
||||
```
|
||||
|
||||
### Step 2: 🔴 红阶段执行
|
||||
```typescript
|
||||
// 1. 先创建测试文件
|
||||
// src/calculator.test.ts
|
||||
import { Calculator } from './calculator'; // ❌ 这会失败
|
||||
|
||||
describe('Calculator', () => {
|
||||
it('should add two positive numbers', () => {
|
||||
const calculator = new Calculator();
|
||||
expect(calculator.add(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. 运行测试确认失败
|
||||
npm run test
|
||||
# ❌ Cannot find module './calculator' - 正确的失败!
|
||||
```
|
||||
|
||||
### Step 3: 🟢 绿阶段执行
|
||||
```typescript
|
||||
// 3. 创建最小实现
|
||||
// src/calculator.ts
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. 运行测试确认通过
|
||||
npm run test
|
||||
# ✅ Tests: 1 passed, 1 total
|
||||
```
|
||||
|
||||
### Step 4: 🔄 重构阶段执行
|
||||
```typescript
|
||||
// 5. 在绿灯状态下改进代码质量
|
||||
export class Calculator {
|
||||
/**
|
||||
* 添加两个数字
|
||||
* @param a 第一个数字
|
||||
* @param b 第二个数字
|
||||
* @returns 两数之和
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
// 可以添加输入验证等
|
||||
if (typeof a !== 'number' || typeof b !== 'number') {
|
||||
throw new Error('Both arguments must be numbers');
|
||||
}
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 6. 确保重构后测试仍然通过
|
||||
npm run test
|
||||
# ✅ Tests: 1 passed, 1 total
|
||||
```
|
||||
|
||||
## 📊 Claude TDD 质量检查清单
|
||||
|
||||
### 🔴 红阶段检查清单
|
||||
- [ ] 测试用例清晰表达了期望的行为
|
||||
- [ ] 测试失败的原因是功能未实现,而非测试错误
|
||||
- [ ] 测试范围适中,只验证一个具体功能点
|
||||
- [ ] 测试用例名称具有良好的可读性
|
||||
|
||||
### 🟢 绿阶段检查清单
|
||||
- [ ] 所有测试都通过
|
||||
- [ ] 实现代码尽可能简单
|
||||
- [ ] 没有实现当前测试不需要的功能
|
||||
- [ ] 代码能够正确处理测试用例的所有场景
|
||||
|
||||
### 🔄 重构阶段检查清单
|
||||
- [ ] 消除了代码重复
|
||||
- [ ] 提高了代码可读性
|
||||
- [ ] 改善了代码结构
|
||||
- [ ] 所有测试仍然通过
|
||||
- [ ] 没有改变外部行为
|
||||
|
||||
## 🎓 Claude TDD 最佳实践
|
||||
|
||||
### 1. 对话驱动的 TDD
|
||||
```
|
||||
用户: "添加减法功能"
|
||||
Claude: "让我们遵循 TDD 流程:
|
||||
1. 🔴 先写减法的失败测试
|
||||
2. 🟢 实现最小的减法功能
|
||||
3. 🔄 重构优化代码"
|
||||
```
|
||||
|
||||
### 2. 增量式开发
|
||||
```typescript
|
||||
// 第一个测试:基本功能
|
||||
it('should subtract two positive numbers', () => {
|
||||
expect(calc.subtract(5, 3)).toBe(2);
|
||||
});
|
||||
|
||||
// 第二个测试:边界情况
|
||||
it('should handle negative results', () => {
|
||||
expect(calc.subtract(3, 5)).toBe(-2);
|
||||
});
|
||||
|
||||
// 第三个测试:零值处理
|
||||
it('should handle zero values', () => {
|
||||
expect(calc.subtract(5, 0)).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 测试即文档
|
||||
```typescript
|
||||
describe('Calculator', () => {
|
||||
describe('add method', () => {
|
||||
it('should add two positive integers correctly', () => {
|
||||
// 测试名称就是功能文档
|
||||
});
|
||||
|
||||
it('should handle floating point numbers with precision', () => {
|
||||
// 清晰表达预期行为
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 Claude TDD 命令速查
|
||||
|
||||
### 快速启动 TDD 循环
|
||||
```bash
|
||||
# 1. 创建测试文件
|
||||
touch src/feature.test.ts
|
||||
|
||||
# 2. 运行测试 (红阶段)
|
||||
npm run test:watch
|
||||
|
||||
# 3. 创建实现文件
|
||||
touch src/feature.ts
|
||||
|
||||
# 4. 运行构建 (绿阶段后)
|
||||
npm run build
|
||||
|
||||
# 5. 查看覆盖率
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### TDD 开发节奏
|
||||
```bash
|
||||
# 保持快速的红-绿-重构循环
|
||||
🔴 写测试 → ❌ 运行测试 → 🟢 写代码 → ✅ 运行测试 → 🔄 重构 → ✅ 运行测试
|
||||
```
|
||||
|
||||
## 📈 成功的 TDD 指标
|
||||
|
||||
### 代码质量指标
|
||||
- ✅ **测试覆盖率**: 95%+ (强制要求)
|
||||
- ✅ **测试执行时间**: < 2 秒 (快速反馈)
|
||||
- ✅ **测试独立性**: 100% (无依赖测试)
|
||||
- ✅ **代码重复度**: 最小化
|
||||
|
||||
### TDD 流程指标
|
||||
- ✅ **红-绿-重构循环**: 严格遵循
|
||||
- ✅ **测试先行率**: 100% (无例外)
|
||||
- ✅ **最小实现原则**: 始终遵守
|
||||
- ✅ **重构安全性**: 测试保护下进行
|
||||
|
||||
## 🎯 下一步 TDD 开发
|
||||
|
||||
继续使用 Claude 进行 TDD 开发时:
|
||||
|
||||
1. **🔴 红阶段**: 告诉 Claude 你需要什么功能,让它先写失败测试
|
||||
2. **🟢 绿阶段**: 让 Claude 实现最小代码使测试通过
|
||||
3. **🔄 重构阶段**: 与 Claude 一起改进代码质量
|
||||
4. **重复循环**: 为每个新功能重复此过程
|
||||
|
||||
**记住:在 Claude 环境中,TDD 不仅是开发方法,更是确保代码质量和设计优雅的严格纪律!** 🎉
|
||||
|
||||
## 💡 Claude TDD 实战示例
|
||||
|
||||
### 示例:添加乘法功能
|
||||
|
||||
#### 🔴 红阶段:用户请求
|
||||
```
|
||||
用户: "我需要添加乘法功能"
|
||||
Claude: "好的,让我们严格遵循 TDD 流程。首先写一个失败的测试:"
|
||||
```
|
||||
|
||||
#### 测试代码 (先写)
|
||||
```typescript
|
||||
// src/calculator.test.ts
|
||||
describe('Calculator', () => {
|
||||
// ... 现有测试 ...
|
||||
|
||||
describe('multiply', () => {
|
||||
it('should multiply two positive numbers correctly', () => {
|
||||
const calculator = new Calculator();
|
||||
expect(calculator.multiply(3, 4)).toBe(12); // ❌ 这会失败
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 🟢 绿阶段:最小实现
|
||||
```typescript
|
||||
// src/calculator.ts
|
||||
export class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b; // 最简实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 🔄 重构阶段:优化代码
|
||||
```typescript
|
||||
export class Calculator {
|
||||
/**
|
||||
* 两数相加
|
||||
*/
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
/**
|
||||
* 两数相乘
|
||||
* @param a 被乘数
|
||||
* @param b 乘数
|
||||
* @returns 乘积
|
||||
*/
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Claude TDD 故障排除
|
||||
|
||||
### 常见问题与解决方案
|
||||
|
||||
#### 问题 1: 测试没有失败
|
||||
```typescript
|
||||
// ❌ 错误:测试一开始就通过了
|
||||
it('should return true', () => {
|
||||
expect(true).toBe(true); // 这不是有效的 TDD 测试
|
||||
});
|
||||
|
||||
// ✅ 正确:测试应该验证具体功能
|
||||
it('should validate email format', () => {
|
||||
const validator = new EmailValidator();
|
||||
expect(validator.isValid('test@example.com')).toBe(true); // 会失败,因为类不存在
|
||||
});
|
||||
```
|
||||
|
||||
#### 问题 2: 实现过度复杂
|
||||
```typescript
|
||||
// ❌ 错误:过度实现
|
||||
multiply(a: number, b: number): number {
|
||||
// 不需要的复杂逻辑
|
||||
if (a === 0 || b === 0) return 0;
|
||||
if (a === 1) return b;
|
||||
if (b === 1) return a;
|
||||
// ... 更多不必要的逻辑
|
||||
return a * b;
|
||||
}
|
||||
|
||||
// ✅ 正确:最小实现
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b; // 简单直接
|
||||
}
|
||||
```
|
||||
|
||||
#### 问题 3: 在红灯状态下重构
|
||||
```bash
|
||||
# ❌ 错误流程
|
||||
npm run test # ❌ 测试失败
|
||||
# 开始重构代码 <- 错误!应该先让测试通过
|
||||
|
||||
# ✅ 正确流程
|
||||
npm run test # ❌ 测试失败
|
||||
# 写最小代码让测试通过
|
||||
npm run test # ✅ 测试通过
|
||||
# 现在可以安全重构
|
||||
```
|
||||
|
||||
### 关键文件说明
|
||||
- `jest.config.js` - 测试框架配置
|
||||
- `tsconfig.json` - TypeScript 严格模式配置
|
||||
- `package.json` - TDD 相关脚本命令
|
||||
- `src/*.test.ts` - 测试文件 (TDD 的核心)
|
||||
@@ -4,7 +4,7 @@ import devConfig from './dev'
|
||||
import prodConfig from './prod'
|
||||
|
||||
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
|
||||
export default defineConfig<'vite'>(async (merge, { command, mode }) => {
|
||||
export default defineConfig<'vite'>(async (merge) => {
|
||||
const baseConfig: UserConfigExport<'vite'> = {
|
||||
projectName: 'bw-mini-app',
|
||||
date: '2025-9-1',
|
||||
|
||||
18
global.d.ts
vendored
Normal file
18
global.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/// <reference path="node_modules/@tarojs/plugin-platform-tt/types/shims-tt.d.ts" />
|
||||
|
||||
declare module '*.png';
|
||||
declare module '*.gif';
|
||||
declare module '*.jpg';
|
||||
declare module '*.jpeg';
|
||||
declare module '*.svg';
|
||||
declare module '*.css';
|
||||
declare module '*.less';
|
||||
declare module '*.scss';
|
||||
declare module '*.sass';
|
||||
declare module '*.styl';
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
|
||||
}
|
||||
}
|
||||
18
jest.config.js
Normal file
18
jest.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const defineJestConfig = require('@tarojs/test-utils-react/dist/jest.js').default
|
||||
|
||||
module.exports = defineJestConfig({
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.{ts,tsx}',
|
||||
'**/*.(test|spec).{ts,tsx}'
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setupTests.ts'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@tarojs|swiper|@babel|@testing-library)/)'
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
|
||||
},
|
||||
})
|
||||
67
package.json
67
package.json
@@ -13,6 +13,11 @@
|
||||
"claude": "claude --dangerously-skip-permissions",
|
||||
"prepare": "husky",
|
||||
"new": "taro new",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx --fix",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"build:swan": "taro build --type swan",
|
||||
"build:alipay": "taro build --type alipay",
|
||||
@@ -48,47 +53,57 @@
|
||||
"@babel/runtime": "^7.24.4",
|
||||
"@tarojs/components": "4.1.6",
|
||||
"@tarojs/helper": "4.1.6",
|
||||
"@tarojs/plugin-platform-weapp": "4.1.6",
|
||||
"@tarojs/plugin-framework-react": "4.1.6",
|
||||
"@tarojs/plugin-platform-alipay": "4.1.6",
|
||||
"@tarojs/plugin-platform-tt": "4.1.6",
|
||||
"@tarojs/plugin-platform-swan": "4.1.6",
|
||||
"@tarojs/plugin-platform-jd": "4.1.6",
|
||||
"@tarojs/plugin-platform-qq": "4.1.6",
|
||||
"@tarojs/plugin-platform-h5": "4.1.6",
|
||||
"@tarojs/plugin-platform-harmony-hybrid": "4.1.6",
|
||||
"@tarojs/plugin-platform-jd": "4.1.6",
|
||||
"@tarojs/plugin-platform-qq": "4.1.6",
|
||||
"@tarojs/plugin-platform-swan": "4.1.6",
|
||||
"@tarojs/plugin-platform-tt": "4.1.6",
|
||||
"@tarojs/plugin-platform-weapp": "4.1.6",
|
||||
"@tarojs/react": "4.1.6",
|
||||
"@tarojs/runtime": "4.1.6",
|
||||
"@tarojs/shared": "4.1.6",
|
||||
"@tarojs/taro": "4.1.6",
|
||||
"@tarojs/plugin-framework-react": "4.1.6",
|
||||
"@tarojs/react": "4.1.6",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^18.0.0"
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tarojs/plugin-generator": "4.1.6",
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"lint-staged": "^16.1.2",
|
||||
"husky": "^9.1.7",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/plugin-transform-class-properties": "7.25.9",
|
||||
"@tarojs/cli": "4.1.6",
|
||||
"@tarojs/vite-runner": "4.1.6",
|
||||
"babel-preset-taro": "4.1.6",
|
||||
"eslint-config-taro": "4.1.6",
|
||||
"eslint": "^8.57.0",
|
||||
"stylelint": "^16.4.0",
|
||||
"terser": "^5.30.4",
|
||||
"vite": "^4.2.0",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@tarojs/cli": "4.1.6",
|
||||
"@tarojs/plugin-generator": "4.1.6",
|
||||
"@tarojs/test-utils-react": "^0.1.1",
|
||||
"@tarojs/vite-runner": "4.1.6",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/minimatch": "^5",
|
||||
"@types/react": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"babel-jest": "^30.1.2",
|
||||
"babel-preset-taro": "4.1.6",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-taro": "4.1.6",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"react-refresh": "^0.14.0",
|
||||
"typescript": "^5.4.5",
|
||||
"husky": "^9.1.7",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.1.2",
|
||||
"jest-environment-jsdom": "^30.1.2",
|
||||
"lint-staged": "^16.1.2",
|
||||
"postcss": "^8.4.38",
|
||||
"@types/minimatch": "^5"
|
||||
"react-refresh": "^0.14.0",
|
||||
"stylelint": "^16.4.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"terser": "^5.30.4",
|
||||
"ts-jest": "^29.4.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
2600
pnpm-lock.yaml
generated
2600
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1 @@
|
||||
{
|
||||
"miniprogramRoot": "./dist",
|
||||
"projectname": "bw-mini-app",
|
||||
"description": "图生图 风格转换 ",
|
||||
"appid": "touristappid",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": false,
|
||||
"postcss": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
}
|
||||
{"miniprogramRoot":"./dist","projectname":"bw-mini-app","description":"图生图 风格转换 ","appid":"ttbfd9c96420ec8f8201","setting":{"urlCheck":true,"es6":true,"enhance":false,"compileHotReLoad":false,"postcss":false,"minified":false},"compileType":"miniprogram"}
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
#app{
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
33
src/hooks/__tests__/useAd.test.ts
Normal file
33
src/hooks/__tests__/useAd.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAd } from '../useAd';
|
||||
|
||||
// Mock Taro
|
||||
jest.mock('@tarojs/taro', () => ({
|
||||
createRewardedVideoAd: jest.fn(() => ({
|
||||
onLoad: jest.fn(),
|
||||
onClose: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
show: jest.fn(),
|
||||
load: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useAd', () => {
|
||||
it('should provide showAd function', () => {
|
||||
const { result } = renderHook(() => useAd());
|
||||
expect(result.current.showAd).toBeDefined();
|
||||
expect(typeof result.current.showAd).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide loadAd function', () => {
|
||||
const { result } = renderHook(() => useAd());
|
||||
expect(result.current.loadAd).toBeDefined();
|
||||
expect(typeof result.current.loadAd).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide loading state', () => {
|
||||
const { result } = renderHook(() => useAd());
|
||||
expect(result.current.loading).toBeDefined();
|
||||
expect(typeof result.current.loading).toBe('boolean');
|
||||
});
|
||||
});
|
||||
64
src/hooks/useAd.ts
Normal file
64
src/hooks/useAd.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createPlatformFactory, RewardedVideoAd, RewardedVideoCloseCb, RewardedVideoErrorCb } from "../platforms";
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface UseAdReturn {
|
||||
showAd: () => void;
|
||||
loadAd: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useAd(): UseAdReturn {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const adRef = useRef<RewardedVideoAd | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const factory = createPlatformFactory()
|
||||
adRef.current = factory.createRewardedVideoAd({
|
||||
adUnitId: 'gncb4kr2b6kwp0uacr'
|
||||
});
|
||||
const ad = adRef.current!;
|
||||
const onClose: RewardedVideoCloseCb = (res) => {
|
||||
console.log('广告关闭:', res);
|
||||
setLoading(false);
|
||||
}
|
||||
const onError: RewardedVideoErrorCb = (res) => {
|
||||
console.error('广告错误:', res);
|
||||
setLoading(false);
|
||||
}
|
||||
const onLoad = () => {
|
||||
console.log(`广告加载`)
|
||||
setLoading(false);
|
||||
}
|
||||
ad.onLoad(onLoad);
|
||||
ad.onClose(onClose);
|
||||
ad.onError(onError);
|
||||
return () => {
|
||||
if (adRef.current) {
|
||||
adRef.current.offClose(onClose)
|
||||
adRef.current.offError(onError)
|
||||
adRef.current.offLoad(onLoad)
|
||||
adRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showAd = useCallback(() => {
|
||||
if (adRef.current) {
|
||||
setLoading(true);
|
||||
adRef.current.show();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAd = useCallback(() => {
|
||||
if (adRef.current) {
|
||||
setLoading(true);
|
||||
adRef.current.load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showAd,
|
||||
loadAd,
|
||||
loading
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.index{
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import './index.css'
|
||||
import { useAd } from '../../hooks/useAd'
|
||||
|
||||
export default function Index () {
|
||||
export default function Index() {
|
||||
|
||||
const { showAd, loadAd } = useAd()
|
||||
useLoad(() => {
|
||||
console.log('Page loaded.')
|
||||
loadAd()
|
||||
return () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<View className='index'>
|
||||
<Text>Hello world!</Text>
|
||||
<Text>Hello world Ymm !!!!</Text>
|
||||
<Button onClick={() => {
|
||||
showAd()
|
||||
}}
|
||||
>广告</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
126
src/platforms/core.ts
Normal file
126
src/platforms/core.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
/**
|
||||
* 激励视频广告关闭回调载荷数据接口
|
||||
*/
|
||||
export interface RewardedVideoCloseCbPayload {
|
||||
/** 视频是否播放完成 */
|
||||
isEnded: boolean;
|
||||
/** 奖励数量 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激励视频广告关闭回调函数接口
|
||||
*/
|
||||
export interface RewardedVideoCloseCb {
|
||||
(data: RewardedVideoCloseCbPayload): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激励视频广告错误回调载荷数据接口
|
||||
*/
|
||||
export interface RewardedVideoErrorCbPayload {
|
||||
/** 错误消息 */
|
||||
errMsg: string;
|
||||
/** 错误代码 */
|
||||
errCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激励视频广告错误回调函数接口
|
||||
*/
|
||||
export interface RewardedVideoErrorCb {
|
||||
(data: RewardedVideoErrorCbPayload): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激励视频广告加载回调函数接口
|
||||
*/
|
||||
export interface RewardedVideoLoadCb {
|
||||
(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激励视频广告配置选项接口
|
||||
*/
|
||||
export interface RewardedVideoAdOptions {
|
||||
/** 广告位 ID */
|
||||
adUnitId: string;
|
||||
}
|
||||
/**
|
||||
* 激励视频广告抽象基类
|
||||
* 定义了跨平台激励视频广告的统一接口
|
||||
*/
|
||||
export abstract class RewardedVideoAd {
|
||||
/**
|
||||
* 手动加载广告素材
|
||||
* 当广告素材加载出现错误时,可以通过此方法手动重新加载
|
||||
* @returns Promise<void> 加载完成的 Promise
|
||||
*/
|
||||
abstract load(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 展示激励视频广告
|
||||
* @returns Promise<void> 展示完成的 Promise
|
||||
*/
|
||||
abstract show(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 销毁激励视频广告实例
|
||||
* 释放相关资源,销毁后需要重新创建实例
|
||||
* @returns Promise<void> 销毁完成的 Promise
|
||||
*/
|
||||
abstract destroy(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 绑定广告关闭事件监听器
|
||||
* 当用户点击广告关闭按钮或广告播放完成时触发
|
||||
* @param cb 关闭事件回调函数
|
||||
*/
|
||||
abstract onClose(cb: RewardedVideoCloseCb): void;
|
||||
|
||||
/**
|
||||
* 解除绑定广告关闭事件监听器
|
||||
* @param cb 需要移除的关闭事件回调函数
|
||||
*/
|
||||
abstract offClose(cb: RewardedVideoCloseCb): void;
|
||||
|
||||
/**
|
||||
* 绑定广告错误事件监听器
|
||||
* 当广告加载或播放出现错误时触发
|
||||
* @param cb 错误事件回调函数
|
||||
*/
|
||||
abstract onError(cb: RewardedVideoErrorCb): void;
|
||||
|
||||
/**
|
||||
* 解除绑定广告错误事件监听器
|
||||
* @param cb 需要移除的错误事件回调函数
|
||||
*/
|
||||
abstract offError(cb: RewardedVideoErrorCb): void;
|
||||
|
||||
/**
|
||||
* 绑定广告加载成功事件监听器
|
||||
* 当广告素材加载完成时触发
|
||||
* @param cb 加载成功事件回调函数
|
||||
*/
|
||||
abstract onLoad(cb: RewardedVideoLoadCb): void;
|
||||
|
||||
/**
|
||||
* 解除绑定广告加载成功事件监听器
|
||||
* @param cb 需要移除的加载成功事件回调函数
|
||||
*/
|
||||
abstract offLoad(cb: RewardedVideoLoadCb): void;
|
||||
|
||||
/**
|
||||
* 获取广告是否准备就绪
|
||||
* @returns boolean 广告是否可以播放
|
||||
*/
|
||||
abstract isReady(): boolean;
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
* 在合适的时机预先加载广告,提高用户体验
|
||||
* @returns Promise<void> 预加载完成的 Promise
|
||||
*/
|
||||
abstract preload(): Promise<void>;
|
||||
}
|
||||
106
src/platforms/factory.ts
Normal file
106
src/platforms/factory.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { RewardedVideoAdTT } from "./tt";
|
||||
import { RewardedVideoAd, RewardedVideoAdOptions } from "./core";
|
||||
|
||||
/**
|
||||
* 小程序平台全局对象类型声明
|
||||
*/
|
||||
declare const tt: any;
|
||||
declare const wx: any;
|
||||
declare const my: any;
|
||||
declare const swan: any;
|
||||
declare const qq: any;
|
||||
|
||||
/**
|
||||
* 支持的平台类型
|
||||
* 目前支持:字节跳动小程序(tt)
|
||||
*/
|
||||
export type Platform = 'tt' | 'weapp' | 'alipay' | 'swan' | 'qq';
|
||||
|
||||
/**
|
||||
* 平台适配器工厂类
|
||||
* 根据不同平台创建对应的广告实例
|
||||
*/
|
||||
export class PlatformFactory {
|
||||
private readonly platform: Platform;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param platform 目标平台类型
|
||||
*/
|
||||
constructor(platform: Platform) {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建激励视频广告实例
|
||||
* @param options 广告配置选项
|
||||
* @returns RewardedVideoAd 广告实例
|
||||
* @throws Error 当平台不支持时抛出错误
|
||||
*/
|
||||
createRewardedVideoAd(options?: RewardedVideoAdOptions): RewardedVideoAd {
|
||||
switch (this.platform) {
|
||||
case 'tt':
|
||||
return new RewardedVideoAdTT(options);
|
||||
case 'weapp':
|
||||
throw new Error(`微信小程序平台暂未实现`);
|
||||
case 'alipay':
|
||||
throw new Error(`支付宝小程序平台暂未实现`);
|
||||
case 'swan':
|
||||
throw new Error(`百度智能小程序平台暂未实现`);
|
||||
case 'qq':
|
||||
throw new Error(`QQ 小程序平台暂未实现`);
|
||||
default:
|
||||
throw new Error(`不支持的平台类型: ${this.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前平台类型
|
||||
* @returns Platform 当前平台
|
||||
*/
|
||||
getPlatform(): Platform {
|
||||
return this.platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查平台是否支持
|
||||
* @param platform 平台类型
|
||||
* @returns boolean 是否支持
|
||||
*/
|
||||
static isSupportedPlatform(platform: string): platform is Platform {
|
||||
return ['tt', 'weapp', 'alipay', 'swan', 'qq'].includes(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测当前运行环境平台
|
||||
* @returns Platform 检测到的平台类型
|
||||
* @throws Error 当无法检测到平台时抛出错误
|
||||
*/
|
||||
static detectPlatform(): Platform {
|
||||
if (typeof tt !== 'undefined') {
|
||||
return 'tt';
|
||||
}
|
||||
if (typeof wx !== 'undefined') {
|
||||
return 'weapp';
|
||||
}
|
||||
if (typeof my !== 'undefined') {
|
||||
return 'alipay';
|
||||
}
|
||||
if (typeof swan !== 'undefined') {
|
||||
return 'swan';
|
||||
}
|
||||
if (typeof qq !== 'undefined') {
|
||||
return 'qq';
|
||||
}
|
||||
throw new Error('无法检测到支持的小程序平台环境');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自动检测平台的工厂实例
|
||||
* @returns PlatformFactory 工厂实例
|
||||
*/
|
||||
static createAutoDetect(): PlatformFactory {
|
||||
const platform = PlatformFactory.detectPlatform();
|
||||
return new PlatformFactory(platform);
|
||||
}
|
||||
}
|
||||
23
src/platforms/index.ts
Normal file
23
src/platforms/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 平台适配器模块导出
|
||||
* 提供跨小程序平台的统一广告接口
|
||||
*/
|
||||
|
||||
import { Platform, PlatformFactory } from './factory';
|
||||
|
||||
export { PlatformFactory, type Platform } from './factory';
|
||||
export { RewardedVideoAdTT } from './tt';
|
||||
|
||||
/**
|
||||
* 便捷创建工厂实例的函数
|
||||
* @param platform 平台类型,如果不指定则自动检测
|
||||
* @returns PlatformFactory 工厂实例
|
||||
*/
|
||||
export function createPlatformFactory(platform?: Platform) {
|
||||
if (platform) {
|
||||
return new PlatformFactory(platform);
|
||||
}
|
||||
return PlatformFactory.createAutoDetect();
|
||||
}
|
||||
|
||||
export * from './core'
|
||||
197
src/platforms/tt.ts
Normal file
197
src/platforms/tt.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
RewardedVideoAd,
|
||||
RewardedVideoCloseCb,
|
||||
RewardedVideoErrorCb,
|
||||
RewardedVideoLoadCb,
|
||||
RewardedVideoAdOptions
|
||||
} from "./core";
|
||||
|
||||
/**
|
||||
* 字节跳动小程序全局对象类型声明
|
||||
*/
|
||||
declare const tt: {
|
||||
createRewardedVideoAd: (options: any) => any;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 字节跳动平台激励视频广告配置接口
|
||||
*/
|
||||
interface TTRewardedVideoAdOptions {
|
||||
/** 广告位 ID */
|
||||
adUnitId: string;
|
||||
/** 是否启用多场景化 */
|
||||
multiton?: boolean;
|
||||
/** 用户 ID */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节跳动平台激励视频广告实现类
|
||||
* 封装字节跳动小程序的激励视频广告 API
|
||||
*/
|
||||
export class RewardedVideoAdTT extends RewardedVideoAd {
|
||||
private readonly ad: any;
|
||||
private readonly options: TTRewardedVideoAdOptions;
|
||||
private _isReady: boolean = false;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param options 广告配置选项
|
||||
*/
|
||||
constructor(options?: RewardedVideoAdOptions) {
|
||||
super();
|
||||
|
||||
// 设置默认配置
|
||||
this.options = {
|
||||
adUnitId: options?.adUnitId || '',
|
||||
};
|
||||
|
||||
if (!this.options.adUnitId) {
|
||||
throw new Error('广告位 ID (adUnitId) 不能为空');
|
||||
}
|
||||
|
||||
// 检查字节跳动环境
|
||||
if (typeof tt === 'undefined') {
|
||||
throw new Error('当前环境不支持字节跳动小程序 API');
|
||||
}
|
||||
|
||||
// 创建激励视频广告实例
|
||||
this.ad = tt.createRewardedVideoAd({
|
||||
adUnitId: this.options.adUnitId,
|
||||
});
|
||||
|
||||
// 监听广告加载成功事件
|
||||
this.ad.onLoad(() => {
|
||||
this._isReady = true;
|
||||
});
|
||||
|
||||
// 监听广告加载错误事件
|
||||
this.ad.onError(() => {
|
||||
this._isReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动加载广告素材
|
||||
* @returns Promise<void> 加载完成的 Promise
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
await this.ad.load();
|
||||
this._isReady = true;
|
||||
} catch (error) {
|
||||
this._isReady = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示激励视频广告
|
||||
* @returns Promise<void> 展示完成的 Promise
|
||||
*/
|
||||
async show(): Promise<void> {
|
||||
if (!this._isReady) {
|
||||
throw new Error('广告尚未加载完成,请先调用 load() 方法或等待广告加载完成');
|
||||
}
|
||||
return this.ad.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁激励视频广告实例
|
||||
* @returns Promise<void> 销毁完成的 Promise
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
this._isReady = false;
|
||||
if (this.ad.destroy) {
|
||||
return this.ad.destroy();
|
||||
}
|
||||
// 兼容性处理:某些版本可能使用 destory (拼写错误)
|
||||
if (this.ad.destory) {
|
||||
return this.ad.destory();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定广告关闭事件监听器
|
||||
* @param cb 关闭事件回调函数
|
||||
*/
|
||||
onClose(cb: RewardedVideoCloseCb): void {
|
||||
this.ad.onClose(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除绑定广告关闭事件监听器
|
||||
* @param cb 需要移除的关闭事件回调函数
|
||||
*/
|
||||
offClose(cb: RewardedVideoCloseCb): void {
|
||||
this.ad.offClose(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定广告错误事件监听器
|
||||
* @param cb 错误事件回调函数
|
||||
*/
|
||||
onError(cb: RewardedVideoErrorCb): void {
|
||||
this.ad.onError(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除绑定广告错误事件监听器
|
||||
* @param cb 需要移除的错误事件回调函数
|
||||
*/
|
||||
offError(cb: RewardedVideoErrorCb): void {
|
||||
this.ad.offError(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定广告加载成功事件监听器
|
||||
* @param cb 加载成功事件回调函数
|
||||
*/
|
||||
onLoad(cb: RewardedVideoLoadCb): void {
|
||||
this.ad.onLoad(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除绑定广告加载成功事件监听器
|
||||
* @param cb 需要移除的加载成功事件回调函数
|
||||
*/
|
||||
offLoad(cb: RewardedVideoLoadCb): void {
|
||||
this.ad.offLoad(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广告是否准备就绪
|
||||
* @returns boolean 广告是否可以播放
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
* 在合适的时机预先加载广告,提高用户体验
|
||||
* @returns Promise<void> 预加载完成的 Promise
|
||||
*/
|
||||
async preload(): Promise<void> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广告配置选项
|
||||
* @returns TTRewardedVideoAdOptions 当前配置
|
||||
*/
|
||||
getOptions(): TTRewardedVideoAdOptions {
|
||||
return { ...this.options };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原生广告实例
|
||||
* 用于访问平台特有的方法和属性
|
||||
* @returns any 原生广告实例
|
||||
*/
|
||||
getNativeAd(): any {
|
||||
return this.ad;
|
||||
}
|
||||
}
|
||||
1
tests/setupTests.ts
Normal file
1
tests/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -6,12 +6,19 @@
|
||||
"preserveConstEnums": true,
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitAny": false,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user