Feature/editor optimization (#251)

* refactor: 编辑器/运行时架构拆分与构建系统升级

* feat(core): 层级系统重构与UI变换矩阵修复

* refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题

* fix(physics): 修复跨包组件类引用问题

* feat: 统一运行时架构与浏览器运行支持

* feat(asset): 实现浏览器运行时资产加载系统

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误

* test: 补齐核心模块测试用例,修复CI构建配置

* fix: 修复测试用例中的类型错误和断言问题

* fix: 修复 turbo build:npm 任务的依赖顺序问题

* fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -0,0 +1,410 @@
import { EntityDataCollector } from '../../../src/Utils/Debug/EntityDataCollector';
import { Scene } from '../../../src/ECS/Scene';
import { Component } from '../../../src/ECS/Component';
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
import { ECSComponent } from '../../../src/ECS/Decorators';
@ECSComponent('TestPosition')
class PositionComponent extends Component {
public x: number = 0;
public y: number = 0;
constructor(x: number = 0, y: number = 0) {
super();
this.x = x;
this.y = y;
}
}
@ECSComponent('TestVelocity')
class VelocityComponent extends Component {
public vx: number = 0;
public vy: number = 0;
}
describe('EntityDataCollector', () => {
let collector: EntityDataCollector;
let scene: Scene;
beforeEach(() => {
collector = new EntityDataCollector();
scene = new Scene({ name: 'TestScene' });
});
afterEach(() => {
scene.end();
});
describe('collectEntityData', () => {
test('should return empty data when scene is null', () => {
const data = collector.collectEntityData(null);
expect(data.totalEntities).toBe(0);
expect(data.activeEntities).toBe(0);
expect(data.pendingAdd).toBe(0);
expect(data.pendingRemove).toBe(0);
expect(data.entitiesPerArchetype).toEqual([]);
expect(data.topEntitiesByComponents).toEqual([]);
});
test('should return empty data when scene is undefined', () => {
const data = collector.collectEntityData(undefined);
expect(data.totalEntities).toBe(0);
expect(data.activeEntities).toBe(0);
});
test('should collect entity data from scene', () => {
const entity1 = scene.createEntity('Entity1');
entity1.addComponent(new PositionComponent(10, 20));
const entity2 = scene.createEntity('Entity2');
entity2.addComponent(new VelocityComponent());
const data = collector.collectEntityData(scene);
expect(data.totalEntities).toBe(2);
expect(data.activeEntities).toBeGreaterThanOrEqual(0);
});
test('should collect archetype distribution', () => {
const entity1 = scene.createEntity('Entity1');
entity1.addComponent(new PositionComponent());
const entity2 = scene.createEntity('Entity2');
entity2.addComponent(new PositionComponent());
const entity3 = scene.createEntity('Entity3');
entity3.addComponent(new VelocityComponent());
const data = collector.collectEntityData(scene);
expect(data.entitiesPerArchetype.length).toBeGreaterThan(0);
});
});
describe('getRawEntityList', () => {
test('should return empty array when scene is null', () => {
const list = collector.getRawEntityList(null);
expect(list).toEqual([]);
});
test('should return raw entity list from scene', () => {
const entity1 = scene.createEntity('Entity1');
entity1.addComponent(new PositionComponent(10, 20));
entity1.tag = 0x01;
const entity2 = scene.createEntity('Entity2');
entity2.addComponent(new VelocityComponent());
const list = collector.getRawEntityList(scene);
expect(list.length).toBe(2);
expect(list[0].name).toBe('Entity1');
expect(list[0].componentCount).toBe(1);
expect(list[0].tag).toBe(0x01);
});
test('should include hierarchy information', () => {
const hierarchySystem = new HierarchySystem();
scene.addSystem(hierarchySystem);
const parent = scene.createEntity('Parent');
const child = scene.createEntity('Child');
hierarchySystem.setParent(child, parent);
const list = collector.getRawEntityList(scene);
const childInfo = list.find(e => e.name === 'Child');
expect(childInfo).toBeDefined();
expect(childInfo!.parentId).toBe(parent.id);
expect(childInfo!.depth).toBe(1);
});
});
describe('getEntityDetails', () => {
test('should return null when scene is null', () => {
const details = collector.getEntityDetails(1, null);
expect(details).toBeNull();
});
test('should return null when entity not found', () => {
const details = collector.getEntityDetails(9999, scene);
expect(details).toBeNull();
});
test('should return entity details', () => {
const entity = scene.createEntity('TestEntity');
entity.addComponent(new PositionComponent(100, 200));
entity.tag = 42;
const details = collector.getEntityDetails(entity.id, scene);
expect(details).not.toBeNull();
expect(details.componentCount).toBe(1);
expect(details.scene).toBeDefined();
});
test('should handle errors gracefully', () => {
const details = collector.getEntityDetails(-1, scene);
expect(details).toBeNull();
});
});
describe('collectEntityDataWithMemory', () => {
test('should return empty data when scene is null', () => {
const data = collector.collectEntityDataWithMemory(null);
expect(data.totalEntities).toBe(0);
expect(data.entityHierarchy).toEqual([]);
expect(data.entityDetailsMap).toEqual({});
});
test('should collect entity data with memory information', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent(10, 20));
const data = collector.collectEntityDataWithMemory(scene);
expect(data.totalEntities).toBe(1);
expect(data.topEntitiesByComponents.length).toBeGreaterThan(0);
});
test('should include entity details map', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent());
const data = collector.collectEntityDataWithMemory(scene);
expect(data.entityDetailsMap).toBeDefined();
expect(data.entityDetailsMap![entity.id]).toBeDefined();
});
test('should build entity hierarchy tree', () => {
const hierarchySystem = new HierarchySystem();
scene.addSystem(hierarchySystem);
const root = scene.createEntity('Root');
root.addComponent(new HierarchyComponent());
const child = scene.createEntity('Child');
hierarchySystem.setParent(child, root);
const data = collector.collectEntityDataWithMemory(scene);
expect(data.entityHierarchy).toBeDefined();
expect(data.entityHierarchy!.length).toBe(1);
expect(data.entityHierarchy![0].name).toBe('Root');
});
});
describe('estimateEntityMemoryUsage', () => {
test('should estimate memory for entity', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent(10, 20));
const memory = collector.estimateEntityMemoryUsage(entity);
expect(memory).toBeGreaterThanOrEqual(0);
expect(typeof memory).toBe('number');
});
test('should return 0 for invalid entity', () => {
const memory = collector.estimateEntityMemoryUsage(null);
expect(memory).toBe(0);
});
test('should handle errors and return 0', () => {
const badEntity = { components: null };
const memory = collector.estimateEntityMemoryUsage(badEntity);
expect(memory).toBeGreaterThanOrEqual(0);
});
});
describe('calculateObjectSize', () => {
test('should return 0 for null/undefined', () => {
expect(collector.calculateObjectSize(null)).toBe(0);
expect(collector.calculateObjectSize(undefined)).toBe(0);
});
test('should calculate size for simple object', () => {
const obj = { x: 10, y: 20, name: 'test' };
const size = collector.calculateObjectSize(obj);
expect(size).toBeGreaterThan(0);
});
test('should respect exclude keys', () => {
const obj = { x: 10, excluded: 'large string'.repeat(100) };
const sizeWithExclude = collector.calculateObjectSize(obj, ['excluded']);
const sizeWithoutExclude = collector.calculateObjectSize(obj);
expect(sizeWithExclude).toBeLessThan(sizeWithoutExclude);
});
test('should handle nested objects with limited depth', () => {
const obj = {
level1: {
level2: {
level3: {
value: 42
}
}
}
};
const size = collector.calculateObjectSize(obj);
expect(size).toBeGreaterThan(0);
});
});
describe('extractComponentDetails', () => {
test('should extract component details', () => {
const component = new PositionComponent(100, 200);
const details = collector.extractComponentDetails([component]);
expect(details.length).toBe(1);
expect(details[0].typeName).toBe('TestPosition');
expect(details[0].properties.x).toBe(100);
expect(details[0].properties.y).toBe(200);
});
test('should handle empty components array', () => {
const details = collector.extractComponentDetails([]);
expect(details).toEqual([]);
});
test('should skip private properties', () => {
class ComponentWithPrivate extends Component {
public publicValue: number = 1;
private _privateValue: number = 2;
}
const component = new ComponentWithPrivate();
const details = collector.extractComponentDetails([component]);
expect(details[0].properties.publicValue).toBe(1);
expect(details[0].properties._privateValue).toBeUndefined();
});
});
describe('getComponentProperties', () => {
test('should return empty object when scene is null', () => {
const props = collector.getComponentProperties(1, 0, null);
expect(props).toEqual({});
});
test('should return empty object when entity not found', () => {
const props = collector.getComponentProperties(9999, 0, scene);
expect(props).toEqual({});
});
test('should return empty object when component index is out of bounds', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent());
const props = collector.getComponentProperties(entity.id, 99, scene);
expect(props).toEqual({});
});
test('should return component properties', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent(50, 75));
const props = collector.getComponentProperties(entity.id, 0, scene);
expect(props.x).toBe(50);
expect(props.y).toBe(75);
});
});
describe('expandLazyObject', () => {
test('should return null when scene is null', () => {
const result = collector.expandLazyObject(1, 0, 'path', null);
expect(result).toBeNull();
});
test('should return null when entity not found', () => {
const result = collector.expandLazyObject(9999, 0, 'path', scene);
expect(result).toBeNull();
});
test('should return null when component index is out of bounds', () => {
const entity = scene.createEntity('Entity');
entity.addComponent(new PositionComponent());
const result = collector.expandLazyObject(entity.id, 99, '', scene);
expect(result).toBeNull();
});
test('should expand object at path', () => {
class ComponentWithNested extends Component {
public nested = { value: 42 };
}
const entity = scene.createEntity('Entity');
entity.addComponent(new ComponentWithNested());
const result = collector.expandLazyObject(entity.id, 0, 'nested', scene);
expect(result).toBeDefined();
expect(result.value).toBe(42);
});
test('should handle array index in path', () => {
class ComponentWithArray extends Component {
public items = [{ id: 1 }, { id: 2 }];
}
const entity = scene.createEntity('Entity');
entity.addComponent(new ComponentWithArray());
const result = collector.expandLazyObject(entity.id, 0, 'items[1]', scene);
expect(result).toBeDefined();
expect(result.id).toBe(2);
});
});
describe('edge cases', () => {
test('should handle scene without entities buffer', () => {
const mockScene = {
entities: null,
getSystem: () => null
};
const data = collector.collectEntityData(mockScene as any);
expect(data.totalEntities).toBe(0);
});
test('should handle entity with long string properties', () => {
class ComponentWithLongString extends Component {
public longText = 'x'.repeat(300);
}
const entity = scene.createEntity('Entity');
entity.addComponent(new ComponentWithLongString());
const details = collector.extractComponentDetails(entity.components);
expect(details[0].properties.longText).toContain('[长字符串:');
});
test('should handle entity with large arrays', () => {
class ComponentWithLargeArray extends Component {
public items = Array.from({ length: 20 }, (_, i) => i);
}
const entity = scene.createEntity('Entity');
entity.addComponent(new ComponentWithLargeArray());
const details = collector.extractComponentDetails(entity.components);
expect(details[0].properties.items).toBeDefined();
expect(details[0].properties.items._isLazyArray).toBe(true);
expect(details[0].properties.items._arrayLength).toBe(20);
});
});
});

View File

@@ -0,0 +1,417 @@
import { AutoProfiler, Profile, ProfileClass } from '../../../src/Utils/Profiler/AutoProfiler';
import { ProfilerSDK } from '../../../src/Utils/Profiler/ProfilerSDK';
import { ProfileCategory } from '../../../src/Utils/Profiler/ProfilerTypes';
describe('AutoProfiler', () => {
beforeEach(() => {
AutoProfiler.resetInstance();
ProfilerSDK.reset();
ProfilerSDK.setEnabled(true);
});
afterEach(() => {
AutoProfiler.resetInstance();
ProfilerSDK.reset();
});
describe('getInstance', () => {
test('should return singleton instance', () => {
const instance1 = AutoProfiler.getInstance();
const instance2 = AutoProfiler.getInstance();
expect(instance1).toBe(instance2);
});
test('should accept custom config', () => {
const instance = AutoProfiler.getInstance({ minDuration: 1.0 });
expect(instance).toBeDefined();
});
});
describe('resetInstance', () => {
test('should reset the singleton instance', () => {
const instance1 = AutoProfiler.getInstance();
AutoProfiler.resetInstance();
const instance2 = AutoProfiler.getInstance();
expect(instance1).not.toBe(instance2);
});
});
describe('setEnabled', () => {
test('should enable/disable auto profiling', () => {
AutoProfiler.setEnabled(false);
const instance = AutoProfiler.getInstance();
expect(instance).toBeDefined();
AutoProfiler.setEnabled(true);
expect(instance).toBeDefined();
});
});
describe('wrapFunction', () => {
test('should wrap a synchronous function', () => {
ProfilerSDK.beginFrame();
const originalFn = (a: number, b: number) => a + b;
const wrappedFn = AutoProfiler.wrapFunction(originalFn, 'add', ProfileCategory.Custom);
const result = wrappedFn(2, 3);
expect(result).toBe(5);
ProfilerSDK.endFrame();
});
test('should preserve function behavior', () => {
const originalFn = (x: number) => x * 2;
const wrappedFn = AutoProfiler.wrapFunction(originalFn, 'double', ProfileCategory.Script);
ProfilerSDK.beginFrame();
expect(wrappedFn(5)).toBe(10);
expect(wrappedFn(0)).toBe(0);
expect(wrappedFn(-3)).toBe(-6);
ProfilerSDK.endFrame();
});
test('should handle async functions', async () => {
const asyncFn = async (x: number) => {
await new Promise((resolve) => setTimeout(resolve, 1));
return x * 2;
};
const wrappedFn = AutoProfiler.wrapFunction(asyncFn, 'asyncDouble', ProfileCategory.Script);
ProfilerSDK.beginFrame();
const result = await wrappedFn(5);
expect(result).toBe(10);
ProfilerSDK.endFrame();
});
test('should handle function that throws error', () => {
const errorFn = () => {
throw new Error('Test error');
};
const wrappedFn = AutoProfiler.wrapFunction(errorFn, 'errorFn', ProfileCategory.Script);
ProfilerSDK.beginFrame();
expect(() => wrappedFn()).toThrow('Test error');
ProfilerSDK.endFrame();
});
test('should return original function when disabled', () => {
AutoProfiler.setEnabled(false);
const originalFn = (x: number) => x + 1;
const wrappedFn = AutoProfiler.wrapFunction(originalFn, 'increment', ProfileCategory.Script);
expect(wrappedFn(5)).toBe(6);
});
});
describe('wrapInstance', () => {
test('should wrap all methods of an object', () => {
class Calculator {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
}
const calc = new Calculator();
AutoProfiler.wrapInstance(calc, 'Calculator', ProfileCategory.Script);
ProfilerSDK.beginFrame();
expect(calc.add(5, 3)).toBe(8);
expect(calc.subtract(5, 3)).toBe(2);
ProfilerSDK.endFrame();
});
test('should not wrap already wrapped objects', () => {
class MyClass {
getValue(): number {
return 42;
}
}
const obj = new MyClass();
AutoProfiler.wrapInstance(obj, 'MyClass', ProfileCategory.Custom);
AutoProfiler.wrapInstance(obj, 'MyClass', ProfileCategory.Custom);
ProfilerSDK.beginFrame();
expect(obj.getValue()).toBe(42);
ProfilerSDK.endFrame();
});
test('should return object unchanged when disabled', () => {
AutoProfiler.setEnabled(false);
class MyClass {
getValue(): number {
return 42;
}
}
const obj = new MyClass();
const wrapped = AutoProfiler.wrapInstance(obj, 'MyClass', ProfileCategory.Custom);
expect(wrapped).toBe(obj);
});
test('should exclude methods matching exclude patterns', () => {
class MyClass {
getValue(): number {
return 42;
}
_privateMethod(): number {
return 1;
}
getName(): string {
return 'test';
}
isValid(): boolean {
return true;
}
hasData(): boolean {
return true;
}
}
const obj = new MyClass();
AutoProfiler.wrapInstance(obj, 'MyClass', ProfileCategory.Custom);
ProfilerSDK.beginFrame();
expect(obj.getValue()).toBe(42);
expect(obj._privateMethod()).toBe(1);
expect(obj.getName()).toBe('test');
expect(obj.isValid()).toBe(true);
expect(obj.hasData()).toBe(true);
ProfilerSDK.endFrame();
});
});
describe('registerClass', () => {
test('should register a class for auto profiling', () => {
class MySystem {
update(): void {
// Do something
}
}
const RegisteredClass = AutoProfiler.registerClass(MySystem, ProfileCategory.ECS);
ProfilerSDK.beginFrame();
const instance = new RegisteredClass();
instance.update();
ProfilerSDK.endFrame();
expect(instance).toBeInstanceOf(MySystem);
});
test('should accept custom class name', () => {
class MySystem {
process(): number {
return 1;
}
}
const RegisteredClass = AutoProfiler.registerClass(MySystem, ProfileCategory.ECS, 'CustomSystem');
ProfilerSDK.beginFrame();
const instance = new RegisteredClass();
expect(instance.process()).toBe(1);
ProfilerSDK.endFrame();
});
});
describe('sampling profiler', () => {
test('should start and stop sampling', () => {
AutoProfiler.startSampling();
const samples = AutoProfiler.stopSampling();
expect(Array.isArray(samples)).toBe(true);
});
test('should return empty array when sampling was never started', () => {
const samples = AutoProfiler.stopSampling();
expect(samples).toEqual([]);
});
test('should collect samples during execution', async () => {
AutoProfiler.startSampling();
// Do some work
for (let i = 0; i < 100; i++) {
Math.sqrt(i);
}
// Wait a bit for samples to accumulate
await new Promise((resolve) => setTimeout(resolve, 50));
const samples = AutoProfiler.stopSampling();
expect(Array.isArray(samples)).toBe(true);
});
});
describe('dispose', () => {
test('should clean up resources', () => {
const instance = AutoProfiler.getInstance();
instance.startSampling();
instance.dispose();
// After dispose, stopping sampling should return empty array
const samples = instance.stopSampling();
expect(samples).toEqual([]);
});
});
describe('minDuration filtering', () => {
test('should respect minDuration setting', () => {
AutoProfiler.resetInstance();
const instance = AutoProfiler.getInstance({ minDuration: 1000 });
const quickFn = () => 1;
const wrappedFn = instance.wrapFunction(quickFn, 'quickFn', ProfileCategory.Script);
ProfilerSDK.beginFrame();
expect(wrappedFn()).toBe(1);
ProfilerSDK.endFrame();
});
});
});
describe('@Profile decorator', () => {
beforeEach(() => {
ProfilerSDK.reset();
ProfilerSDK.setEnabled(true);
});
afterEach(() => {
ProfilerSDK.reset();
});
test('should profile decorated methods', () => {
class TestClass {
@Profile()
calculate(): number {
return 42;
}
}
const instance = new TestClass();
ProfilerSDK.beginFrame();
const result = instance.calculate();
ProfilerSDK.endFrame();
expect(result).toBe(42);
});
test('should use custom name when provided', () => {
class TestClass {
@Profile('CustomMethodName', ProfileCategory.Physics)
compute(): number {
return 100;
}
}
const instance = new TestClass();
ProfilerSDK.beginFrame();
expect(instance.compute()).toBe(100);
ProfilerSDK.endFrame();
});
test('should handle async methods', async () => {
class TestClass {
@Profile()
async asyncMethod(): Promise<number> {
await new Promise((resolve) => setTimeout(resolve, 1));
return 99;
}
}
const instance = new TestClass();
ProfilerSDK.beginFrame();
const result = await instance.asyncMethod();
ProfilerSDK.endFrame();
expect(result).toBe(99);
});
test('should handle method that throws error', () => {
class TestClass {
@Profile()
throwingMethod(): void {
throw new Error('Decorated error');
}
}
const instance = new TestClass();
ProfilerSDK.beginFrame();
expect(() => instance.throwingMethod()).toThrow('Decorated error');
ProfilerSDK.endFrame();
});
test('should skip profiling when ProfilerSDK is disabled', () => {
ProfilerSDK.setEnabled(false);
class TestClass {
@Profile()
simpleMethod(): number {
return 1;
}
}
const instance = new TestClass();
expect(instance.simpleMethod()).toBe(1);
});
});
describe('@ProfileClass decorator', () => {
beforeEach(() => {
AutoProfiler.resetInstance();
ProfilerSDK.reset();
ProfilerSDK.setEnabled(true);
});
afterEach(() => {
AutoProfiler.resetInstance();
ProfilerSDK.reset();
});
test('should profile all methods of decorated class', () => {
@ProfileClass(ProfileCategory.ECS)
class GameSystem {
update(): void {
// Update logic
}
render(): number {
return 1;
}
}
ProfilerSDK.beginFrame();
const system = new GameSystem();
system.update();
expect(system.render()).toBe(1);
ProfilerSDK.endFrame();
});
test('should use default category when not specified', () => {
@ProfileClass()
class DefaultSystem {
process(): boolean {
return true;
}
}
ProfilerSDK.beginFrame();
const system = new DefaultSystem();
expect(system.process()).toBe(true);
ProfilerSDK.endFrame();
});
});