feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils - Add TestServer, TestClient, MockRoom for unit testing - Export testing utilities from @esengine/server/testing ## Transaction Storage (BREAKING) - Simplify RedisStorage/MongoStorage to factory pattern only - Remove DI client injection option - Add lazy connection and Symbol.asyncDispose support - Add 161 unit tests with full coverage ## Pathfinding Tests - Add 150 unit tests covering all components - BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother ## Docs - Update storage.md for new factory pattern API
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @zh CurrencyOperation 单元测试
|
||||
* @en CurrencyOperation unit tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { CurrencyOperation, createCurrencyOperation, type ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
|
||||
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||
import type { ITransactionContext } from '../../src/core/types.js'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Provider | 模拟数据提供者
|
||||
// ============================================================================
|
||||
|
||||
class MockCurrencyProvider implements ICurrencyProvider {
|
||||
private _balances: Map<string, Map<string, number>> = new Map()
|
||||
|
||||
setInitialBalance(playerId: string, currency: string, amount: number): void {
|
||||
if (!this._balances.has(playerId)) {
|
||||
this._balances.set(playerId, new Map())
|
||||
}
|
||||
this._balances.get(playerId)!.set(currency, amount)
|
||||
}
|
||||
|
||||
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||
return this._balances.get(playerId)?.get(currency) ?? 0
|
||||
}
|
||||
|
||||
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||
if (!this._balances.has(playerId)) {
|
||||
this._balances.set(playerId, new Map())
|
||||
}
|
||||
this._balances.get(playerId)!.set(currency, amount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite | 测试套件
|
||||
// ============================================================================
|
||||
|
||||
describe('CurrencyOperation', () => {
|
||||
let storage: MemoryStorage
|
||||
let ctx: ITransactionContext
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage()
|
||||
ctx = new TransactionContext({ storage })
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 构造器测试 | Constructor Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should create with data', () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
expect(op.name).toBe('currency')
|
||||
expect(op.data.type).toBe('add')
|
||||
expect(op.data.playerId).toBe('player-1')
|
||||
expect(op.data.currency).toBe('gold')
|
||||
expect(op.data.amount).toBe(100)
|
||||
})
|
||||
|
||||
it('should use createCurrencyOperation factory', () => {
|
||||
const op = createCurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-2',
|
||||
currency: 'diamond',
|
||||
amount: 50,
|
||||
})
|
||||
|
||||
expect(op).toBeInstanceOf(CurrencyOperation)
|
||||
expect(op.data.type).toBe('deduct')
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 验证测试 | Validation Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('validate()', () => {
|
||||
it('should fail validation with zero amount', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 0,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail validation with negative amount', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: -10,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass validation for add with positive amount', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail validation for deduct with insufficient balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 50)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass validation for deduct with sufficient balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 150)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should use provider for validation', async () => {
|
||||
const provider = new MockCurrencyProvider()
|
||||
provider.setInitialBalance('player-1', 'gold', 200)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 150,
|
||||
}).setProvider(provider)
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 增加货币 | Execute Tests - Add Currency
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - add', () => {
|
||||
it('should add currency to empty balance', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeBalance).toBe(0)
|
||||
expect(result.data?.afterBalance).toBe(100)
|
||||
|
||||
const balance = await storage.get<number>('player:player-1:currency:gold')
|
||||
expect(balance).toBe(100)
|
||||
})
|
||||
|
||||
it('should add currency to existing balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 50)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeBalance).toBe(50)
|
||||
expect(result.data?.afterBalance).toBe(150)
|
||||
})
|
||||
|
||||
it('should store context data for verification', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
|
||||
expect(ctx.get('currency:player-1:gold:before')).toBe(0)
|
||||
expect(ctx.get('currency:player-1:gold:after')).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 扣除货币 | Execute Tests - Deduct Currency
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - deduct', () => {
|
||||
it('should deduct currency from balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 200)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 75,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeBalance).toBe(200)
|
||||
expect(result.data?.afterBalance).toBe(125)
|
||||
|
||||
const balance = await storage.get<number>('player:player-1:currency:gold')
|
||||
expect(balance).toBe(125)
|
||||
})
|
||||
|
||||
it('should fail deduct with insufficient balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 50)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Insufficient balance')
|
||||
expect(result.errorCode).toBe('INSUFFICIENT_BALANCE')
|
||||
})
|
||||
|
||||
it('should deduct exact balance', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 100)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterBalance).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 补偿测试 | Compensate Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('compensate()', () => {
|
||||
it('should restore balance after add', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(100)
|
||||
|
||||
await op.compensate(ctx)
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||
})
|
||||
|
||||
it('should restore balance after deduct', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 200)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 75,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(125)
|
||||
|
||||
await op.compensate(ctx)
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Provider 测试 | Provider Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Provider', () => {
|
||||
it('should use provider for operations', async () => {
|
||||
const provider = new MockCurrencyProvider()
|
||||
provider.setInitialBalance('player-1', 'gold', 1000)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 300,
|
||||
}).setProvider(provider)
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeBalance).toBe(1000)
|
||||
expect(result.data?.afterBalance).toBe(700)
|
||||
|
||||
const newBalance = await provider.getBalance('player-1', 'gold')
|
||||
expect(newBalance).toBe(700)
|
||||
})
|
||||
|
||||
it('should compensate using provider', async () => {
|
||||
const provider = new MockCurrencyProvider()
|
||||
provider.setInitialBalance('player-1', 'gold', 500)
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 200,
|
||||
}).setProvider(provider)
|
||||
|
||||
await op.execute(ctx)
|
||||
expect(await provider.getBalance('player-1', 'gold')).toBe(700)
|
||||
|
||||
await op.compensate(ctx)
|
||||
expect(await provider.getBalance('player-1', 'gold')).toBe(500)
|
||||
})
|
||||
|
||||
it('should support method chaining with setProvider', () => {
|
||||
const provider = new MockCurrencyProvider()
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = op.setProvider(provider)
|
||||
expect(result).toBe(op)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 多货币类型测试 | Multiple Currency Types Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Multiple Currency Types', () => {
|
||||
it('should handle different currency types independently', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 1000)
|
||||
await storage.set('player:player-1:currency:diamond', 50)
|
||||
|
||||
const goldOp = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 500,
|
||||
})
|
||||
|
||||
const diamondOp = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'diamond',
|
||||
amount: 10,
|
||||
})
|
||||
|
||||
await goldOp.execute(ctx)
|
||||
await diamondOp.execute(ctx)
|
||||
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(500)
|
||||
expect(await storage.get('player:player-1:currency:diamond')).toBe(60)
|
||||
})
|
||||
|
||||
it('should handle multiple players independently', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 1000)
|
||||
await storage.set('player:player-2:currency:gold', 500)
|
||||
|
||||
const op1 = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 300,
|
||||
})
|
||||
|
||||
const op2 = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-2',
|
||||
currency: 'gold',
|
||||
amount: 300,
|
||||
})
|
||||
|
||||
await op1.execute(ctx)
|
||||
await op2.execute(ctx)
|
||||
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(700)
|
||||
expect(await storage.get('player:player-2:currency:gold')).toBe(800)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 边界情况测试 | Edge Cases
|
||||
// ========================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero balance for new player', async () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'new-player',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
expect(result.data?.beforeBalance).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle context without storage', async () => {
|
||||
const noStorageCtx = new TransactionContext()
|
||||
|
||||
const op = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
})
|
||||
|
||||
const result = await op.execute(noStorageCtx)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeBalance).toBe(0)
|
||||
expect(result.data?.afterBalance).toBe(100)
|
||||
})
|
||||
|
||||
it('should include reason in data', () => {
|
||||
const op = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player-1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
reason: 'purchase_item',
|
||||
})
|
||||
|
||||
expect(op.data.reason).toBe('purchase_item')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @zh InventoryOperation 单元测试
|
||||
* @en InventoryOperation unit tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
InventoryOperation,
|
||||
createInventoryOperation,
|
||||
type IInventoryProvider,
|
||||
type ItemData,
|
||||
} from '../../src/operations/InventoryOperation.js'
|
||||
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||
import type { ITransactionContext } from '../../src/core/types.js'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Provider | 模拟数据提供者
|
||||
// ============================================================================
|
||||
|
||||
class MockInventoryProvider implements IInventoryProvider {
|
||||
private _inventory: Map<string, Map<string, ItemData>> = new Map()
|
||||
private _capacity: Map<string, number> = new Map()
|
||||
|
||||
addItem(playerId: string, itemId: string, item: ItemData): void {
|
||||
if (!this._inventory.has(playerId)) {
|
||||
this._inventory.set(playerId, new Map())
|
||||
}
|
||||
this._inventory.get(playerId)!.set(itemId, item)
|
||||
}
|
||||
|
||||
setCapacity(playerId: string, capacity: number): void {
|
||||
this._capacity.set(playerId, capacity)
|
||||
}
|
||||
|
||||
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||
return this._inventory.get(playerId)?.get(itemId) ?? null
|
||||
}
|
||||
|
||||
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||
if (!this._inventory.has(playerId)) {
|
||||
this._inventory.set(playerId, new Map())
|
||||
}
|
||||
if (item) {
|
||||
this._inventory.get(playerId)!.set(itemId, item)
|
||||
} else {
|
||||
this._inventory.get(playerId)!.delete(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||
const capacity = this._capacity.get(playerId)
|
||||
if (capacity === undefined) return true
|
||||
const currentCount = this._inventory.get(playerId)?.size ?? 0
|
||||
return currentCount + count <= capacity
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite | 测试套件
|
||||
// ============================================================================
|
||||
|
||||
describe('InventoryOperation', () => {
|
||||
let storage: MemoryStorage
|
||||
let ctx: ITransactionContext
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage()
|
||||
ctx = new TransactionContext({ storage })
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 构造器测试 | Constructor Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should create with data', () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword-001',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
expect(op.name).toBe('inventory')
|
||||
expect(op.data.type).toBe('add')
|
||||
expect(op.data.playerId).toBe('player-1')
|
||||
expect(op.data.itemId).toBe('sword-001')
|
||||
expect(op.data.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('should use createInventoryOperation factory', () => {
|
||||
const op = createInventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-2',
|
||||
itemId: 'potion-hp',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
expect(op).toBeInstanceOf(InventoryOperation)
|
||||
expect(op.data.type).toBe('remove')
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 验证测试 | Validation Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('validate()', () => {
|
||||
it('should fail validation with zero quantity', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 0,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail validation with negative quantity', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: -1,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass validation for add with positive quantity', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail validation for remove with insufficient quantity', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 2,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail validation for remove with non-existent item', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'nonexistent',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass validation for remove with sufficient quantity', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 10,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should check capacity with provider', async () => {
|
||||
const provider = new MockInventoryProvider()
|
||||
provider.setCapacity('player-1', 1)
|
||||
provider.addItem('player-1', 'existing', { itemId: 'existing', quantity: 1 })
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'new-item',
|
||||
quantity: 1,
|
||||
}).setProvider(provider)
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 添加物品 | Execute Tests - Add Item
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - add', () => {
|
||||
it('should add new item to empty inventory', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeItem).toBeUndefined()
|
||||
expect(result.data?.afterItem).toEqual({
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
properties: undefined,
|
||||
})
|
||||
|
||||
const item = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(item?.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('should stack items when adding to existing', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeItem?.quantity).toBe(5)
|
||||
expect(result.data?.afterItem?.quantity).toBe(8)
|
||||
})
|
||||
|
||||
it('should add item with properties', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'enchanted-sword',
|
||||
quantity: 1,
|
||||
properties: { damage: 100, enchant: 'fire' },
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterItem?.properties).toEqual({
|
||||
damage: 100,
|
||||
enchant: 'fire',
|
||||
})
|
||||
})
|
||||
|
||||
it('should store context data', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
|
||||
expect(ctx.get('inventory:player-1:sword:before')).toBeNull()
|
||||
expect(ctx.get('inventory:player-1:sword:after')).toEqual({
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
properties: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 移除物品 | Execute Tests - Remove Item
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - remove', () => {
|
||||
it('should remove partial quantity', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 10,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.beforeItem?.quantity).toBe(10)
|
||||
expect(result.data?.afterItem?.quantity).toBe(7)
|
||||
|
||||
const item = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||
expect(item?.quantity).toBe(7)
|
||||
})
|
||||
|
||||
it('should delete item when removing all quantity', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterItem).toBeUndefined()
|
||||
|
||||
const item = await storage.get('player:player-1:inventory:sword')
|
||||
expect(item).toBeNull()
|
||||
})
|
||||
|
||||
it('should fail when item not found', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'nonexistent',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Insufficient item quantity')
|
||||
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
|
||||
})
|
||||
|
||||
it('should fail when quantity insufficient', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 2,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 更新物品 | Execute Tests - Update Item
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - update', () => {
|
||||
it('should update item properties', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
properties: { damage: 10 },
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 0, // keep existing
|
||||
properties: { damage: 20, enchant: 'ice' },
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterItem?.properties).toEqual({
|
||||
damage: 20,
|
||||
enchant: 'ice',
|
||||
})
|
||||
expect(result.data?.afterItem?.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('should update item quantity', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 10,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterItem?.quantity).toBe(10)
|
||||
})
|
||||
|
||||
it('should fail when updating non-existent item', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player-1',
|
||||
itemId: 'nonexistent',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Item not found')
|
||||
expect(result.errorCode).toBe('ITEM_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 补偿测试 | Compensate Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('compensate()', () => {
|
||||
it('should restore state after add', async () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
expect(await storage.get('player:player-1:inventory:sword')).not.toBeNull()
|
||||
|
||||
await op.compensate(ctx)
|
||||
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||
})
|
||||
|
||||
it('should restore state after remove', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
const afterRemove = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||
expect(afterRemove?.quantity).toBe(2)
|
||||
|
||||
await op.compensate(ctx)
|
||||
const afterCompensate = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||
expect(afterCompensate?.quantity).toBe(5)
|
||||
})
|
||||
|
||||
it('should restore deleted item after remove all', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||
|
||||
await op.compensate(ctx)
|
||||
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(restored?.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('should restore state after update', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
properties: { damage: 10 },
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 0,
|
||||
properties: { damage: 50 },
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
await op.compensate(ctx)
|
||||
|
||||
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(restored?.properties).toEqual({ damage: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Provider 测试 | Provider Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Provider', () => {
|
||||
it('should use provider for operations', async () => {
|
||||
const provider = new MockInventoryProvider()
|
||||
provider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 2,
|
||||
}).setProvider(provider)
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.afterItem?.quantity).toBe(3)
|
||||
|
||||
const item = await provider.getItem('player-1', 'sword')
|
||||
expect(item?.quantity).toBe(3)
|
||||
})
|
||||
|
||||
it('should compensate using provider', async () => {
|
||||
const provider = new MockInventoryProvider()
|
||||
provider.addItem('player-1', 'potion', { itemId: 'potion', quantity: 10 })
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 3,
|
||||
}).setProvider(provider)
|
||||
|
||||
await op.execute(ctx)
|
||||
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(7)
|
||||
|
||||
await op.compensate(ctx)
|
||||
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 边界情况测试 | Edge Cases
|
||||
// ========================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle context without storage', async () => {
|
||||
const noStorageCtx = new TransactionContext()
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const result = await op.execute(noStorageCtx)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should include reason in data', () => {
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'reward-sword',
|
||||
quantity: 1,
|
||||
reason: 'quest_reward',
|
||||
})
|
||||
|
||||
expect(op.data.reason).toBe('quest_reward')
|
||||
})
|
||||
|
||||
it('should preserve item properties when stacking', async () => {
|
||||
await storage.set('player:player-1:inventory:potion', {
|
||||
itemId: 'potion',
|
||||
quantity: 5,
|
||||
properties: { quality: 'rare' },
|
||||
})
|
||||
|
||||
const op = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player-1',
|
||||
itemId: 'potion',
|
||||
quantity: 3,
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.data?.afterItem?.properties).toEqual({ quality: 'rare' })
|
||||
expect(result.data?.afterItem?.quantity).toBe(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* @zh TradeOperation 单元测试
|
||||
* @en TradeOperation unit tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { TradeOperation, createTradeOperation, type ITradeProvider } from '../../src/operations/TradeOperation.js'
|
||||
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||
import type { ITransactionContext } from '../../src/core/types.js'
|
||||
import type { ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
|
||||
import type { IInventoryProvider, ItemData } from '../../src/operations/InventoryOperation.js'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Providers | 模拟数据提供者
|
||||
// ============================================================================
|
||||
|
||||
class MockCurrencyProvider implements ICurrencyProvider {
|
||||
private _balances: Map<string, Map<string, number>> = new Map()
|
||||
|
||||
initBalance(playerId: string, currency: string, amount: number): void {
|
||||
if (!this._balances.has(playerId)) {
|
||||
this._balances.set(playerId, new Map())
|
||||
}
|
||||
this._balances.get(playerId)!.set(currency, amount)
|
||||
}
|
||||
|
||||
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||
return this._balances.get(playerId)?.get(currency) ?? 0
|
||||
}
|
||||
|
||||
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||
if (!this._balances.has(playerId)) {
|
||||
this._balances.set(playerId, new Map())
|
||||
}
|
||||
this._balances.get(playerId)!.set(currency, amount)
|
||||
}
|
||||
}
|
||||
|
||||
class MockInventoryProvider implements IInventoryProvider {
|
||||
private _inventory: Map<string, Map<string, ItemData>> = new Map()
|
||||
|
||||
addItem(playerId: string, itemId: string, item: ItemData): void {
|
||||
if (!this._inventory.has(playerId)) {
|
||||
this._inventory.set(playerId, new Map())
|
||||
}
|
||||
this._inventory.get(playerId)!.set(itemId, item)
|
||||
}
|
||||
|
||||
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||
return this._inventory.get(playerId)?.get(itemId) ?? null
|
||||
}
|
||||
|
||||
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||
if (!this._inventory.has(playerId)) {
|
||||
this._inventory.set(playerId, new Map())
|
||||
}
|
||||
if (item) {
|
||||
this._inventory.get(playerId)!.set(itemId, item)
|
||||
} else {
|
||||
this._inventory.get(playerId)!.delete(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite | 测试套件
|
||||
// ============================================================================
|
||||
|
||||
describe('TradeOperation', () => {
|
||||
let storage: MemoryStorage
|
||||
let ctx: ITransactionContext
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage()
|
||||
ctx = new TransactionContext({ storage })
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 构造器测试 | Constructor Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should create with data', () => {
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-001',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(op.name).toBe('trade')
|
||||
expect(op.data.tradeId).toBe('trade-001')
|
||||
expect(op.data.partyA.playerId).toBe('player-1')
|
||||
expect(op.data.partyB.playerId).toBe('player-2')
|
||||
})
|
||||
|
||||
it('should use createTradeOperation factory', () => {
|
||||
const op = createTradeOperation({
|
||||
tradeId: 'trade-002',
|
||||
partyA: { playerId: 'player-1' },
|
||||
partyB: { playerId: 'player-2' },
|
||||
})
|
||||
|
||||
expect(op).toBeInstanceOf(TradeOperation)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 验证测试 | Validation Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('validate()', () => {
|
||||
it('should validate item trade', async () => {
|
||||
// Player 1 has sword, Player 2 has gold
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:currency:gold', 1000)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-001',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail validation when party A lacks items', async () => {
|
||||
await storage.set('player:player-2:currency:gold', 1000)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-001',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail validation when party B lacks currency', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:currency:gold', 500) // Not enough
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-001',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
const isValid = await op.validate(ctx)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 物品换货币 | Execute Tests - Item for Currency
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - item for currency', () => {
|
||||
it('should trade item for currency', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:currency:gold', 1000)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-001',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.tradeId).toBe('trade-001')
|
||||
expect(result.data?.completed).toBe(true)
|
||||
|
||||
// Player 1: no sword, got gold
|
||||
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
|
||||
|
||||
// Player 2: got sword, no gold
|
||||
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||
expect(p2Sword?.quantity).toBe(1)
|
||||
expect(await storage.get('player:player-2:currency:gold')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 物品换物品 | Execute Tests - Item for Item
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - item for item', () => {
|
||||
it('should trade items between players', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:inventory:shield', {
|
||||
itemId: 'shield',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-002',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
items: [{ itemId: 'shield', quantity: 1 }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Player 1: got shield, no sword
|
||||
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||
const p1Shield = await storage.get<ItemData>('player:player-1:inventory:shield')
|
||||
expect(p1Shield?.quantity).toBe(1)
|
||||
|
||||
// Player 2: got sword, no shield
|
||||
expect(await storage.get('player:player-2:inventory:shield')).toBeNull()
|
||||
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||
expect(p2Sword?.quantity).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 货币换货币 | Execute Tests - Currency for Currency
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - currency for currency', () => {
|
||||
it('should trade currencies between players', async () => {
|
||||
await storage.set('player:player-1:currency:gold', 1000)
|
||||
await storage.set('player:player-2:currency:diamond', 100)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-003',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'diamond', amount: 100 }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Player 1: no gold, got diamonds
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||
expect(await storage.get('player:player-1:currency:diamond')).toBe(100)
|
||||
|
||||
// Player 2: got gold, no diamonds
|
||||
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
|
||||
expect(await storage.get('player:player-2:currency:diamond')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 执行测试 - 复杂交易 | Execute Tests - Complex Trade
|
||||
// ========================================================================
|
||||
|
||||
describe('execute() - complex trade', () => {
|
||||
it('should handle multiple items and currencies', async () => {
|
||||
// Setup
|
||||
await storage.set('player:player-1:inventory:sword', { itemId: 'sword', quantity: 2 })
|
||||
await storage.set('player:player-1:inventory:potion', { itemId: 'potion', quantity: 10 })
|
||||
await storage.set('player:player-2:currency:gold', 5000)
|
||||
await storage.set('player:player-2:currency:diamond', 50)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-004',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [
|
||||
{ itemId: 'sword', quantity: 1 },
|
||||
{ itemId: 'potion', quantity: 5 },
|
||||
],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [
|
||||
{ currency: 'gold', amount: 2000 },
|
||||
{ currency: 'diamond', amount: 20 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Player 1
|
||||
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(p1Sword?.quantity).toBe(1) // Had 2, gave 1
|
||||
const p1Potion = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||
expect(p1Potion?.quantity).toBe(5) // Had 10, gave 5
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(2000)
|
||||
expect(await storage.get('player:player-1:currency:diamond')).toBe(20)
|
||||
|
||||
// Player 2
|
||||
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||
expect(p2Sword?.quantity).toBe(1)
|
||||
const p2Potion = await storage.get<ItemData>('player:player-2:inventory:potion')
|
||||
expect(p2Potion?.quantity).toBe(5)
|
||||
expect(await storage.get('player:player-2:currency:gold')).toBe(3000) // Had 5000, gave 2000
|
||||
expect(await storage.get('player:player-2:currency:diamond')).toBe(30) // Had 50, gave 20
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 失败和补偿测试 | Failure and Compensation Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('failure and compensation', () => {
|
||||
it('should rollback on partial failure', async () => {
|
||||
// Player 1 has sword, Player 2 does NOT have enough gold
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:currency:gold', 500) // Not enough
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-fail',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errorCode).toBe('TRADE_FAILED')
|
||||
|
||||
// Everything should be restored
|
||||
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(p1Sword?.quantity).toBe(1) // Restored
|
||||
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
|
||||
})
|
||||
|
||||
it('should compensate after successful execute', async () => {
|
||||
await storage.set('player:player-1:inventory:sword', {
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
})
|
||||
await storage.set('player:player-2:currency:gold', 1000)
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-compensate',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
})
|
||||
|
||||
await op.execute(ctx)
|
||||
|
||||
// Verify trade happened
|
||||
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
|
||||
|
||||
// Compensate
|
||||
await op.compensate(ctx)
|
||||
|
||||
// Everything should be restored
|
||||
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||
expect(p1Sword?.quantity).toBe(1)
|
||||
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
|
||||
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Provider 测试 | Provider Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Provider', () => {
|
||||
it('should use providers for trade', async () => {
|
||||
const currencyProvider = new MockCurrencyProvider()
|
||||
const inventoryProvider = new MockInventoryProvider()
|
||||
|
||||
currencyProvider.initBalance('player-1', 'gold', 0)
|
||||
currencyProvider.initBalance('player-2', 'gold', 1000)
|
||||
inventoryProvider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
|
||||
|
||||
const provider: ITradeProvider = {
|
||||
currencyProvider,
|
||||
inventoryProvider,
|
||||
}
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-provider',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player-2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
}).setProvider(provider)
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify using providers
|
||||
expect(await inventoryProvider.getItem('player-1', 'sword')).toBeNull()
|
||||
expect(await currencyProvider.getBalance('player-1', 'gold')).toBe(1000)
|
||||
expect((await inventoryProvider.getItem('player-2', 'sword'))?.quantity).toBe(1)
|
||||
expect(await currencyProvider.getBalance('player-2', 'gold')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// 边界情况测试 | Edge Cases
|
||||
// ========================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty trade', async () => {
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-empty',
|
||||
partyA: { playerId: 'player-1' },
|
||||
partyB: { playerId: 'player-2' },
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle one-sided gift', async () => {
|
||||
await storage.set('player:player-1:inventory:gift', {
|
||||
itemId: 'gift',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-gift',
|
||||
partyA: {
|
||||
playerId: 'player-1',
|
||||
items: [{ itemId: 'gift', quantity: 1 }],
|
||||
},
|
||||
partyB: { playerId: 'player-2' }, // Gives nothing
|
||||
})
|
||||
|
||||
const result = await op.execute(ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(await storage.get('player:player-1:inventory:gift')).toBeNull()
|
||||
const p2Gift = await storage.get<ItemData>('player:player-2:inventory:gift')
|
||||
expect(p2Gift?.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('should include reason in data', () => {
|
||||
const op = new TradeOperation({
|
||||
tradeId: 'trade-reason',
|
||||
partyA: { playerId: 'player-1' },
|
||||
partyB: { playerId: 'player-2' },
|
||||
reason: 'player_trade',
|
||||
})
|
||||
|
||||
expect(op.data.reason).toBe('player_trade')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user