feat(ecs): 核心系统改进 - 句柄、调度、变更检测与查询编译 (#304)
新增功能: - EntityHandle: 轻量级实体句柄 (28位索引 + 20位代数) - SystemScheduler: 声明式系统调度,支持 @Stage/@Before/@After/@InSet 装饰器 - EpochManager: 帧级变更检测 - CompiledQuery: 预编译类型安全查询 API 改进: - EntitySystem 添加 getBefore()/getAfter()/getSets() getter 方法 - Entity 添加 markDirty() 辅助方法 - IScene 添加 epochManager 属性 - CommandBuffer.pendingCount 修正为返回实际操作数 文档更新: - 更新系统调度和查询相关文档
This commit is contained in:
@@ -282,7 +282,9 @@ describe('CommandBuffer', () => {
|
||||
commandBuffer.addComponent(entity, new MarkerComponent());
|
||||
commandBuffer.destroyEntity(entity);
|
||||
|
||||
expect(commandBuffer.pendingCount).toBe(2);
|
||||
// 由于去重逻辑,destroyEntity 会清除同一实体的其他操作
|
||||
// Due to deduplication, destroyEntity clears other operations for the same entity
|
||||
expect(commandBuffer.pendingCount).toBe(1);
|
||||
|
||||
commandBuffer.clear();
|
||||
|
||||
|
||||
373
packages/core/tests/ECS/Core/CompiledQuery.test.ts
Normal file
373
packages/core/tests/ECS/Core/CompiledQuery.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { CompiledQuery } from '../../../src/ECS/Core/Query/CompiledQuery';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import { Entity } from '../../../src/ECS/Entity';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
|
||||
// 测试组件
|
||||
@ECSComponent('CompiledQuery_Position')
|
||||
class PositionComponent extends Component {
|
||||
public x: number;
|
||||
public y: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [x = 0, y = 0] = args as [number?, number?];
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('CompiledQuery_Velocity')
|
||||
class VelocityComponent extends Component {
|
||||
public vx: number;
|
||||
public vy: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [vx = 0, vy = 0] = args as [number?, number?];
|
||||
this.vx = vx;
|
||||
this.vy = vy;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('CompiledQuery_Health')
|
||||
class HealthComponent extends Component {
|
||||
public current: number;
|
||||
public max: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [current = 100, max = 100] = args as [number?, number?];
|
||||
this.current = current;
|
||||
this.max = max;
|
||||
}
|
||||
}
|
||||
|
||||
describe('CompiledQuery', () => {
|
||||
let scene: Scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scene.end();
|
||||
});
|
||||
|
||||
describe('创建和基本属性', () => {
|
||||
it('应该能通过 QuerySystem.compile 创建', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query).toBeInstanceOf(CompiledQuery);
|
||||
});
|
||||
|
||||
it('应该保存组件类型列表', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent, VelocityComponent);
|
||||
expect(query.componentTypes).toContain(PositionComponent);
|
||||
expect(query.componentTypes).toContain(VelocityComponent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entities 属性', () => {
|
||||
it('初始应该返回空数组', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该返回匹配的实体', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.entities).toHaveLength(1);
|
||||
expect(query.entities[0]).toBe(entity);
|
||||
});
|
||||
|
||||
it('应该只返回拥有所有组件的实体', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent());
|
||||
entity1.addComponent(new VelocityComponent());
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent());
|
||||
// entity2 没有 VelocityComponent
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent, VelocityComponent);
|
||||
expect(query.entities).toHaveLength(1);
|
||||
expect(query.entities[0]).toBe(entity1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count 属性', () => {
|
||||
it('应该返回匹配实体的数量', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.count).toBe(0);
|
||||
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
expect(query.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEach', () => {
|
||||
it('应该遍历所有匹配的实体', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const visited: Entity[] = [];
|
||||
|
||||
query.forEach((entity, pos) => {
|
||||
visited.push(entity);
|
||||
});
|
||||
|
||||
expect(visited).toHaveLength(2);
|
||||
expect(visited).toContain(entity1);
|
||||
expect(visited).toContain(entity2);
|
||||
});
|
||||
|
||||
it('应该提供类型安全的组件参数', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
entity.addComponent(new VelocityComponent(1, 2));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent, VelocityComponent);
|
||||
|
||||
query.forEach((entity, pos, vel) => {
|
||||
expect(pos.x).toBe(10);
|
||||
expect(pos.y).toBe(20);
|
||||
expect(vel.vx).toBe(1);
|
||||
expect(vel.vy).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEachChanged', () => {
|
||||
it('应该只遍历变更的实体', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
const pos1 = entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
const pos2 = entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
|
||||
// 获取当前 epoch
|
||||
const epoch = scene.epochManager.current;
|
||||
|
||||
// 递增 epoch
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 只标记 entity1 的组件为已修改
|
||||
pos1.markDirty(scene.epochManager.current);
|
||||
|
||||
const changed: Entity[] = [];
|
||||
query.forEachChanged(epoch, (entity, pos) => {
|
||||
changed.push(entity);
|
||||
});
|
||||
|
||||
expect(changed).toHaveLength(1);
|
||||
expect(changed[0]).toBe(entity1);
|
||||
});
|
||||
|
||||
it('当所有组件都未变更时应该不遍历任何实体', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
|
||||
// 使用当前 epoch 检查 - 组件的 epoch 应该小于等于当前
|
||||
const futureEpoch = scene.epochManager.current + 100;
|
||||
|
||||
const changed: Entity[] = [];
|
||||
query.forEachChanged(futureEpoch, (entity, pos) => {
|
||||
changed.push(entity);
|
||||
});
|
||||
|
||||
expect(changed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('first', () => {
|
||||
it('应该返回第一个匹配的实体和组件', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.first();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result![0]).toBe(entity);
|
||||
expect(result![1].x).toBe(10);
|
||||
expect(result![1].y).toBe(20);
|
||||
});
|
||||
|
||||
it('没有匹配实体时应该返回 null', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.first();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toArray', () => {
|
||||
it('应该返回实体和组件的数组', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.toArray();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]![0]).toBe(entity1);
|
||||
expect(result[0]![1].x).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('map', () => {
|
||||
it('应该映射转换实体数据', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.map((entity, pos) => pos.x + pos.y);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(30); // 10 + 20
|
||||
expect(result).toContain(70); // 30 + 40
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter', () => {
|
||||
it('应该过滤实体', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.filter((entity, pos) => pos.x > 20);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(entity2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('应该找到第一个满足条件的实体', () => {
|
||||
const entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.find((entity, pos) => pos.x > 20);
|
||||
|
||||
expect(result).toBe(entity2);
|
||||
});
|
||||
|
||||
it('找不到时应该返回 undefined', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
const result = query.find((entity, pos) => pos.x > 100);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('any', () => {
|
||||
it('有匹配实体时应该返回 true', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.any()).toBe(true);
|
||||
});
|
||||
|
||||
it('没有匹配实体时应该返回 false', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.any()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty', () => {
|
||||
it('没有匹配实体时应该返回 true', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.empty()).toBe(true);
|
||||
});
|
||||
|
||||
it('有匹配实体时应该返回 false', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
expect(query.empty()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('缓存机制', () => {
|
||||
it('应该缓存查询结果', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
|
||||
// 第一次访问
|
||||
const entities1 = query.entities;
|
||||
// 第二次访问
|
||||
const entities2 = query.entities;
|
||||
|
||||
// 应该返回相同的缓存数组
|
||||
expect(entities1).toBe(entities2);
|
||||
});
|
||||
|
||||
it('当实体变化时应该刷新缓存', () => {
|
||||
const query = scene.querySystem.compile(PositionComponent);
|
||||
|
||||
// 初始为空
|
||||
expect(query.count).toBe(0);
|
||||
|
||||
// 添加实体
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
// 应该检测到变化
|
||||
expect(query.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多组件查询', () => {
|
||||
it('应该支持多组件查询', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
entity.addComponent(new PositionComponent(10, 20));
|
||||
entity.addComponent(new VelocityComponent(1, 2));
|
||||
entity.addComponent(new HealthComponent(80, 100));
|
||||
|
||||
const query = scene.querySystem.compile(
|
||||
PositionComponent,
|
||||
VelocityComponent,
|
||||
HealthComponent
|
||||
);
|
||||
|
||||
query.forEach((entity, pos, vel, health) => {
|
||||
expect(pos.x).toBe(10);
|
||||
expect(vel.vx).toBe(1);
|
||||
expect(health.current).toBe(80);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
380
packages/core/tests/ECS/Core/EntityHandle.test.ts
Normal file
380
packages/core/tests/ECS/Core/EntityHandle.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import {
|
||||
makeHandle,
|
||||
indexOf,
|
||||
genOf,
|
||||
isValidHandle,
|
||||
handleEquals,
|
||||
handleToString,
|
||||
NULL_HANDLE,
|
||||
INDEX_BITS,
|
||||
GEN_BITS,
|
||||
INDEX_MASK,
|
||||
GEN_MASK,
|
||||
MAX_ENTITIES,
|
||||
MAX_GENERATION,
|
||||
EntityHandle
|
||||
} from '../../../src/ECS/Core/EntityHandle';
|
||||
import { EntityHandleManager } from '../../../src/ECS/Core/EntityHandleManager';
|
||||
|
||||
describe('EntityHandle', () => {
|
||||
describe('常量定义', () => {
|
||||
it('INDEX_BITS 应该是 28', () => {
|
||||
expect(INDEX_BITS).toBe(28);
|
||||
});
|
||||
|
||||
it('GEN_BITS 应该是 20', () => {
|
||||
expect(GEN_BITS).toBe(20);
|
||||
});
|
||||
|
||||
it('INDEX_MASK 应该正确', () => {
|
||||
expect(INDEX_MASK).toBe((1 << INDEX_BITS) - 1);
|
||||
});
|
||||
|
||||
it('GEN_MASK 应该正确', () => {
|
||||
expect(GEN_MASK).toBe((1 << GEN_BITS) - 1);
|
||||
});
|
||||
|
||||
it('MAX_ENTITIES 应该是 2^28', () => {
|
||||
expect(MAX_ENTITIES).toBe(1 << INDEX_BITS);
|
||||
});
|
||||
|
||||
it('MAX_GENERATION 应该是 2^20', () => {
|
||||
expect(MAX_GENERATION).toBe(1 << GEN_BITS);
|
||||
});
|
||||
|
||||
it('NULL_HANDLE 应该是 0', () => {
|
||||
expect(NULL_HANDLE).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeHandle', () => {
|
||||
it('应该正确组合 index 和 generation', () => {
|
||||
const handle = makeHandle(100, 5);
|
||||
expect(indexOf(handle)).toBe(100);
|
||||
expect(genOf(handle)).toBe(5);
|
||||
});
|
||||
|
||||
it('应该处理边界值', () => {
|
||||
// 最小值
|
||||
const minHandle = makeHandle(0, 0);
|
||||
expect(indexOf(minHandle)).toBe(0);
|
||||
expect(genOf(minHandle)).toBe(0);
|
||||
|
||||
// 最大 index
|
||||
const maxIndexHandle = makeHandle(INDEX_MASK, 0);
|
||||
expect(indexOf(maxIndexHandle)).toBe(INDEX_MASK);
|
||||
|
||||
// 最大 generation
|
||||
const maxGenHandle = makeHandle(0, GEN_MASK);
|
||||
expect(genOf(maxGenHandle)).toBe(GEN_MASK);
|
||||
});
|
||||
|
||||
it('应该正确处理大数值', () => {
|
||||
const largeIndex = 1000000;
|
||||
const largeGen = 500;
|
||||
const handle = makeHandle(largeIndex, largeGen);
|
||||
|
||||
expect(indexOf(handle)).toBe(largeIndex);
|
||||
expect(genOf(handle)).toBe(largeGen);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexOf', () => {
|
||||
it('应该正确提取 index', () => {
|
||||
const handle = makeHandle(12345, 67);
|
||||
expect(indexOf(handle)).toBe(12345);
|
||||
});
|
||||
|
||||
it('应该对 NULL_HANDLE 返回 0', () => {
|
||||
expect(indexOf(NULL_HANDLE)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genOf', () => {
|
||||
it('应该正确提取 generation', () => {
|
||||
const handle = makeHandle(12345, 67);
|
||||
expect(genOf(handle)).toBe(67);
|
||||
});
|
||||
|
||||
it('应该对 NULL_HANDLE 返回 0', () => {
|
||||
expect(genOf(NULL_HANDLE)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidHandle', () => {
|
||||
it('应该判断 NULL_HANDLE 为无效', () => {
|
||||
expect(isValidHandle(NULL_HANDLE)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该判断非零句柄为有效', () => {
|
||||
const handle = makeHandle(1, 0);
|
||||
expect(isValidHandle(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该判断带 generation 的句柄为有效', () => {
|
||||
const handle = makeHandle(0, 1);
|
||||
expect(isValidHandle(handle)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEquals', () => {
|
||||
it('应该正确比较相同句柄', () => {
|
||||
const handle1 = makeHandle(100, 5);
|
||||
const handle2 = makeHandle(100, 5);
|
||||
expect(handleEquals(handle1, handle2)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确比较不同句柄', () => {
|
||||
const handle1 = makeHandle(100, 5);
|
||||
const handle2 = makeHandle(100, 6);
|
||||
expect(handleEquals(handle1, handle2)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该区分 index 不同的句柄', () => {
|
||||
const handle1 = makeHandle(100, 5);
|
||||
const handle2 = makeHandle(101, 5);
|
||||
expect(handleEquals(handle1, handle2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleToString', () => {
|
||||
it('应该返回可读的字符串格式', () => {
|
||||
const handle = makeHandle(100, 5);
|
||||
const str = handleToString(handle);
|
||||
expect(str).toContain('100');
|
||||
expect(str).toContain('5');
|
||||
});
|
||||
|
||||
it('应该对 NULL_HANDLE 返回特殊标记', () => {
|
||||
const str = handleToString(NULL_HANDLE);
|
||||
expect(str.toLowerCase()).toContain('null');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityHandleManager', () => {
|
||||
let manager: EntityHandleManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new EntityHandleManager();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该创建有效的句柄', () => {
|
||||
const handle = manager.create();
|
||||
expect(isValidHandle(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该创建不同的句柄', () => {
|
||||
const handle1 = manager.create();
|
||||
const handle2 = manager.create();
|
||||
expect(handleEquals(handle1, handle2)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该递增 index', () => {
|
||||
const handle1 = manager.create();
|
||||
const handle2 = manager.create();
|
||||
expect(indexOf(handle2)).toBe(indexOf(handle1) + 1);
|
||||
});
|
||||
|
||||
it('创建的句柄应该是存活的', () => {
|
||||
const handle = manager.create();
|
||||
expect(manager.isAlive(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('创建的句柄默认应该是启用的', () => {
|
||||
const handle = manager.create();
|
||||
expect(manager.isEnabled(handle)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('应该销毁句柄', () => {
|
||||
const handle = manager.create();
|
||||
expect(manager.isAlive(handle)).toBe(true);
|
||||
|
||||
manager.destroy(handle);
|
||||
expect(manager.isAlive(handle)).toBe(false);
|
||||
});
|
||||
|
||||
it('销毁后使用相同 index 应该增加 generation', () => {
|
||||
const handle1 = manager.create();
|
||||
const index1 = indexOf(handle1);
|
||||
|
||||
manager.destroy(handle1);
|
||||
const handle2 = manager.create();
|
||||
const index2 = indexOf(handle2);
|
||||
|
||||
// 应该复用同一个 index
|
||||
expect(index2).toBe(index1);
|
||||
// 但 generation 应该不同
|
||||
expect(genOf(handle2)).toBe(genOf(handle1) + 1);
|
||||
});
|
||||
|
||||
it('销毁已销毁的句柄不应该报错', () => {
|
||||
const handle = manager.create();
|
||||
manager.destroy(handle);
|
||||
|
||||
expect(() => {
|
||||
manager.destroy(handle);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('销毁 NULL_HANDLE 不应该报错', () => {
|
||||
expect(() => {
|
||||
manager.destroy(NULL_HANDLE);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlive', () => {
|
||||
it('应该对存活句柄返回 true', () => {
|
||||
const handle = manager.create();
|
||||
expect(manager.isAlive(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对已销毁句柄返回 false', () => {
|
||||
const handle = manager.create();
|
||||
manager.destroy(handle);
|
||||
expect(manager.isAlive(handle)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对 NULL_HANDLE 返回 false', () => {
|
||||
expect(manager.isAlive(NULL_HANDLE)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对过期 generation 返回 false', () => {
|
||||
const handle1 = manager.create();
|
||||
manager.destroy(handle1);
|
||||
const handle2 = manager.create();
|
||||
|
||||
// handle1 的 generation 已过期
|
||||
expect(manager.isAlive(handle1)).toBe(false);
|
||||
expect(manager.isAlive(handle2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled/setEnabled', () => {
|
||||
it('新创建的句柄默认启用', () => {
|
||||
const handle = manager.create();
|
||||
expect(manager.isEnabled(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该能够禁用句柄', () => {
|
||||
const handle = manager.create();
|
||||
manager.setEnabled(handle, false);
|
||||
expect(manager.isEnabled(handle)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该能够重新启用句柄', () => {
|
||||
const handle = manager.create();
|
||||
manager.setEnabled(handle, false);
|
||||
manager.setEnabled(handle, true);
|
||||
expect(manager.isEnabled(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it('对已销毁句柄设置启用状态不应该报错', () => {
|
||||
const handle = manager.create();
|
||||
manager.destroy(handle);
|
||||
|
||||
expect(() => {
|
||||
manager.setEnabled(handle, true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('已销毁句柄应该返回未启用', () => {
|
||||
const handle = manager.create();
|
||||
manager.destroy(handle);
|
||||
expect(manager.isEnabled(handle)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aliveCount', () => {
|
||||
it('初始时应该为 0', () => {
|
||||
expect(manager.aliveCount).toBe(0);
|
||||
});
|
||||
|
||||
it('创建句柄后应该增加', () => {
|
||||
manager.create();
|
||||
expect(manager.aliveCount).toBe(1);
|
||||
|
||||
manager.create();
|
||||
expect(manager.aliveCount).toBe(2);
|
||||
});
|
||||
|
||||
it('销毁句柄后应该减少', () => {
|
||||
const handle1 = manager.create();
|
||||
const handle2 = manager.create();
|
||||
expect(manager.aliveCount).toBe(2);
|
||||
|
||||
manager.destroy(handle1);
|
||||
expect(manager.aliveCount).toBe(1);
|
||||
|
||||
manager.destroy(handle2);
|
||||
expect(manager.aliveCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('应该重置所有状态', () => {
|
||||
const handle1 = manager.create();
|
||||
const handle2 = manager.create();
|
||||
manager.destroy(handle1);
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(manager.aliveCount).toBe(0);
|
||||
expect(manager.isAlive(handle2)).toBe(false);
|
||||
});
|
||||
|
||||
it('重置后应该能重新创建句柄', () => {
|
||||
manager.create();
|
||||
manager.create();
|
||||
manager.reset();
|
||||
|
||||
const newHandle = manager.create();
|
||||
expect(isValidHandle(newHandle)).toBe(true);
|
||||
expect(manager.isAlive(newHandle)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('大规模测试', () => {
|
||||
it('应该能处理大量句柄', () => {
|
||||
const handles: EntityHandle[] = [];
|
||||
const count = 10000;
|
||||
|
||||
// 创建大量句柄
|
||||
for (let i = 0; i < count; i++) {
|
||||
handles.push(manager.create());
|
||||
}
|
||||
|
||||
expect(manager.aliveCount).toBe(count);
|
||||
|
||||
// 验证所有句柄都是存活的
|
||||
for (const handle of handles) {
|
||||
expect(manager.isAlive(handle)).toBe(true);
|
||||
}
|
||||
|
||||
// 销毁一半
|
||||
for (let i = 0; i < count / 2; i++) {
|
||||
manager.destroy(handles[i]!);
|
||||
}
|
||||
|
||||
expect(manager.aliveCount).toBe(count / 2);
|
||||
});
|
||||
|
||||
it('应该正确复用已销毁的 index', () => {
|
||||
// 创建并销毁
|
||||
const handle1 = manager.create();
|
||||
manager.destroy(handle1);
|
||||
|
||||
// 再次创建
|
||||
const handle2 = manager.create();
|
||||
|
||||
// 应该复用 index
|
||||
expect(indexOf(handle2)).toBe(indexOf(handle1));
|
||||
// 但 generation 增加
|
||||
expect(genOf(handle2)).toBe(genOf(handle1) + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/core/tests/ECS/Core/EpochManager.test.ts
Normal file
72
packages/core/tests/ECS/Core/EpochManager.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { EpochManager } from '../../../src/ECS/Core/EpochManager';
|
||||
|
||||
describe('EpochManager', () => {
|
||||
let epochManager: EpochManager;
|
||||
|
||||
beforeEach(() => {
|
||||
epochManager = new EpochManager();
|
||||
});
|
||||
|
||||
describe('初始状态', () => {
|
||||
it('初始 epoch 应该是 1', () => {
|
||||
expect(epochManager.current).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('increment', () => {
|
||||
it('应该递增 epoch', () => {
|
||||
const initial = epochManager.current;
|
||||
epochManager.increment();
|
||||
expect(epochManager.current).toBe(initial + 1);
|
||||
});
|
||||
|
||||
it('应该正确递增多次', () => {
|
||||
const initial = epochManager.current;
|
||||
epochManager.increment();
|
||||
epochManager.increment();
|
||||
epochManager.increment();
|
||||
expect(epochManager.current).toBe(initial + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('应该重置 epoch 到 1', () => {
|
||||
epochManager.increment();
|
||||
epochManager.increment();
|
||||
expect(epochManager.current).toBeGreaterThan(1);
|
||||
|
||||
epochManager.reset();
|
||||
expect(epochManager.current).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('current getter', () => {
|
||||
it('应该返回当前 epoch', () => {
|
||||
expect(epochManager.current).toBe(1);
|
||||
epochManager.increment();
|
||||
expect(epochManager.current).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('使用场景', () => {
|
||||
it('可以用于追踪帧数', () => {
|
||||
// 模拟 10 帧
|
||||
for (let i = 0; i < 10; i++) {
|
||||
epochManager.increment();
|
||||
}
|
||||
expect(epochManager.current).toBe(11); // 初始 1 + 10 帧
|
||||
});
|
||||
|
||||
it('可以用于变更检测', () => {
|
||||
// 保存检查点
|
||||
const checkpoint = epochManager.current;
|
||||
|
||||
// 模拟几帧
|
||||
epochManager.increment();
|
||||
epochManager.increment();
|
||||
|
||||
// 当前 epoch 应该大于检查点
|
||||
expect(epochManager.current).toBeGreaterThan(checkpoint);
|
||||
});
|
||||
});
|
||||
});
|
||||
555
packages/core/tests/ECS/Core/SystemScheduler.test.ts
Normal file
555
packages/core/tests/ECS/Core/SystemScheduler.test.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
SystemScheduler,
|
||||
CycleDependencyError,
|
||||
DEFAULT_STAGE_ORDER,
|
||||
SystemStage
|
||||
} from '../../../src/ECS/Core/SystemScheduler';
|
||||
import { SystemDependencyGraph } from '../../../src/ECS/Core/SystemDependencyGraph';
|
||||
import { EntitySystem } from '../../../src/ECS/Systems/EntitySystem';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import { Matcher } from '../../../src/ECS/Utils/Matcher';
|
||||
import { Entity } from '../../../src/ECS/Entity';
|
||||
import { ECSSystem, Stage, Before, After, InSet } from '../../../src/ECS/Decorators';
|
||||
|
||||
// 测试系统
|
||||
@ECSSystem('TestSystemA')
|
||||
class TestSystemA extends EntitySystem {
|
||||
public executionOrder: number = 0;
|
||||
public static executionCounter = 0;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
this.executionOrder = ++TestSystemA.executionCounter;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('TestSystemB')
|
||||
class TestSystemB extends EntitySystem {
|
||||
public executionOrder: number = 0;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
this.executionOrder = ++TestSystemA.executionCounter;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('TestSystemC')
|
||||
class TestSystemC extends EntitySystem {
|
||||
public executionOrder: number = 0;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
this.executionOrder = ++TestSystemA.executionCounter;
|
||||
}
|
||||
}
|
||||
|
||||
describe('SystemDependencyGraph', () => {
|
||||
let graph: SystemDependencyGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = new SystemDependencyGraph();
|
||||
});
|
||||
|
||||
describe('addSystemNode', () => {
|
||||
it('应该添加节点', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
expect(graph.size).toBe(1);
|
||||
});
|
||||
|
||||
it('应该支持添加多个节点', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
expect(graph.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSetNode', () => {
|
||||
it('应该添加虚拟集合节点', () => {
|
||||
graph.addSetNode('CoreSystems');
|
||||
expect(graph.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEdge', () => {
|
||||
it('应该添加边', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
graph.addEdge('SystemA', 'SystemB');
|
||||
|
||||
// 拓扑排序后 A 应该在 B 之前
|
||||
const sorted = graph.topologicalSort();
|
||||
expect(sorted.indexOf('SystemA')).toBeLessThan(sorted.indexOf('SystemB'));
|
||||
});
|
||||
|
||||
it('应该忽略自引用边', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addEdge('SystemA', 'SystemA');
|
||||
|
||||
// 不应该产生循环
|
||||
const sorted = graph.topologicalSort();
|
||||
expect(sorted).toContain('SystemA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topologicalSort', () => {
|
||||
it('应该返回正确的拓扑顺序', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
graph.addSystemNode('SystemC');
|
||||
|
||||
// A -> B -> C
|
||||
graph.addEdge('SystemA', 'SystemB');
|
||||
graph.addEdge('SystemB', 'SystemC');
|
||||
|
||||
const sorted = graph.topologicalSort();
|
||||
|
||||
expect(sorted.indexOf('SystemA')).toBeLessThan(sorted.indexOf('SystemB'));
|
||||
expect(sorted.indexOf('SystemB')).toBeLessThan(sorted.indexOf('SystemC'));
|
||||
});
|
||||
|
||||
it('应该检测循环依赖', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
|
||||
// A -> B -> A (循环)
|
||||
graph.addEdge('SystemA', 'SystemB');
|
||||
graph.addEdge('SystemB', 'SystemA');
|
||||
|
||||
expect(() => {
|
||||
graph.topologicalSort();
|
||||
}).toThrow(CycleDependencyError);
|
||||
});
|
||||
|
||||
it('应该检测多节点循环依赖', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
graph.addSystemNode('SystemC');
|
||||
|
||||
// A -> B -> C -> A (循环)
|
||||
graph.addEdge('SystemA', 'SystemB');
|
||||
graph.addEdge('SystemB', 'SystemC');
|
||||
graph.addEdge('SystemC', 'SystemA');
|
||||
|
||||
expect(() => {
|
||||
graph.topologicalSort();
|
||||
}).toThrow(CycleDependencyError);
|
||||
});
|
||||
|
||||
it('应该处理没有依赖的系统', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
|
||||
// 没有边,两个独立系统
|
||||
const sorted = graph.topologicalSort();
|
||||
|
||||
expect(sorted).toHaveLength(2);
|
||||
expect(sorted).toContain('SystemA');
|
||||
expect(sorted).toContain('SystemB');
|
||||
});
|
||||
|
||||
it('不应该包含虚拟节点在结果中', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSetNode('CoreSystems');
|
||||
|
||||
const sorted = graph.topologicalSort();
|
||||
|
||||
expect(sorted).toContain('SystemA');
|
||||
expect(sorted).not.toContain('set:CoreSystems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFromSystems', () => {
|
||||
it('应该从系统依赖信息构建图', () => {
|
||||
graph.buildFromSystems([
|
||||
{ name: 'SystemA', before: ['SystemB'], after: [], sets: [] },
|
||||
{ name: 'SystemB', before: [], after: [], sets: [] }
|
||||
]);
|
||||
|
||||
const sorted = graph.topologicalSort();
|
||||
expect(sorted.indexOf('SystemA')).toBeLessThan(sorted.indexOf('SystemB'));
|
||||
});
|
||||
|
||||
it('应该处理 after 依赖', () => {
|
||||
graph.buildFromSystems([
|
||||
{ name: 'SystemA', before: [], after: [], sets: [] },
|
||||
{ name: 'SystemB', before: [], after: ['SystemA'], sets: [] }
|
||||
]);
|
||||
|
||||
const sorted = graph.topologicalSort();
|
||||
expect(sorted.indexOf('SystemA')).toBeLessThan(sorted.indexOf('SystemB'));
|
||||
});
|
||||
|
||||
it('应该处理 set 依赖', () => {
|
||||
graph.buildFromSystems([
|
||||
{ name: 'SystemA', before: [], after: [], sets: ['CoreSystems'] },
|
||||
{ name: 'SystemB', before: [], after: [], sets: ['CoreSystems'] }
|
||||
]);
|
||||
|
||||
const sorted = graph.topologicalSort();
|
||||
expect(sorted).toHaveLength(2);
|
||||
expect(sorted).toContain('SystemA');
|
||||
expect(sorted).toContain('SystemB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('应该清除所有节点和边', () => {
|
||||
graph.addSystemNode('SystemA');
|
||||
graph.addSystemNode('SystemB');
|
||||
graph.addEdge('SystemA', 'SystemB');
|
||||
|
||||
graph.clear();
|
||||
|
||||
expect(graph.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SystemScheduler', () => {
|
||||
let scheduler: SystemScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new SystemScheduler();
|
||||
TestSystemA.executionCounter = 0;
|
||||
});
|
||||
|
||||
describe('DEFAULT_STAGE_ORDER', () => {
|
||||
it('应该包含所有阶段', () => {
|
||||
expect(DEFAULT_STAGE_ORDER).toContain('startup');
|
||||
expect(DEFAULT_STAGE_ORDER).toContain('preUpdate');
|
||||
expect(DEFAULT_STAGE_ORDER).toContain('update');
|
||||
expect(DEFAULT_STAGE_ORDER).toContain('postUpdate');
|
||||
expect(DEFAULT_STAGE_ORDER).toContain('cleanup');
|
||||
});
|
||||
|
||||
it('阶段顺序应该正确', () => {
|
||||
expect(DEFAULT_STAGE_ORDER.indexOf('startup')).toBeLessThan(
|
||||
DEFAULT_STAGE_ORDER.indexOf('preUpdate')
|
||||
);
|
||||
expect(DEFAULT_STAGE_ORDER.indexOf('preUpdate')).toBeLessThan(
|
||||
DEFAULT_STAGE_ORDER.indexOf('update')
|
||||
);
|
||||
expect(DEFAULT_STAGE_ORDER.indexOf('update')).toBeLessThan(
|
||||
DEFAULT_STAGE_ORDER.indexOf('postUpdate')
|
||||
);
|
||||
expect(DEFAULT_STAGE_ORDER.indexOf('postUpdate')).toBeLessThan(
|
||||
DEFAULT_STAGE_ORDER.indexOf('cleanup')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedSystems', () => {
|
||||
it('应该返回排序后的系统', () => {
|
||||
const systemA = new TestSystemA();
|
||||
const systemB = new TestSystemB();
|
||||
|
||||
const systems = scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
expect(systems.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('应该按 updateOrder 排序', () => {
|
||||
const systemA = new TestSystemA();
|
||||
const systemB = new TestSystemB();
|
||||
systemA.updateOrder = 10;
|
||||
systemB.updateOrder = 5;
|
||||
|
||||
const systems = scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
|
||||
// B 的 updateOrder 更小,应该在 A 之前
|
||||
expect(systems.indexOf(systemB)).toBeLessThan(systems.indexOf(systemA));
|
||||
});
|
||||
|
||||
it('应该按依赖关系排序', () => {
|
||||
// 创建带有依赖关系的系统
|
||||
@ECSSystem('DepSystemA')
|
||||
@Stage('update')
|
||||
class DepSystemA extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('DepSystemB')
|
||||
@Stage('update')
|
||||
@After('DepSystemA')
|
||||
class DepSystemB extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const systemA = new DepSystemA();
|
||||
const systemB = new DepSystemB();
|
||||
|
||||
const systems = scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
const indexA = systems.indexOf(systemA);
|
||||
const indexB = systems.indexOf(systemB);
|
||||
|
||||
expect(indexA).toBeLessThan(indexB);
|
||||
});
|
||||
|
||||
it('应该返回指定阶段的系统', () => {
|
||||
@ECSSystem('PreUpdateSystem')
|
||||
@Stage('preUpdate')
|
||||
class PreUpdateSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('UpdateSystem')
|
||||
@Stage('update')
|
||||
class UpdateSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const preSystem = new PreUpdateSystem();
|
||||
const updateSystem = new UpdateSystem();
|
||||
|
||||
const preSystems = scheduler.getSortedSystems([preSystem, updateSystem], 'preUpdate');
|
||||
const updateSystems = scheduler.getSortedSystems([preSystem, updateSystem], 'update');
|
||||
|
||||
expect(preSystems).toContain(preSystem);
|
||||
expect(preSystems).not.toContain(updateSystem);
|
||||
|
||||
expect(updateSystems).toContain(updateSystem);
|
||||
expect(updateSystems).not.toContain(preSystem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllSortedSystems', () => {
|
||||
it('应该返回所有阶段的系统', () => {
|
||||
@ECSSystem('AllPreSystem')
|
||||
@Stage('preUpdate')
|
||||
class AllPreSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('AllUpdateSystem')
|
||||
@Stage('update')
|
||||
class AllUpdateSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const preSystem = new AllPreSystem();
|
||||
const updateSystem = new AllUpdateSystem();
|
||||
|
||||
const allSystems = scheduler.getAllSortedSystems([preSystem, updateSystem]);
|
||||
|
||||
expect(allSystems).toContain(preSystem);
|
||||
expect(allSystems).toContain(updateSystem);
|
||||
// preUpdate 阶段在 update 之前
|
||||
expect(allSystems.indexOf(preSystem)).toBeLessThan(allSystems.indexOf(updateSystem));
|
||||
});
|
||||
});
|
||||
|
||||
describe('markDirty', () => {
|
||||
it('调用 markDirty 后应该重新排序', () => {
|
||||
const systemA = new TestSystemA();
|
||||
const systemB = new TestSystemB();
|
||||
|
||||
// 第一次排序
|
||||
scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
|
||||
// 标记脏
|
||||
scheduler.markDirty();
|
||||
|
||||
// 应该重新排序而不出错
|
||||
const systems = scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
expect(systems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUseDependencySort', () => {
|
||||
it('禁用依赖排序后应该只使用 updateOrder', () => {
|
||||
@ECSSystem('DisabledDepA')
|
||||
@Stage('update')
|
||||
class DisabledDepA extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('DisabledDepB')
|
||||
@Stage('update')
|
||||
@Before('DisabledDepA')
|
||||
class DisabledDepB extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const systemA = new DisabledDepA();
|
||||
const systemB = new DisabledDepB();
|
||||
systemA.updateOrder = 1;
|
||||
systemB.updateOrder = 2;
|
||||
|
||||
scheduler.setUseDependencySort(false);
|
||||
|
||||
const systems = scheduler.getSortedSystems([systemA, systemB], 'update');
|
||||
// 禁用依赖排序后,按 updateOrder 排序
|
||||
expect(systems.indexOf(systemA)).toBeLessThan(systems.indexOf(systemB));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('调度装饰器', () => {
|
||||
describe('@Stage', () => {
|
||||
it('应该设置系统的执行阶段', () => {
|
||||
@ECSSystem('StageTestSystem')
|
||||
@Stage('postUpdate')
|
||||
class StageTestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new StageTestSystem();
|
||||
expect(system.getStage()).toBe('postUpdate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('@Before', () => {
|
||||
it('应该设置系统的前置依赖', () => {
|
||||
@ECSSystem('BeforeTestSystem')
|
||||
@Before('OtherSystem')
|
||||
class BeforeTestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new BeforeTestSystem();
|
||||
expect(system.getBefore()).toContain('OtherSystem');
|
||||
});
|
||||
|
||||
it('应该支持多个前置依赖', () => {
|
||||
@ECSSystem('MultiBeforeSystem')
|
||||
@Before('SystemA', 'SystemB')
|
||||
class MultiBeforeSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new MultiBeforeSystem();
|
||||
expect(system.getBefore()).toContain('SystemA');
|
||||
expect(system.getBefore()).toContain('SystemB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('@After', () => {
|
||||
it('应该设置系统的后置依赖', () => {
|
||||
@ECSSystem('AfterTestSystem')
|
||||
@After('OtherSystem')
|
||||
class AfterTestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new AfterTestSystem();
|
||||
expect(system.getAfter()).toContain('OtherSystem');
|
||||
});
|
||||
});
|
||||
|
||||
describe('@InSet', () => {
|
||||
it('应该设置系统所属的集合', () => {
|
||||
@ECSSystem('InSetTestSystem')
|
||||
@InSet('CoreSystems')
|
||||
class InSetTestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new InSetTestSystem();
|
||||
expect(system.getSets()).toContain('CoreSystems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('组合使用', () => {
|
||||
it('应该支持组合多个装饰器', () => {
|
||||
@ECSSystem('CombinedSystem')
|
||||
@Stage('update')
|
||||
@After('InputSystem')
|
||||
@Before('RenderSystem')
|
||||
@InSet('CoreSystems')
|
||||
class CombinedSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
}
|
||||
|
||||
const system = new CombinedSystem();
|
||||
expect(system.getStage()).toBe('update');
|
||||
expect(system.getAfter()).toContain('InputSystem');
|
||||
expect(system.getBefore()).toContain('RenderSystem');
|
||||
expect(system.getSets()).toContain('CoreSystems');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fluent API 调度配置', () => {
|
||||
it('应该支持 stage() 方法', () => {
|
||||
@ECSSystem('FluentStageSystem')
|
||||
class FluentStageSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
this.stage('postUpdate');
|
||||
}
|
||||
}
|
||||
|
||||
const system = new FluentStageSystem();
|
||||
expect(system.getStage()).toBe('postUpdate');
|
||||
});
|
||||
|
||||
it('应该支持链式调用', () => {
|
||||
@ECSSystem('FluentChainSystem')
|
||||
class FluentChainSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
this.stage('update')
|
||||
.after('SystemA')
|
||||
.before('SystemB')
|
||||
.inSet('CoreSystems');
|
||||
}
|
||||
}
|
||||
|
||||
const system = new FluentChainSystem();
|
||||
expect(system.getStage()).toBe('update');
|
||||
expect(system.getAfter()).toContain('SystemA');
|
||||
expect(system.getBefore()).toContain('SystemB');
|
||||
expect(system.getSets()).toContain('CoreSystems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CycleDependencyError', () => {
|
||||
it('应该包含循环节点信息', () => {
|
||||
const error = new CycleDependencyError(['SystemA', 'SystemB', 'SystemA']);
|
||||
expect(error.message).toContain('SystemA');
|
||||
expect(error.message).toContain('SystemB');
|
||||
expect(error.involvedNodes).toEqual(['SystemA', 'SystemB', 'SystemA']);
|
||||
});
|
||||
|
||||
it('应该是 Error 的实例', () => {
|
||||
const error = new CycleDependencyError(['SystemA']);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('CycleDependencyError');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import { EntitySystem } from '../../../src/ECS/Systems/EntitySystem';
|
||||
import { Entity } from '../../../src/ECS/Entity';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import { Matcher } from '../../../src/ECS/Utils/Matcher';
|
||||
import { ECSComponent, ECSSystem } from '../../../src/ECS/Decorators';
|
||||
|
||||
// 测试组件
|
||||
@ECSComponent('ChangeDetect_Position')
|
||||
class PositionComponent extends Component {
|
||||
private _x: number;
|
||||
private _y: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [x = 0, y = 0] = args as [number?, number?];
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
}
|
||||
|
||||
public get x(): number {
|
||||
return this._x;
|
||||
}
|
||||
|
||||
public set x(value: number) {
|
||||
this._x = value;
|
||||
// 实际使用中需要通过 entity.scene.epochManager.current 获取
|
||||
// 这里简化测试,手动调用 markDirty
|
||||
}
|
||||
|
||||
public get y(): number {
|
||||
return this._y;
|
||||
}
|
||||
|
||||
public set y(value: number) {
|
||||
this._y = value;
|
||||
}
|
||||
|
||||
public setPosition(x: number, y: number, epoch: number): void {
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
this.markDirty(epoch);
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('ChangeDetect_Velocity')
|
||||
class VelocityComponent extends Component {
|
||||
private _vx: number;
|
||||
private _vy: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [vx = 0, vy = 0] = args as [number?, number?];
|
||||
this._vx = vx;
|
||||
this._vy = vy;
|
||||
}
|
||||
|
||||
public get vx(): number {
|
||||
return this._vx;
|
||||
}
|
||||
|
||||
public get vy(): number {
|
||||
return this._vy;
|
||||
}
|
||||
|
||||
public setVelocity(vx: number, vy: number, epoch: number): void {
|
||||
this._vx = vx;
|
||||
this._vy = vy;
|
||||
this.markDirty(epoch);
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('ChangeDetect_Health')
|
||||
class HealthComponent extends Component {
|
||||
public current: number;
|
||||
public max: number;
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
super();
|
||||
const [current = 100, max = 100] = args as [number?, number?];
|
||||
this.current = current;
|
||||
this.max = max;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试系统 - 暴露 protected 方法供测试
|
||||
@ECSSystem('ChangeDetectionTestSystem')
|
||||
class ChangeDetectionTestSystem extends EntitySystem {
|
||||
public processedEntities: Entity[] = [];
|
||||
public changedEntities: Entity[] = [];
|
||||
|
||||
constructor() {
|
||||
super(Matcher.all(PositionComponent, VelocityComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
this.processedEntities = [...entities];
|
||||
}
|
||||
|
||||
// 暴露 protected 方法供测试
|
||||
public testForEachChanged(
|
||||
entities: readonly Entity[],
|
||||
componentTypes: any[],
|
||||
processor: (entity: Entity, index: number) => void,
|
||||
sinceEpoch?: number
|
||||
): void {
|
||||
this.forEachChanged(entities, componentTypes, processor, sinceEpoch);
|
||||
}
|
||||
|
||||
public testFilterChanged(
|
||||
entities: readonly Entity[],
|
||||
componentTypes: any[],
|
||||
sinceEpoch?: number
|
||||
): Entity[] {
|
||||
return this.filterChanged(entities, componentTypes, sinceEpoch);
|
||||
}
|
||||
|
||||
public testHasChanged(
|
||||
entity: Entity,
|
||||
componentTypes: any[],
|
||||
sinceEpoch?: number
|
||||
): boolean {
|
||||
return this.hasChanged(entity, componentTypes, sinceEpoch);
|
||||
}
|
||||
|
||||
public testSaveEpoch(): void {
|
||||
this.saveEpoch();
|
||||
}
|
||||
|
||||
public testGetLastProcessEpoch(): number {
|
||||
return this.lastProcessEpoch;
|
||||
}
|
||||
|
||||
public testGetCurrentEpoch(): number {
|
||||
return this.currentEpoch;
|
||||
}
|
||||
}
|
||||
|
||||
describe('EntitySystem 变更检测', () => {
|
||||
let scene: Scene;
|
||||
let system: ChangeDetectionTestSystem;
|
||||
let entity1: Entity;
|
||||
let entity2: Entity;
|
||||
let entity3: Entity;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
system = new ChangeDetectionTestSystem();
|
||||
scene.addSystem(system);
|
||||
|
||||
// 创建测试实体
|
||||
entity1 = scene.createEntity('entity1');
|
||||
entity1.addComponent(new PositionComponent(10, 20));
|
||||
entity1.addComponent(new VelocityComponent(1, 2));
|
||||
|
||||
entity2 = scene.createEntity('entity2');
|
||||
entity2.addComponent(new PositionComponent(30, 40));
|
||||
entity2.addComponent(new VelocityComponent(3, 4));
|
||||
|
||||
entity3 = scene.createEntity('entity3');
|
||||
entity3.addComponent(new PositionComponent(50, 60));
|
||||
entity3.addComponent(new VelocityComponent(5, 6));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scene.removeSystem(system);
|
||||
scene.end();
|
||||
});
|
||||
|
||||
describe('lastProcessEpoch', () => {
|
||||
it('初始值应该是 0', () => {
|
||||
expect(system.testGetLastProcessEpoch()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentEpoch', () => {
|
||||
it('应该返回场景的当前 epoch', () => {
|
||||
expect(system.testGetCurrentEpoch()).toBe(scene.epochManager.current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveEpoch', () => {
|
||||
it('应该保存当前 epoch', () => {
|
||||
const currentEpoch = scene.epochManager.current;
|
||||
system.testSaveEpoch();
|
||||
expect(system.testGetLastProcessEpoch()).toBe(currentEpoch);
|
||||
});
|
||||
|
||||
it('递增 epoch 后 saveEpoch 应该保存新值', () => {
|
||||
scene.epochManager.increment();
|
||||
scene.epochManager.increment();
|
||||
const currentEpoch = scene.epochManager.current;
|
||||
|
||||
system.testSaveEpoch();
|
||||
expect(system.testGetLastProcessEpoch()).toBe(currentEpoch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanged', () => {
|
||||
it('新组件应该被检测为已变更', () => {
|
||||
// 组件的 lastWriteEpoch 默认是 0,如果检查点也是 0,则不算变更
|
||||
// 需要先保存 epoch,然后修改组件
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
const pos = entity1.getComponent(PositionComponent)!;
|
||||
pos.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
expect(system.testHasChanged(entity1, [PositionComponent])).toBe(true);
|
||||
});
|
||||
|
||||
it('未修改的组件应该不被检测为变更', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// entity2 的组件未被修改
|
||||
expect(system.testHasChanged(entity2, [PositionComponent])).toBe(false);
|
||||
});
|
||||
|
||||
it('应该检查多个组件类型', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 只修改 Velocity
|
||||
const vel = entity1.getComponent(VelocityComponent)!;
|
||||
vel.setVelocity(10, 20, scene.epochManager.current);
|
||||
|
||||
// 检查 Position 和 Velocity,应该检测到变更
|
||||
expect(system.testHasChanged(entity1, [PositionComponent, VelocityComponent])).toBe(true);
|
||||
});
|
||||
|
||||
it('指定 sinceEpoch 参数应该使用该值作为检查点', () => {
|
||||
const pos = entity1.getComponent(PositionComponent)!;
|
||||
|
||||
// 在 epoch 5 修改组件
|
||||
scene.epochManager.increment(); // 2
|
||||
scene.epochManager.increment(); // 3
|
||||
scene.epochManager.increment(); // 4
|
||||
scene.epochManager.increment(); // 5
|
||||
pos.setPosition(100, 200, 5);
|
||||
|
||||
// 检查 epoch 4 之后的变更 - 应该检测到
|
||||
expect(system.testHasChanged(entity1, [PositionComponent], 4)).toBe(true);
|
||||
|
||||
// 检查 epoch 5 之后的变更 - 不应该检测到
|
||||
expect(system.testHasChanged(entity1, [PositionComponent], 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterChanged', () => {
|
||||
it('应该返回有变更的实体', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 只修改 entity1
|
||||
const pos1 = entity1.getComponent(PositionComponent)!;
|
||||
pos1.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
const entities = [entity1, entity2, entity3];
|
||||
const changed = system.testFilterChanged(entities, [PositionComponent]);
|
||||
|
||||
expect(changed).toHaveLength(1);
|
||||
expect(changed[0]).toBe(entity1);
|
||||
});
|
||||
|
||||
it('应该返回空数组当没有变更时', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 没有修改任何组件
|
||||
const entities = [entity1, entity2, entity3];
|
||||
const changed = system.testFilterChanged(entities, [PositionComponent]);
|
||||
|
||||
expect(changed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该返回所有变更的实体', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 修改 entity1 和 entity3
|
||||
const pos1 = entity1.getComponent(PositionComponent)!;
|
||||
pos1.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
const pos3 = entity3.getComponent(PositionComponent)!;
|
||||
pos3.setPosition(500, 600, scene.epochManager.current);
|
||||
|
||||
const entities = [entity1, entity2, entity3];
|
||||
const changed = system.testFilterChanged(entities, [PositionComponent]);
|
||||
|
||||
expect(changed).toHaveLength(2);
|
||||
expect(changed).toContain(entity1);
|
||||
expect(changed).toContain(entity3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEachChanged', () => {
|
||||
it('应该只遍历有变更的实体', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 只修改 entity2
|
||||
const pos2 = entity2.getComponent(PositionComponent)!;
|
||||
pos2.setPosition(300, 400, scene.epochManager.current);
|
||||
|
||||
const entities = [entity1, entity2, entity3];
|
||||
const processed: Entity[] = [];
|
||||
|
||||
system.testForEachChanged(entities, [PositionComponent], (entity) => {
|
||||
processed.push(entity);
|
||||
});
|
||||
|
||||
expect(processed).toHaveLength(1);
|
||||
expect(processed[0]).toBe(entity2);
|
||||
});
|
||||
|
||||
it('应该自动更新 lastProcessEpoch', () => {
|
||||
system.testSaveEpoch();
|
||||
const savedEpoch = system.testGetLastProcessEpoch();
|
||||
|
||||
scene.epochManager.increment();
|
||||
const currentEpoch = scene.epochManager.current;
|
||||
|
||||
const entities = [entity1, entity2];
|
||||
system.testForEachChanged(entities, [PositionComponent], () => {});
|
||||
|
||||
// forEachChanged 应该更新 lastProcessEpoch
|
||||
expect(system.testGetLastProcessEpoch()).toBe(currentEpoch);
|
||||
expect(system.testGetLastProcessEpoch()).toBeGreaterThan(savedEpoch);
|
||||
});
|
||||
|
||||
it('指定 sinceEpoch 时不应该影响自动更新', () => {
|
||||
scene.epochManager.increment();
|
||||
scene.epochManager.increment();
|
||||
|
||||
const entities = [entity1];
|
||||
system.testForEachChanged(entities, [PositionComponent], () => {}, 0);
|
||||
|
||||
// 应该更新到当前 epoch
|
||||
expect(system.testGetLastProcessEpoch()).toBe(scene.epochManager.current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('实际使用场景', () => {
|
||||
it('应该支持增量更新模式', () => {
|
||||
// 模拟第一帧
|
||||
scene.update();
|
||||
|
||||
// 保存检查点
|
||||
system.testSaveEpoch();
|
||||
const checkpoint = system.testGetLastProcessEpoch();
|
||||
|
||||
// 模拟第二帧 - 修改一个实体
|
||||
scene.epochManager.increment();
|
||||
const pos1 = entity1.getComponent(PositionComponent)!;
|
||||
pos1.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
// 只处理变更的实体
|
||||
const changed = system.testFilterChanged(system.entities, [PositionComponent]);
|
||||
expect(changed).toHaveLength(1);
|
||||
expect(changed[0]).toBe(entity1);
|
||||
|
||||
// 更新检查点
|
||||
system.testSaveEpoch();
|
||||
|
||||
// 模拟第三帧 - 没有修改
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 不应该有变更
|
||||
const noChanges = system.testFilterChanged(system.entities, [PositionComponent]);
|
||||
expect(noChanges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该正确处理多次变更', () => {
|
||||
system.testSaveEpoch();
|
||||
|
||||
// 第一次变更
|
||||
scene.epochManager.increment();
|
||||
const pos1 = entity1.getComponent(PositionComponent)!;
|
||||
pos1.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
let changed = system.testFilterChanged(system.entities, [PositionComponent]);
|
||||
expect(changed).toContain(entity1);
|
||||
|
||||
// 更新检查点
|
||||
system.testSaveEpoch();
|
||||
|
||||
// 第二次变更 - 不同实体
|
||||
scene.epochManager.increment();
|
||||
const pos2 = entity2.getComponent(PositionComponent)!;
|
||||
pos2.setPosition(300, 400, scene.epochManager.current);
|
||||
|
||||
changed = system.testFilterChanged(system.entities, [PositionComponent]);
|
||||
expect(changed).not.toContain(entity1);
|
||||
expect(changed).toContain(entity2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('空实体列表应该正常处理', () => {
|
||||
const processed: Entity[] = [];
|
||||
system.testForEachChanged([], [PositionComponent], (entity) => {
|
||||
processed.push(entity);
|
||||
});
|
||||
expect(processed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('空组件类型列表应该不检测到任何变更', () => {
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
const pos1 = entity1.getComponent(PositionComponent)!;
|
||||
pos1.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
// 空组件类型列表
|
||||
const changed = system.testFilterChanged(system.entities, []);
|
||||
expect(changed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('实体缺少指定组件时应该跳过', () => {
|
||||
// 创建一个只有 Position 的实体
|
||||
const entityWithoutVelocity = scene.createEntity('noVel');
|
||||
entityWithoutVelocity.addComponent(new PositionComponent(70, 80));
|
||||
|
||||
system.testSaveEpoch();
|
||||
scene.epochManager.increment();
|
||||
|
||||
// 修改该实体的 Position
|
||||
const pos = entityWithoutVelocity.getComponent(PositionComponent)!;
|
||||
pos.setPosition(100, 200, scene.epochManager.current);
|
||||
|
||||
// 检查 Velocity 变更 - 该实体没有 Velocity,应该不被检测
|
||||
const entities = [entityWithoutVelocity];
|
||||
const changed = system.testFilterChanged(entities, [VelocityComponent]);
|
||||
expect(changed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component.markDirty', () => {
|
||||
let scene: Scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scene.end();
|
||||
});
|
||||
|
||||
it('应该更新 lastWriteEpoch', () => {
|
||||
const entity = scene.createEntity('test');
|
||||
const pos = entity.addComponent(new PositionComponent(10, 20));
|
||||
|
||||
expect(pos.lastWriteEpoch).toBe(0);
|
||||
|
||||
pos.markDirty(5);
|
||||
expect(pos.lastWriteEpoch).toBe(5);
|
||||
|
||||
pos.markDirty(10);
|
||||
expect(pos.lastWriteEpoch).toBe(10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user