diff --git a/.vscode/launch.json b/.vscode/launch.json index 9b3f11d2ca..7b2e2c9e3a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,7 @@ } ], "configurations": [ + { "command": "cd packages/ui && npm run test:watch", "name": "ui-jest", @@ -96,6 +97,12 @@ "request": "launch", "type": "node-terminal", }, + { + "command": "cd packages/ecs && npm run test", + "name": "npm run test - ecs", + "request": "launch", + "type": "node-terminal", + }, { "command": "cd packages/engine && npm run test", "name": "npm run test - engine", diff --git a/packages/ecs/src/ComponentFunctions.test.ts b/packages/ecs/src/ComponentFunctions.test.tsx similarity index 55% rename from packages/ecs/src/ComponentFunctions.test.ts rename to packages/ecs/src/ComponentFunctions.test.tsx index bf43ec3d30..10dc044678 100644 --- a/packages/ecs/src/ComponentFunctions.test.ts +++ b/packages/ecs/src/ComponentFunctions.test.tsx @@ -23,8 +23,10 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ +import { act, render } from '@testing-library/react' import assert from 'assert' import { Types } from 'bitecs' +import React, { useEffect } from 'react' import { ComponentMap, @@ -33,10 +35,14 @@ import { getComponent, hasComponent, removeComponent, - setComponent + setComponent, + useComponent, + useOptionalComponent } from './ComponentFunctions' import { destroyEngine, startEngine } from './Engine' -import { createEntity } from './EntityFunctions' +import { Entity, EntityUUID, UndefinedEntity } from './Entity' +import { createEntity, removeEntity } from './EntityFunctions' +import { UUIDComponent } from './UUIDComponent' describe('ComponentFunctions', async () => { beforeEach(() => { @@ -219,7 +225,7 @@ describe('ComponentFunctions', async () => { }) describe('removeComponent', () => { - it('should have component', () => { + it('should remove component', () => { const TestComponent = defineComponent({ name: 'TestComponent', onInit: () => true }) const entity = createEntity() @@ -233,7 +239,7 @@ describe('ComponentFunctions', async () => { assert.ok(TestComponent.stateMap[entity]!.promised === true) }) - it('should have component with AoS values', () => { + it('should remove component with AoS values', () => { const TestComponent = defineComponent({ name: 'TestComponent', @@ -257,7 +263,7 @@ describe('ComponentFunctions', async () => { assert.ok(!hasComponent(entity, TestComponent)) }) - it('should have component with SoA values', () => { + it('should remove component with SoA values', () => { const { f32 } = Types const ValueSchema = { value: f32 } const TestComponent = defineComponent({ name: 'TestComponent', schema: ValueSchema }) @@ -291,6 +297,190 @@ describe('ComponentFunctions', async () => { assert.ok(component3) }) }) +}) + +describe('ComponentFunctions Hooks', async () => { + describe('useComponent', async () => { + type ResultType = undefined | string + const ResultValue: ResultType = 'ReturnValue' + const component = defineComponent({ name: 'TestComponent', onInit: () => ResultValue }) + let testEntity = UndefinedEntity + let result = undefined as ResultType + let counter = 0 + + beforeEach(() => { + startEngine() + ComponentMap.clear() + testEntity = createEntity() + }) + + afterEach(() => { + counter = 0 + removeEntity(testEntity) + return destroyEngine() + }) + + // Define the Reactor that will run the tested hook + const Reactor = () => { + const data = useComponent(testEntity, component) + useEffect(() => { + result = data.value as ResultType + ++counter + }, [data]) + return null + } + + it('assigns the correct value with onInit', async () => { + setComponent(testEntity, component) + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, "The result data didn't get assigned.") + assert.equal(result, ResultValue, `Did not return the correct data. result = ${result}`) + unmount() + }) + }) // useComponent + + describe('useOptionalComponent : Simple cases', async () => { + type ResultType = string | undefined + const ResultValue: ResultType = 'ReturnValue' + const component = defineComponent({ name: 'TestComponent', onInit: () => ResultValue }) + let testEntity = UndefinedEntity + let result: ResultType = undefined + let counter = 0 + + beforeEach(() => { + startEngine() + ComponentMap.clear() + testEntity = createEntity() + }) + + afterEach(() => { + counter = 0 + removeEntity(testEntity) + return destroyEngine() + }) + + // Define the Reactor that will run the tested hook + const Reactor = () => { + const data = useOptionalComponent(testEntity, component) + useEffect(() => { + result = data?.value + ++counter + }, [data]) + return null + } + + it("returns undefined when the component wasn't set yet", async () => { + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.equal(result, undefined, `Should have returned undefined.`) + unmount() + }) + + it('returns the correct data when the component has been set', async () => { + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + setComponent(testEntity, component) + await act(() => rerender(tag)) + assert.equal(true, hasComponent(testEntity, component), 'The test entity did not get its component set correctly') + assert.notEqual(result, undefined, "The result data didn't get assigned.") + assert.equal(counter, 2, `The reactor has run an incorrect number of times: ${counter}`) + assert.equal(result, ResultValue, `Did not return the correct data.`) + unmount() + }) + }) // useOptionalComponent : Simple Cases + + describe('useOptionalComponent : Isolated Test Cases', async () => { + /** @note These test cases are isolated from each other, by defining everything without using any common code (like beforeEach/afterEach/etc) */ + + it('returns different data when the entity is changed', async () => { + // Initialize the isolated case + startEngine() + ComponentMap.clear() + + // Initialize the dummy data + type ResultType = EntityUUID | undefined + const component = UUIDComponent + const TestUUID1 = 'TestUUID1' as EntityUUID + const TestUUID2 = 'TestUUID2' as EntityUUID + const oneEntity = createEntity() + const twoEntity = createEntity() + let result: ResultType = undefined + let counter = 0 + + setComponent(oneEntity, UUIDComponent, TestUUID1) + setComponent(twoEntity, UUIDComponent, TestUUID2) + + // Define the Reactor that will run the tested hook + const Reactor = (props: { entity: Entity }) => { + // Call the hook to set the data + const data = useComponent(props.entity, component) + useEffect(() => { + result = data.value + ++counter + }, [data]) + return null + } + + // Run the test case + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, "The result data didn't get initialized") + assert.equal(result, TestUUID1) + await act(() => rerender()) + assert.equal(result, TestUUID2) + + // Terminate the Reactor and Isolated Test + unmount() + return destroyEngine() + }) + + it('suspense should work', async () => { + // Initialize the isolated case + startEngine() + ComponentMap.clear() + + // Initialize the dummy data + const entity = createEntity() + const TestComponent = defineComponent({ name: 'TestComponent' }) + let result = 0 + + // Define the Reactor that will run the tested hook + const Reactor = () => { + result++ + const data = useComponent(entity, TestComponent) + result++ + useEffect(() => { + result++ + }, [data]) + return null + } + + // Run the test case + const tag = + assert.equal(TestComponent.stateMap[entity]!, undefined) + const { rerender, unmount } = render(tag) + assert.equal(result, 1) + + setComponent(entity, TestComponent) + await act(() => rerender(tag)) + assert.equal(result, 4) + + // Terminate the Reactor and Isolated Test + unmount() + return destroyEngine() + }) + }) // useOptionalComponent : Isolated Test Cases // TODO describe('defineQuery', () => {}) diff --git a/packages/ecs/src/QueryFunctions.test.tsx b/packages/ecs/src/QueryFunctions.test.tsx index 26cf5609a2..d0e048770d 100644 --- a/packages/ecs/src/QueryFunctions.test.tsx +++ b/packages/ecs/src/QueryFunctions.test.tsx @@ -23,27 +23,75 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { renderHook } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' import assert from 'assert' +import React, { useEffect } from 'react' + import { ComponentMap, defineComponent, hasComponent, removeComponent, setComponent } from './ComponentFunctions' import { destroyEngine, startEngine } from './Engine' -import { createEntity } from './EntityFunctions' -import { defineQuery, useQuery } from './QueryFunctions' +import { Entity, UndefinedEntity } from './Entity' +import { createEntity, removeEntity } from './EntityFunctions' +import { Query, defineQuery, useQuery } from './QueryFunctions' + +function assertArrayEqual(A: Array, B: Array, err = 'Arrays are not equal') { + assert.equal(A.length, B.length, err) + for (let id = 0; id < A.length && id < B.length; id++) { + assert.deepEqual(A[id], B[id], err) + } +} +function assertArrayNotEqual(A: Array, B: Array, err = 'Arrays are equal') { + for (let id = 0; id < A.length && id < B.length; id++) { + assert.notDeepEqual(A[id], B[id], err) + } +} + +function assertQueryOk(Q: Query, err = 'Query is not Ok') { + assert.doesNotThrow(Q.enter, err) + assert.doesNotThrow(Q.exit, err) + assert.doesNotThrow(Q, err) +} + +function assertQueryNotOk(Q: Query, err = 'Query is Ok') { + assert.throws(Q.enter, err) + assert.throws(Q.exit, err) + assert.throws(Q, err) +} + +function assertDefinedQuery(Q: Query, expected: Entity[]) { + assertQueryOk(Q, 'The test query did not get defined correctly') + assertArrayNotEqual(Q(), [], 'The query did not return any entities.') + assertArrayEqual(Q(), expected, 'The test query did not return the expected result') +} const ComponentA = defineComponent({ name: 'ComponentA' }) const ComponentB = defineComponent({ name: 'ComponentB' }) describe('QueryFunctions', () => { + const component = defineComponent({ name: 'TestComponent' }) + let entity1 = UndefinedEntity + let entity2 = UndefinedEntity + beforeEach(() => { startEngine() + entity1 = createEntity() + entity2 = createEntity() }) afterEach(() => { ComponentMap.clear() + removeEntity(entity1) + removeEntity(entity2) return destroyEngine() }) describe('defineQuery', () => { + it('should create a valid query', () => { + const query = defineQuery([component]) + setComponent(entity1, component) + setComponent(entity2, component) + assertDefinedQuery(query, [entity1, entity2]) + }) + it('should define a query with the given components', () => { const query = defineQuery([ComponentA, ComponentB]) assert.ok(query) @@ -68,8 +116,41 @@ describe('QueryFunctions', () => { assert.ok(hasComponent(entities[0], ComponentB)) }) }) +}) +describe('QueryFunctions Hooks', async () => { describe('useQuery', () => { + type ResultType = undefined | Entity[] + const component = defineComponent({ name: 'TestComponent' }) + let entity1 = UndefinedEntity + let entity2 = UndefinedEntity + let result = undefined as ResultType + let counter = 0 + + beforeEach(() => { + startEngine() + entity1 = createEntity() + entity2 = createEntity() + }) + + afterEach(() => { + counter = 0 + removeEntity(entity1) + removeEntity(entity2) + ComponentMap.clear() + return destroyEngine() + }) + + // Define the Reactor that will run the tested hook + const Reactor = () => { + const data = useQuery([component]) + useEffect(() => { + result = data as ResultType + ++counter + }, [data]) + return null + } + it('should return entities that match the query', () => { const e1 = createEntity() const e2 = createEntity() @@ -112,5 +193,39 @@ describe('QueryFunctions', () => { assert.ok(hasComponent(entities[0], ComponentA)) assert.ok(hasComponent(entities[0], ComponentB)) }) + + it(`should return an empty array when entities don't have the component`, async () => { + const ExpectedValue: ResultType = [] + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, `The result data did not get assigned.`) + assertArrayEqual( + result as Entity[], + ExpectedValue as Entity[], + `Did not return the correct data.\n result = ${result}` + ) + unmount() + }) + + it('should return the list of entities that have the component', async () => { + const ExpectedValue: ResultType = [entity1, entity2] + setComponent(entity1, component) + setComponent(entity2, component) + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, `The result data did not get assigned.`) + assertArrayEqual( + result as Entity[], + ExpectedValue as Entity[], + `Did not return the correct data.\n result = ${result}\n expected = ${ExpectedValue}` + ) + unmount() + }) }) }) diff --git a/packages/ecs/src/SystemFunctions.test.ts b/packages/ecs/src/SystemFunctions.test.ts index 7bbe0bd1ee..a47e9b4578 100755 --- a/packages/ecs/src/SystemFunctions.test.ts +++ b/packages/ecs/src/SystemFunctions.test.ts @@ -58,7 +58,7 @@ describe('SystemFunctions', () => { return destroyEngine() }) - it('can run multiple simultion ticks to catch up to elapsed time', async () => { + it('can run multiple simulation ticks to catch up to elapsed time', async () => { const mockState = getMutableState(MockState) assert.equal(mockState.count.value, 0) diff --git a/packages/ecs/src/UUIDComponent.test.tsx b/packages/ecs/src/UUIDComponent.test.tsx new file mode 100644 index 0000000000..d20b482b88 --- /dev/null +++ b/packages/ecs/src/UUIDComponent.test.tsx @@ -0,0 +1,254 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { act, render } from '@testing-library/react' +import assert from 'assert' +import React, { useEffect } from 'react' + +import { + ComponentMap, + getComponent, + getOptionalComponent, + hasComponent, + removeComponent, + serializeComponent, + setComponent +} from './ComponentFunctions' +import { destroyEngine, startEngine } from './Engine' +import { Entity, EntityUUID, UndefinedEntity } from './Entity' +import { createEntity, removeEntity } from './EntityFunctions' +import { UUIDComponent } from './UUIDComponent' + +describe('UUIDComponent', () => { + const TestUUID = 'TestUUID' as EntityUUID + const TestUUID2 = UUIDComponent.generateUUID() + let entity1 = UndefinedEntity + let entity2 = UndefinedEntity + + beforeEach(() => { + startEngine() + ComponentMap.clear() + entity1 = createEntity() + entity2 = createEntity() + }) + + afterEach(() => { + removeEntity(entity1) + removeEntity(entity2) + return destroyEngine() + }) + + describe('onSet', () => { + it('should throw an Error exception when the uuid argument is not passed.', () => { + assert.throws(() => { + setComponent(entity1, UUIDComponent) + }, Error) + }) + + it('should set/get the data of the component.', () => { + // Case1: set/get + setComponent(entity1, UUIDComponent, TestUUID) + const component1 = getComponent(entity1, UUIDComponent) + assert.ok(component1, 'The UUIDComponent did not get set correctly') + assert.equal(component1, TestUUID, 'The UUID value did not get set correctly') + }) + + it("shouldn't change the data when set multiple times with the same data", () => { + setComponent(entity1, UUIDComponent, TestUUID) + const component1 = getComponent(entity1, UUIDComponent) + setComponent(entity1, UUIDComponent, TestUUID) + const component2 = getComponent(entity1, UUIDComponent) + assert.equal(component1, component2) + }) + + it('Should throw an error when the UUID is already in use for another entity', () => { + setComponent(entity1, UUIDComponent, TestUUID) + assert.throws(() => { + setComponent(entity2, UUIDComponent, TestUUID) + }, Error) + }) + + it('should remove the old uuid from the entity', () => { + setComponent(entity1, UUIDComponent, TestUUID) + setComponent(entity1, UUIDComponent, TestUUID2) + assert.notEqual(getComponent(entity1, UUIDComponent), TestUUID) + }) + + it('should set a new uuid, and return its value when called with getOptionalComponent', () => { + setComponent(entity1, UUIDComponent, TestUUID) + assert.notEqual(getOptionalComponent(entity1, UUIDComponent), undefined) + }) + }) + + describe('toJson', () => { + it('should return correctly serialized data', () => { + setComponent(entity1, UUIDComponent, TestUUID) + const json = serializeComponent(entity1, UUIDComponent) + assert.equal(json, TestUUID as string) + }) + }) + + describe('onRemove', () => { + it('should remove the component from the entity', () => { + setComponent(entity1, UUIDComponent, TestUUID) + removeComponent(entity1, UUIDComponent) + assert.equal(UndefinedEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(false, hasComponent(entity1, UUIDComponent)) + assert.equal(getOptionalComponent(entity1, UUIDComponent), undefined) + }) + + it('should do nothing if the entity does not have the component', () => { + removeComponent(entity1, UUIDComponent) + assert.equal(UndefinedEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(getOptionalComponent(entity1, UUIDComponent), undefined) + }) + }) + + describe('getEntityByUUID', () => { + it('should return the correct entity', () => { + setComponent(entity1, UUIDComponent, TestUUID) + const testEntity = UUIDComponent.getEntityByUUID(TestUUID) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(testEntity, entity1) + }) + + it('should return the correct entity when its UUIDComponent is removed and added back with a different UUID', () => { + setComponent(entity1, UUIDComponent, TestUUID) + removeComponent(entity1, UUIDComponent) + setComponent(entity1, UUIDComponent, TestUUID2) + const testEntity = UUIDComponent.getEntityByUUID(TestUUID2) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID2].value) + }) + + it('should return UndefinedEntity when the UUID has not been added to any entity', () => { + const testEntity = UUIDComponent.getEntityByUUID(TestUUID) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(testEntity, UndefinedEntity) + }) + }) + + describe('getOrCreateEntityByUUID', () => { + it('should return the correct entity when it exists', () => { + setComponent(entity1, UUIDComponent, TestUUID) + const testEntity = UUIDComponent.getOrCreateEntityByUUID(TestUUID) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(testEntity, entity1) + }) + + it("should create a new entity when the UUID hasn't been added to any entity", () => { + setComponent(entity1, UUIDComponent, TestUUID) + const testEntity = UUIDComponent.getOrCreateEntityByUUID(TestUUID2) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID2].value) + assert.notEqual(testEntity, entity1) + }) + }) + describe('generateUUID', () => { + it('should generate a non-empty UUID', () => { + const uuid = UUIDComponent.generateUUID() + assert.notEqual(uuid, '' as EntityUUID) + }) + + const iter = 8_500 /** @note 10_000 iterations takes ~4sec on an AMD Ryzen 5 2600 */ + it(`should generate unique UUIDs when run multiple times (${iter} iterations)`, () => { + const list = [] as EntityUUID[] + // Generate the list of (supposedly) unique UUIDs + for (let id = 0; id < iter; id++) { + list.push(UUIDComponent.generateUUID()) + } + // Compare every UUID with all other UUIDs + for (let id = 0; id < iter; id++) { + const A = list[id] + for (const B in list.filter((n) => n !== list[id])) { + // For every other uuid that is not the current one + assert.notEqual(A, B, 'Found two identical UUIDs') + } + } + }) + }) +}) + +describe('UUIDComponent Hooks', async () => { + describe('useEntityByUUID', async () => { + type ResultType = Entity | undefined + const TestUUID = 'TestUUID' as EntityUUID + let entity1 = UndefinedEntity + let entity2 = UndefinedEntity + let result: ResultType = undefined + let counter = 0 + + beforeEach(() => { + startEngine() + ComponentMap.clear() + entity1 = createEntity() + entity2 = createEntity() + }) + + afterEach(() => { + counter = 0 + removeEntity(entity1) + removeEntity(entity2) + return destroyEngine() + }) + + // Define the Reactor that will run the tested hook + const Reactor = () => { + const data = UUIDComponent.useEntityByUUID(TestUUID) + useEffect(() => { + result = data as ResultType + ++counter + }, [data]) + return null + } + + it('assigns the correct entity', async () => { + const ExpectedValue: ResultType = entity1 + setComponent(entity1, UUIDComponent, TestUUID) + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, "The result data didn't get assigned.") + assert.equal(result, ExpectedValue, `Did not return the correct data. result = ${result}`) + unmount() + }) + + it('returns the same entity than genEntityByUUID', async () => { + const ExpectedValue: ResultType = entity1 + setComponent(entity1, UUIDComponent, TestUUID) + const testEntity = UUIDComponent.getEntityByUUID(TestUUID) + assert.equal(counter, 0, "The reactor shouldn't have run before rendering") + const tag = + const { rerender, unmount } = render(tag) + await act(() => rerender(tag)) + assert.equal(counter, 1, `The reactor has run an incorrect number of times: ${counter}`) + assert.notEqual(result, undefined, "The result data didn't get assigned.") + assert.equal(result, ExpectedValue, `Did not return the correct data. result = ${result}`) + assert.equal(testEntity, UUIDComponent.entitiesByUUIDState[TestUUID].value) + assert.equal(testEntity, ExpectedValue) + unmount() + }) + }) // useComponent +})