mirror of
https://github.com/motajs/template.git
synced 2026-09-28 06:00:17 +08:00
test(06-16): add isolated perf channel and critical calculation perf
- vitest.perf.config.ts: include only *.perf.ts, mirror vite.config.ts aliases, 30s timeouts - package.json: add test:perf script (test/test:ci untouched) - damage.perf.ts: 3 cases (1000/10000/50000) warmup 3 + 20 samples, no assertions
This commit is contained in:
parent
ac2a9b34d4
commit
37bf89bc94
@ -7,6 +7,7 @@
|
||||
"dev": "tsx script/dev.ts",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"test:perf": "vitest run --config vitest.perf.config.ts",
|
||||
"test:data-node": "tsx script/test-data-node.ts",
|
||||
"preview": "vite preview",
|
||||
"declare": "tsx script/declare.ts",
|
||||
|
||||
293
packages-user/data-system/src/combat/damage.perf.ts
Normal file
293
packages-user/data-system/src/combat/damage.perf.ts
Normal file
@ -0,0 +1,293 @@
|
||||
// 性能测量:临界计算(calculateCritical)完整枚举随重复次数的耗时,只记录不断言
|
||||
import { afterAll, beforeAll, describe, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import { type IReadonlyEnemy, type IStateBase } from '@user/data-base';
|
||||
import {
|
||||
type CriticalableHeroStatus,
|
||||
type IDamageCalculator,
|
||||
type IEnemyContext,
|
||||
type IEnemyDamageInfoBase,
|
||||
type IEnemyView,
|
||||
type IReadonlyEnemyHandler
|
||||
} from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestEnemyAttr {
|
||||
/** 怪物生命值 */
|
||||
hp: number;
|
||||
/** 怪物攻击力 */
|
||||
atk: number;
|
||||
/** 怪物防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestHeroAttr {
|
||||
/** 勇士生命值 */
|
||||
hp: number;
|
||||
/** 勇士攻击力 */
|
||||
atk: number;
|
||||
/** 勇士防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestModules {
|
||||
DamageContext: typeof import('./damage').DamageContext;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
MapLocIndexer: typeof import('@user/data-common').MapLocIndexer;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const damageModule = await import('./damage');
|
||||
const baseModule = await import('@user/data-base');
|
||||
const commonModule = await import('@user/data-common');
|
||||
modules = {
|
||||
DamageContext: damageModule.DamageContext,
|
||||
HeroAttribute: baseModule.HeroAttribute,
|
||||
Enemy: baseModule.Enemy,
|
||||
MapLocIndexer: commonModule.MapLocIndexer
|
||||
};
|
||||
});
|
||||
|
||||
/** 预热次数,不计入采样 */
|
||||
const WARMUP_RUNS = 3;
|
||||
/** 采样次数,取排序后的下中位数 */
|
||||
const SAMPLE_RUNS = 20;
|
||||
/** 临界计算覆盖的重复枚举次数 */
|
||||
const CRITICAL_SCALES = [1000, 10000, 50000] as const;
|
||||
|
||||
/** 单条性能记录,字段与控制台表格列一一对应 */
|
||||
interface PerfRecord {
|
||||
/** 测量项名称 */
|
||||
case: string;
|
||||
/** 规模档位 */
|
||||
scale: string;
|
||||
/** 中位耗时,单位毫秒 */
|
||||
'median ms': number;
|
||||
/** 最小耗时,单位毫秒 */
|
||||
'min ms': number;
|
||||
/** 95 分位耗时,单位毫秒 */
|
||||
'p95 ms': number;
|
||||
}
|
||||
|
||||
/** 本次运行累积的性能记录,由 afterAll 统一打印 */
|
||||
const records: PerfRecord[] = [];
|
||||
|
||||
/**
|
||||
* 测量一次用例:先预热若干次,再采样后取中位数、最小值与 p95
|
||||
* @param caseName 测量项名称
|
||||
* @param scale 规模档位
|
||||
* @param run 单次被测量的操作
|
||||
*/
|
||||
function measureCase(
|
||||
caseName: string,
|
||||
scale: string,
|
||||
run: () => void
|
||||
): PerfRecord {
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
run();
|
||||
}
|
||||
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < SAMPLE_RUNS; i++) {
|
||||
const start = globalThis.performance.now();
|
||||
run();
|
||||
samples.push(globalThis.performance.now() - start);
|
||||
}
|
||||
|
||||
samples.sort((left, right) => left - right);
|
||||
const record: PerfRecord = {
|
||||
case: caseName,
|
||||
scale,
|
||||
'median ms': Number(samples[SAMPLE_RUNS / 2].toFixed(3)),
|
||||
'min ms': Number(samples[0].toFixed(3)),
|
||||
'p95 ms': Number(samples[Math.ceil(SAMPLE_RUNS * 0.95) - 1].toFixed(3))
|
||||
};
|
||||
records.push(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定伤害计算器:伤害区间由 100 按每点攻击 10 递减到 0,临界上界固定为 10
|
||||
*/
|
||||
class PerfCalculator implements IDamageCalculator<TestEnemyAttr, TestHeroAttr> {
|
||||
calculate(
|
||||
handler: IReadonlyEnemyHandler<TestEnemyAttr, TestHeroAttr>
|
||||
): IEnemyDamageInfoBase {
|
||||
const atk = handler.hero.getBaseAttribute('atk');
|
||||
return {
|
||||
damage: Math.max(0, 100 - atk * 10),
|
||||
turn: 2
|
||||
};
|
||||
}
|
||||
|
||||
getCriticalLimit(
|
||||
_handler: IReadonlyEnemyHandler<TestEnemyAttr, TestHeroAttr>,
|
||||
_attribute: CriticalableHeroStatus<TestHeroAttr>
|
||||
): number {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
type TestHeroAttribute = import('@user/data-base').HeroAttribute<TestHeroAttr>;
|
||||
|
||||
interface CriticalFixture {
|
||||
/** 怪物上下文假对象 */
|
||||
context: IEnemyContext<TestEnemyAttr, TestHeroAttr>;
|
||||
/** 固定伤害计算器 */
|
||||
calculator: PerfCalculator;
|
||||
/** 勇士属性对象,攻击力固定为 0 */
|
||||
hero: TestHeroAttribute;
|
||||
/** 怪物视图假对象 */
|
||||
view: IEnemyView<TestEnemyAttr>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临界计算测量夹具,怪物上下文只实现被测路径所需的最小接口
|
||||
*/
|
||||
function createCriticalFixture(): CriticalFixture {
|
||||
const indexer = new modules.MapLocIndexer();
|
||||
indexer.setWidth(4);
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
const origin = new modules.Enemy<TestEnemyAttr>('perf-enemy', 1, {
|
||||
hp: 30,
|
||||
atk: 8,
|
||||
def: 2
|
||||
});
|
||||
const computed = origin.clone();
|
||||
const locators = new Map<IEnemyView<TestEnemyAttr>, ITileLocator>();
|
||||
const computedViews = new Map<
|
||||
IReadonlyEnemy<TestEnemyAttr>,
|
||||
IEnemyView<TestEnemyAttr>
|
||||
>();
|
||||
const view: IEnemyView<TestEnemyAttr> = {
|
||||
context: {} as never,
|
||||
reset: () => {},
|
||||
getBaseEnemy: () => origin,
|
||||
getComputedEnemy: () => computed,
|
||||
getModifiableEnemy: () => origin,
|
||||
markDirty: () => {}
|
||||
};
|
||||
locators.set(view, { x: 2, y: 3 });
|
||||
computedViews.set(computed, view);
|
||||
const context = {
|
||||
state: {} as IStateBase,
|
||||
indexer,
|
||||
getBindedHero: () => hero,
|
||||
getEnemyLocatorByView: (target: IEnemyView<TestEnemyAttr>) =>
|
||||
locators.get(target) ?? null,
|
||||
getViewByComputed: (target: IReadonlyEnemy<TestEnemyAttr>) =>
|
||||
computedViews.get(target) ?? null
|
||||
} as never;
|
||||
return {
|
||||
context,
|
||||
calculator: new PerfCalculator(),
|
||||
hero,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
console.table(records);
|
||||
});
|
||||
|
||||
describe('临界计算性能', () => {
|
||||
// 覆盖 1000 次完整临界枚举的重复耗时
|
||||
it('measures 1000 critical enumerations', () => {
|
||||
const fixture = createCriticalFixture();
|
||||
const damageContext = new modules.DamageContext(
|
||||
fixture.context,
|
||||
fixture.calculator,
|
||||
fixture.hero
|
||||
);
|
||||
const count = CRITICAL_SCALES[0];
|
||||
|
||||
measureCase('临界计算', String(count), () => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (const _ of damageContext.calculateCritical(
|
||||
fixture.view,
|
||||
'atk'
|
||||
)) {
|
||||
// 必须完整消费生成器,否则测不到临界枚举的真实开销
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 覆盖 10000 次完整临界枚举的重复耗时
|
||||
it('measures 10000 critical enumerations', () => {
|
||||
const fixture = createCriticalFixture();
|
||||
const damageContext = new modules.DamageContext(
|
||||
fixture.context,
|
||||
fixture.calculator,
|
||||
fixture.hero
|
||||
);
|
||||
const count = CRITICAL_SCALES[1];
|
||||
|
||||
measureCase('临界计算', String(count), () => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (const _ of damageContext.calculateCritical(
|
||||
fixture.view,
|
||||
'atk'
|
||||
)) {
|
||||
// 必须完整消费生成器,否则测不到临界枚举的真实开销
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 覆盖 50000 次完整临界枚举的重复耗时
|
||||
it('measures 50000 critical enumerations', () => {
|
||||
const fixture = createCriticalFixture();
|
||||
const damageContext = new modules.DamageContext(
|
||||
fixture.context,
|
||||
fixture.calculator,
|
||||
fixture.hero
|
||||
);
|
||||
const count = CRITICAL_SCALES[2];
|
||||
|
||||
measureCase('临界计算', String(count), () => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (const _ of damageContext.calculateCritical(
|
||||
fixture.view,
|
||||
'atk'
|
||||
)) {
|
||||
// 必须完整消费生成器,否则测不到临界枚举的真实开销
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
33
vitest.perf.config.ts
Normal file
33
vitest.perf.config.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// 性能测量专用 vitest 配置:仅收集 *.perf.ts,与默认 test:ci 通道完全隔离
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
import * as glob from 'glob';
|
||||
|
||||
// 镜像 vite.config.ts 的核心包别名计算,node_modules 下没有 @motajs/* 与 @user/*
|
||||
const aliases = glob.sync('packages/*/src').map(srcPath => {
|
||||
const packageName = path.basename(path.dirname(srcPath));
|
||||
return {
|
||||
find: `@motajs/${packageName}`,
|
||||
replacement: path.resolve(__dirname, srcPath)
|
||||
};
|
||||
});
|
||||
|
||||
// 镜像 vite.config.ts 的用户包别名计算,保证 perf 文件及其传递依赖可解析
|
||||
const aliasesUser = glob.sync('packages-user/*/src').map(srcPath => {
|
||||
const packageName = path.basename(path.dirname(srcPath));
|
||||
return {
|
||||
find: `@user/${packageName}`,
|
||||
replacement: path.resolve(__dirname, srcPath)
|
||||
};
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [...aliases, ...aliasesUser]
|
||||
},
|
||||
test: {
|
||||
include: ['**/*.perf.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user