import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert'; import Serpentity, { Component, Entity, Node, System } from '../lib/serpentity.js'; const internals = { context: {}, system: class TestSystem extends System { added(engine) { this.testNodes = engine.getNodes(internals.node); this.addedCalled = true; this.addedEngine = engine; this.emittedEvents = []; this.beforeCall = null; this.afterCall = null; this.changeObserver = (event) => { this.emittedEvents.push(event) }; this.nodeAddedObserver = ({ node }) => { node.test.addEventListener('change', this.changeObserver); }; this.nodeRemovedObserver = ({ node }) => { node.test.removeEventListener('change', this.changeObserver); }; for (const node of this.testNodes) { node.test.addEventListener('change', this.changeObserver); } this.testNodes.addEventListener('nodeAdded', this.nodeAddedObserver); this.testNodes.addEventListener('nodeRemoved', this.nodeRemovedObserver); super.added(); // not needed, but takes care of coverage :P } removed(engine) { this.testNodes.removeEventListener('nodeAdded', this.nodeAddedObserver); this.nodeAddedObserver = null; this.testNodes.removeEventListener('nodeRemoved', this.nodeRemovedObserver); this.nodeRemovedObserver = null; for (const node of this.testNodes) { node.test.removeEventListener('change', this.changeObserver); } this.changeObserver = null; this.testNodes = null; this.removedCalled = true; this.removedEngine = engine; this.emittedEvents = null; this.beforeCall = null; this.afterCall = null; super.removed(); // not needed, but takes care of coverage :P } update(dt) { this.updateCalled = Date.now(); this.updateDt = dt; for (const node of this.testNodes) { this.beforeCall = this.beforeCall === null ? node.test.called : this.beforeCall; node.test.called = true; this.afterCall = this.afterCall === null ? node.test.called : this.afterCall; node.entity.called = true; } while (Date.now() === this.updateCalled) { /* pass some time */ } super.update(); // not needed, but takes care of coverage :P } }, component: class TestComponent extends Component { constructor(config) { super(config); this.called = false; } }, node: class TestNode extends Node {}, delta: 10 }; // adds a component to the node internals.node.types = { test: internals.component }; describe('Loading', () => { it('Should export the main class', () => { assert(typeof Serpentity === 'function'); }); it('Should export the Entity class', () => { assert(typeof Entity === 'function'); }); it('Should export the Component class', () => { assert(typeof Component === 'function'); }); it('Should export the System class', () => { assert(typeof System === 'function'); }); it('Should export the Node class', () => { assert(typeof Node === 'function'); }); }); describe('Engine', () => { beforeEach(() => { internals.context.engine = new Serpentity(); internals.context.regularSystem = new internals.system(); internals.context.highPrioritySystem = new internals.system(); internals.context.lowPrioritySystem = new internals.system(); internals.context.firstEntity = new Entity(); internals.context.firstEntity.addComponent(new internals.component()); internals.context.secondEntity = new Entity(); internals.context.secondEntity.addComponent(new internals.component()); internals.context.emptyEntity = new Entity(); // Add entity before the systems internals.context.engine.addEntity(internals.context.firstEntity); internals.context.engine.addSystem(internals.context.regularSystem, 100); internals.context.engine.addSystem(internals.context.highPrioritySystem, 0); internals.context.engine.addSystem(internals.context.lowPrioritySystem, 1000); // Add entity after the systems internals.context.engine.addEntity(internals.context.secondEntity); internals.context.engine.addEntity(internals.context.emptyEntity); }); it('should call added callback on added systems', () => { // Ensure the added callback is being called assert(internals.context.regularSystem.addedCalled); assert(internals.context.highPrioritySystem.addedCalled); assert(internals.context.lowPrioritySystem.addedCalled); }); it('should send the engine instance in added callback', () => { // Ensure the added callback is sending the engine assert(internals.context.regularSystem.addedEngine instanceof Serpentity); assert(internals.context.highPrioritySystem.addedEngine instanceof Serpentity); assert(internals.context.lowPrioritySystem.addedEngine instanceof Serpentity); }); it('should not add duplicate systems', () => { const originalSystemsLength = internals.context.engine.systems.length; const added = internals.context.engine.addSystem(internals.context.regularSystem, 0); const newSystemsLength = internals.context.engine.systems.length; // Ensure we don't add the same system twice assert(!added); assert.deepEqual(newSystemsLength, originalSystemsLength); }); it('should call update callback on added systems', () => { internals.context.engine.update(internals.delta); // Ensure update function called assert(!!internals.context.regularSystem.updateCalled); assert(!!internals.context.highPrioritySystem.updateCalled); assert(!!internals.context.lowPrioritySystem.updateCalled); }); it('should keep proxied object behavior as expected', () => { internals.context.engine.update(internals.delta); assert(internals.context.highPrioritySystem.beforeCall === false); assert(internals.context.highPrioritySystem.afterCall === true); }); it('should emit an event for every changed property', () => { internals.context.engine.update(internals.delta); assert(internals.context.regularSystem.emittedEvents[0].property === 'called'); assert(internals.context.regularSystem.emittedEvents[0].from === false); assert(internals.context.regularSystem.emittedEvents[0].to === true); // 3 systems x 2 entities. assert(internals.context.regularSystem.emittedEvents.length === 6); }); it('should call update callback in the order of priorities', () => { internals.context.engine.update(internals.delta); // Ensure order of priorities assert(internals.context.regularSystem.updateCalled < internals.context.lowPrioritySystem.updateCalled); assert(internals.context.regularSystem.updateCalled > internals.context.highPrioritySystem.updateCalled); }); it('should send the delta in the update callback', () => { internals.context.engine.update(internals.delta); // Ensure delta is being sent assert.deepEqual(internals.context.regularSystem.updateDt, internals.delta); assert.deepEqual(internals.context.highPrioritySystem.updateDt, internals.delta); assert.deepEqual(internals.context.lowPrioritySystem.updateDt, internals.delta); }); it('should no longer call removed systems', () => { const originalSystemLength = internals.context.engine.systems.length; const originalRemoved = internals.context.engine.removeSystem(internals.context.lowPrioritySystem); const intermediateSystemLength = internals.context.engine.systems.length; const finalRemoved = internals.context.engine.removeSystem(internals.context.lowPrioritySystem); const finalSystemLength = internals.context.engine.systems.length; internals.context.engine.update(internals.delta); // Check for return value assert(originalRemoved); assert(!finalRemoved); // Confirm that only removed if found by checking length of systems // array assert(originalSystemLength > intermediateSystemLength); assert.deepEqual(finalSystemLength, intermediateSystemLength); // Ensure callback is sent assert(!internals.context.regularSystem.removedCalled); assert(!internals.context.highPrioritySystem.removedCalled); assert(!!internals.context.lowPrioritySystem.removedCalled); // Ensure update is no longer sent assert(!!internals.context.regularSystem.updateCalled); assert(!!internals.context.highPrioritySystem.updateCalled); assert(!internals.context.lowPrioritySystem.updateCalled); }); it('should only call nodes in selected node collections', () => { internals.context.engine.update(internals.delta); // Ensure component is called for each entity assert(!!internals.context.firstEntity._components[0].called); assert(!!internals.context.secondEntity._components[0].called); // Ensure entity not in node collection not called assert(!!internals.context.firstEntity.called); assert(!!internals.context.secondEntity.called); assert(!internals.context.emptyEntity.called); }); it('should stop showing removed entities', () => { internals.context.engine.removeEntity(internals.context.secondEntity); internals.context.engine.update(internals.delta); assert(!!internals.context.firstEntity._components[0].called); assert(!internals.context.secondEntity._components[0].called); assert(!!internals.context.firstEntity.called); assert(!internals.context.secondEntity.called); assert(!internals.context.emptyEntity.called); }); it('should not add duplicate components to entities', () => { const originalComponentsLength = internals.context.secondEntity._components.length; const result = internals.context.secondEntity.addComponent(new internals.component()); const newComponentsLength = internals.context.secondEntity._components.length; assert(!result); assert.deepEqual(newComponentsLength, originalComponentsLength); }); it('should allow access to components by class', () => { const firstComponent = internals.context.firstEntity.getComponent(internals.component); const emptyComponent = internals.context.emptyEntity.getComponent(internals.component); assert(firstComponent instanceof internals.component); assert.deepEqual(emptyComponent, undefined); }); it('should not add duplicate entities', () => { const originalEntitiesLength = internals.context.engine.entities.length; const added = internals.context.engine.addEntity(internals.context.firstEntity); const finalEntitiesLength = internals.context.engine.entities.length; assert(!added); assert.deepEqual(finalEntitiesLength, originalEntitiesLength); }); it('should remove entities', () => { const originalEntityLength = internals.context.engine.entities.length; const originalRemoved = internals.context.engine.removeEntity(internals.context.firstEntity); const intermediateEntityLength = internals.context.engine.entities.length; const finalRemoved = internals.context.engine.removeEntity(internals.context.firstEntity); const finalEntityLength = internals.context.engine.entities.length; internals.context.engine.update(internals.delta); // Check for return value assert(originalRemoved); assert(!finalRemoved); // Confirm that only removed if found by checking length of systems // array assert(originalEntityLength > intermediateEntityLength); assert.deepEqual(finalEntityLength, intermediateEntityLength); // Ensure callback is sent assert(!internals.context.firstEntity.called); assert(!!internals.context.secondEntity.called); }); it('should not add duplicate entities', () => { const originalEntitiesLength = internals.context.engine.entities.length; const added = internals.context.engine.addEntity(internals.context.firstEntity); const finalEntitiesLength = internals.context.engine.entities.length; assert(!added); assert.deepEqual(finalEntitiesLength, originalEntitiesLength); }); });