Unit tests

This commit is contained in:
Ukendio 2024-07-30 01:11:22 +02:00
parent ff54fb3d62
commit 0f7cb78a59
2 changed files with 519 additions and 360 deletions

View file

@ -283,7 +283,7 @@ local function ECS_ID_IS_WILDCARD(e: i53): boolean
return first == EcsWildcard or second == EcsWildcard return first == EcsWildcard or second == EcsWildcard
end end
local function archetype_of(world: any, types: { i24 }, prev: Archetype?): Archetype local function archetype_create(world: any, types: { i24 }, prev: Archetype?): Archetype
local ty = hash(types) local ty = hash(types)
local id = world.nextArchetypeId + 1 local id = world.nextArchetypeId + 1
@ -393,7 +393,7 @@ local function archetype_ensure(world: World, types, prev): Archetype
return archetype return archetype
end end
return archetype_of(world, types, prev) return archetype_create(world, types, prev)
end end
local function find_insert(types: { i53 }, toAdd: i53): number local function find_insert(types: { i53 }, toAdd: i53): number
@ -844,13 +844,16 @@ do
end end
end end
lastArchetype = 1
archetype = compatible_archetypes[lastArchetype] archetype = compatible_archetypes[lastArchetype]
if not archetype then if not archetype then
return EmptyQuery return EmptyQuery
end end
entities = archetype.entities
columns = archetype.columns
tr = column_indices[lastArchetype]
i = #entities
return self return self
end end
@ -916,14 +919,26 @@ do
end end
if shouldRemove then if shouldRemove then
table.remove(compatible_archetypes, i) local last = #compatible_archetypes
if last ~= i then
compatible_archetypes[i] = compatible_archetypes[last]
column_indices[i] = column_indices[last]
end
compatible_archetypes[last] = nil
column_indices[last] = nil
end end
end end
if #compatible_archetypes == 0 then archetype = compatible_archetypes[lastArchetype]
if not archetype then
return EmptyQuery return EmptyQuery
end end
entities = archetype.entities
columns = archetype.columns
tr = column_indices[lastArchetype]
i = #entities
return query return query
end end
@ -1190,7 +1205,7 @@ function World.new()
ROOT_ARCHETYPE = (nil :: any) :: Archetype, ROOT_ARCHETYPE = (nil :: any) :: Archetype,
}, World) }, World)
self.ROOT_ARCHETYPE = archetype_of(self, {}) self.ROOT_ARCHETYPE = archetype_create(self, {})
for i = HI_COMPONENT_ID + 1, EcsRest do for i = HI_COMPONENT_ID + 1, EcsRest do
-- Initialize built-in components -- Initialize built-in components
@ -1210,7 +1225,7 @@ return {
Component = EcsComponent, Component = EcsComponent,
Wildcard = EcsWildcard :: Entity, Wildcard = EcsWildcard :: Entity,
w = EcsWildcard :: Entity, w = EcsWildcard :: Entity,
Rest = EcsRest, Rest = EcsRest :: Entity,
pair = (ECS_PAIR :: any) :: <R, T>(pred: Entity, obj: Entity) -> number, pair = (ECS_PAIR :: any) :: <R, T>(pred: Entity, obj: Entity) -> number,

View file

@ -20,11 +20,137 @@ local function CHECK_NO_ERR<T...>(s: string, fn: (T...) -> (), ...: T...)
CHECK(msg == s, 2) CHECK(msg == s, 2)
end end
end end
local N = 10 local N = 2^8
type World = jecs.WorldShim type World = jecs.WorldShim
TEST("world", function() local function debug_world_inspect(world)
local function record(e)
return world.entityIndex.sparse[e]
end
local function tbl(e)
return record(e).archetype
end
local function archetype(e)
return tbl(e).type
end
local function records(e)
return tbl(e).records
end
local function columns(e)
return tbl(e).columns
end
local function row(e)
return record(e).row
end
-- Important to order them in the order of their columns
local function tuple(e, ...)
for i, column in columns(e) do
if select(i, ...) ~= column[row(e)] then
return false
end
end
return true
end
return {
record = record,
tbl = tbl,
archetype = archetype,
records = records,
row = row,
tuple = tuple,
}
end
TEST("world:entity()", function()
do CASE "unique IDs"
local world = jecs.World.new()
local set = {}
for i = 1, N do
local e = world:entity()
CHECK(not set[e])
set[e] = true
end
end
do CASE "generations"
local world = jecs.World.new()
local e = world:entity()
CHECK(ECS_ID(e) == 1 + jecs.Rest)
CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE "pairs"
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
local e3 = world:entity()
-- Incomplete pair, must have a bit flag that notes it is a pair
CHECK(IS_PAIR(world:entity()) == false)
local pair = pair(e2, e3)
CHECK(IS_PAIR(pair) == true)
CHECK(ecs_pair_relation(world.entityIndex, pair) == e2)
CHECK(ecs_pair_object(world.entityIndex, pair) == e3)
end
end)
TEST("world:set()", function()
do CASE "archetype move"
do
local world = jecs.World.new()
local d = debug_world_inspect(world)
local _1 = world:component()
local _2 = world:component()
local e = world:entity()
-- An entity starts without an archetype or row
-- should therefore not need to copy over data
CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == nil)
local archetypes = #world.archetypes
-- This should create a new archetype since it is the first
-- entity to have moved there
world:set(e, _1, 1)
local oldRow = d.row(e)
local oldArchetype = d.archetype(e)
CHECK(#world.archetypes == archetypes + 1)
CHECK(oldArchetype == "1")
CHECK(d.tbl(e))
CHECK(oldRow == 1)
world:set(e, _2, 2)
CHECK(d.archetype(e) == "1_2")
-- Should have tuple of fields to the next archetype and set the component data
CHECK(d.tuple(e, 1, 2))
-- Should have moved the data from the old archetype
CHECK(world.archetypeIndex[oldArchetype].columns[_1][oldRow] == nil)
end
end
do CASE "arbitrary order"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
CHECK(world:get(id, Poison) == 5)
end
end)
TEST("world:remove()", function()
do CASE "should allow remove a component that doesn't exist on entity" do CASE "should allow remove a component that doesn't exist on entity"
local world = jecs.World.new() local world = jecs.World.new()
@ -43,44 +169,72 @@ TEST("world", function()
CHECK(world:get(id, Poison) == nil) CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == 50) CHECK(world:get(id, Health) == 50)
end end
do CASE("should find every component id") end)
local world = jecs.World.new() :: World
local A = world:component()
local B = world:component()
world:entity()
world:entity()
world:entity()
local count = 0 TEST("world:add()", function()
for componentId in world:query(jecs.Component) do do CASE "idempotent"
if componentId ~= A and componentId ~= B then local world = jecs.World.new()
error("found entity") local d = debug_world_inspect(world)
end local _1, _2 = world:component(), world:component()
count += 1
end
CHECK(count == 2)
end
do CASE("should remove its components")
local world = jecs.World.new() :: World
local A = world:component()
local B = world:component()
local e = world:entity() local e = world:entity()
world:add(e, _1)
world:set(e, A, true) world:add(e, _2)
world:set(e, B, true) world:add(e, _2) -- should have 0 effects
CHECK(d.archetype(e) == "1_2")
CHECK(world:get(e, A))
CHECK(world:get(e, B))
world:clear(e)
CHECK(world:get(e, A) == nil)
CHECK(world:get(e, B) == nil)
end end
do CASE("should drain query while iterating") do CASE "archetype move"
do
local world = jecs.World.new()
local d = debug_world_inspect(world)
local _1 = world:component()
local e = world:entity()
-- An entity starts without an archetype or row
-- should therefore not need to copy over data
CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == nil)
local archetypes = #world.archetypes
-- This should create a new archetype
world:add(e, _1)
CHECK(#world.archetypes == archetypes + 1)
CHECK(d.archetype(e) == "1")
CHECK(d.tbl(e))
end
end
end)
TEST("world:query()", function()
do CASE "query single component"
do
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i > 5 then
world:set(id, B, true)
end
entities[i] = id
end
for id in world:query(A) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do
local world = jecs.World.new() :: World local world = jecs.World.new() :: World
local A = world:component() local A = world:component()
local B = world:component() local B = world:component()
@ -105,8 +259,30 @@ TEST("world", function()
CHECK(i == 2) CHECK(i == 2)
CHECK(j == 0) CHECK(j == 0)
end end
end
do CASE("should be able to get next results") do CASE "query missing component"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local e1 = world:entity()
local e2 = world:entity()
world:set(e1, A, "abc")
world:set(e2, A, "def")
world:set(e1, B, 123)
world:set(e2, B, 457)
local counter = 0
for _ in world:query(B, C) do
counter += 1
end
CHECK(counter == 0)
end
do CASE "should be able to get next results"
local world = jecs.World.new() :: World local world = jecs.World.new() :: World
world:component() world:component()
local A = world:component() local A = world:component()
@ -123,36 +299,12 @@ TEST("world", function()
local e, data = q:next() local e, data = q:next()
while e do while e do
CHECK( if e ~= eA and e ~= eAB then
if e == eA then data == true CHECK(false)
elseif e == eAB then data == true end
else false
)
e, data = q:next() e, data = q:next()
end end
end CHECK(true)
do CASE("should query all matching entities")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i > 5 then
world:set(id, B, true)
end
entities[i] = id
end
for id in world:query(A) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end end
do CASE("should query all matching entities when irrelevant component is removed") do CASE("should query all matching entities when irrelevant component is removed")
@ -208,65 +360,6 @@ TEST("world", function()
CHECK(#entities == 0) CHECK(#entities == 0)
end end
do CASE("should allow setting components in arbitrary order")
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
CHECK(world:get(id, Poison) == 5)
end
do CASE("should allow deleting components")
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
world:delete(id)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
do CASE("should increment generation")
local world = jecs.World.new()
local e = world:entity()
CHECK(ECS_ID(e) == 1 + jecs.Rest)
CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE("should get alive from index in the dense array")
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
local e3 = world:entity()
CHECK(IS_PAIR(world:entity()) == false)
local pair = pair(e2, e3)
CHECK(IS_PAIR(pair) == true)
CHECK(ecs_pair_relation(world.entityIndex, pair) == e2)
CHECK(ecs_pair_object(world.entityIndex, pair) == e3)
end
do CASE("should allow querying for relations") do CASE("should allow querying for relations")
local world = jecs.World.new() local world = jecs.World.new()
local Eats = world:entity() local Eats = world:entity()
@ -392,49 +485,8 @@ TEST("world", function()
CHECK(count == 2) CHECK(count == 2)
end end
do CASE "should be able to add/remove matching entity during iteration" do CASE "iterator invalidation"
local world = jecs.World.new() do CASE "adding"
local Name = world:component()
for i = 1, 5 do
local e = world:entity()
world:set(e, Name, tostring(e))
end
local count = 0
for id, name in world:query(Name) do
count += 1
CHECK(id == tonumber(name))
world:remove(id, Name)
local e = world:entity()
world:set(e, Name, tostring(e))
end
CHECK(count == 5)
end
do CASE "should allow adding a matching entity during iteration"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e1 = world:entity()
local e2 = world:entity()
world:add(e1, A)
world:add(e2, A)
world:add(e2, B)
local count = 0
for id in world:query(A) do
local e = world:entity()
world:add(e, A)
world:add(e, B)
count += 1
end
CHECK(count == 3)
end
do CASE "should not iterate same entity when adding component"
SKIP() SKIP()
local world = jecs.World.new() local world = jecs.World.new()
local A = world:component() local A = world:component()
@ -456,65 +508,27 @@ TEST("world", function()
CHECK(count == 2) CHECK(count == 2)
end end
do CASE "should replace component data" do CASE "spawning"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local e = world:entity()
world:set(e, A, 1)
world:set(e, B, true)
world:set(e, C, "hello ")
world:query(A, B, C):replace(function(a, b, c)
return a * 2, not b, c.."world"
end)
CHECK(world:get(e, A) == 2)
CHECK(world:get(e, B) == false)
CHECK(world:get(e, C) == "hello world")
end
do CASE "should not iterate when nothing matches query"
local world = jecs.World.new() local world = jecs.World.new()
local A = world:component() local A = world:component()
local B = world:component() local B = world:component()
local e1 = world:entity() local e1 = world:entity()
local e2 = world:entity()
world:add(e1, A) world:add(e1, A)
world:add(e2, A)
world:add(e2, B)
local count = 0 local count = 0
for id in world:query(B) do for id in world:query(A) do
local e = world:entity()
world:add(e, A)
world:add(e, B)
count += 1 count += 1
end end
CHECK(count == 0) CHECK(count == 3)
end end
do CASE "should return nothing for empty iteration"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e1 = world:entity()
world:add(e1, A)
local query = world:query(B)
CHECK(query.next() == nil)
CHECK(query.replace() == nil)
end
do CASE "should properly handle query:without for empty iteration"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e1 = world:entity()
world:add(e1, A)
local query = world:query(B)
CHECK(query == query:without())
end end
do CASE "should not find any entities" do CASE "should not find any entities"
@ -535,6 +549,94 @@ TEST("world", function()
CHECK(withoutCount == 0) CHECK(withoutCount == 0)
end end
do CASE "Empty Query"
do
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e1 = world:entity()
world:add(e1, A)
local query = world:query(B)
CHECK(query:next() == nil)
CHECK(query:replace() == nil)
CHECK(query:without() == query)
end
do
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local e1 = world:entity()
world:add(e1, A)
local count = 0
for id in world:query(B) do
count += 1
end
CHECK(count == 0)
end
end
do CASE "replace"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local e = world:entity()
world:set(e, A, 1)
world:set(e, B, true)
world:set(e, C, "hello ")
world:query(A, B, C):replace(function(a, b, c)
return a * 2, not b, c.."world"
end)
CHECK(world:get(e, A) == 2)
CHECK(world:get(e, B) == false)
CHECK(world:get(e, C) == "hello world")
end
do CASE "without"
do
-- REGRESSION TEST
local world = jecs.World.new()
local _1, _2, _3 = world:component(), world:component(), world:component()
local counter = 0
for e, a, b in world:query(_1, _2):without(_3) do
counter += 1
end
CHECK(counter == 0)
end
end
end)
TEST("world:clear()", function()
do CASE("should remove its components")
local world = jecs.World.new() :: World
local A = world:component()
local B = world:component()
local e = world:entity()
world:set(e, A, true)
world:set(e, B, true)
CHECK(world:get(e, A))
CHECK(world:get(e, B))
world:clear(e)
CHECK(world:get(e, A) == nil)
CHECK(world:get(e, B) == nil)
end
end)
TEST("world:has()", function()
do CASE "should find Tag on entity" do CASE "should find Tag on entity"
local world = jecs.World.new() local world = jecs.World.new()
@ -561,7 +663,40 @@ TEST("world", function()
CHECK(world:has(e, A, B, C, D) == false) CHECK(world:has(e, A, B, C, D) == false)
end end
end)
TEST("world:component()", function()
do CASE "only components should have EcsComponent trait"
local world = jecs.World.new() :: World
local A = world:component()
local e = world:entity()
CHECK(world:has(A, jecs.Component))
CHECK(not world:has(e, jecs.Component))
end
end)
TEST("world:delete", function()
do CASE("should allow deleting components")
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
world:delete(id)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
end) end)
type Tracker<T> = { track: (world: World, fn: (changes: { type Tracker<T> = { track: (world: World, fn: (changes: {
@ -573,6 +708,25 @@ type Tracker<T> = { track: (world: World, fn: (changes: {
type Entity<T = any> = number & { __nominal_type_dont_use: T } type Entity<T = any> = number & { __nominal_type_dont_use: T }
local function diff(a, b)
local size = 0
for k, v in a do
if b[k] ~= v then
return true
end
size += 1
end
for k, v in b do
size -= 1
end
if size ~= 0 then
return true
end
return false
end
local ChangeTracker: <T>(world: World, component: Entity<T>) -> Tracker<T> local ChangeTracker: <T>(world: World, component: Entity<T>) -> Tracker<T>
do do
@ -594,37 +748,15 @@ do
end end
if is_trivial == nil then if is_trivial == nil then
isTrivial = typeof(data) ~= "table" is_trivial = typeof(data) ~= "table"
end
if not isTrivial then
data = table.clone(data)
end end
add[id] = data add[id] = data
return id, data return id, data
end end
end end
local function diff(a, b)
local size = 0
for k, v in a do
if b[k] ~= v then
return true
end
size += 1
end
for k, v in b do
size -= 1
end
if size ~= 0 then
return true
end
return false
end
local function changes_changed() local function changes_changed()
local q = world:query(T, PreviousT) local q = world:query(T, PreviousT)
@ -635,7 +767,8 @@ do
return nil return nil
end end
if isTrivial and new ~= old then if is_trivial and new ~= old then
break
elseif diff(new, old) then elseif diff(new, old) then
break break
end end
@ -669,8 +802,8 @@ do
} }
local function track(fn) local function track(fn)
added = true added = false
removed = true removed = false
fn(changes) fn(changes)
@ -685,7 +818,7 @@ do
end end
for e, data in add do for e, data in add do
world:set(e, PreviousT, if isTrivial then data else table.clone(data)) world:set(e, PreviousT, if is_trivial then data else table.clone(data))
end end
end end
@ -702,78 +835,89 @@ do
end end
end end
TEST("changetracker", function() TEST("changetracker:track()", function()
local world = jecs.World.new() local world = jecs.World.new()
do CASE "should allow change tracking" do CASE "added"
local Test = world:component() :: Entity<{ foo: number }> local Test = world:component() :: Entity<{ foo: number }>
local TestTracker = ChangeTracker(world, Test) local TestTracker = ChangeTracker(world, Test)
local e = world:entity() local e1 = world:entity()
world:set(e, Test, { foo = 11 }) local data = { foo = 11 }
world:set(e1, Test, data)
TestTracker.track(function(changes) TestTracker.track(function(changes)
local added = 0 local added = 0
local changed = 0 for e, test in changes.added() do
local removed = 0
for e, data in changes.added() do
added+=1 added+=1
CHECK(test == data)
end end
for e, old, new in changes.changed() do for e, old, new in changes.changed() do
changed+=1 CHECK(false)
end end
for e in changes.removed() do for e in changes.removed() do
removed+=1 CHECK(false)
end end
CHECK(added == 1) CHECK(added == 1)
CHECK(changed == 0)
CHECK(removed == 0)
end) end)
for e, test in world:query(Test) do
test.foo = test.foo + 1
end end
do CASE "changed"
local Test = world:component() :: Entity<{ foo: number }>
local TestTracker = ChangeTracker(world, Test)
local data = { foo = 11 }
local e1 = world:entity()
world:set(e1, Test, data)
TestTracker.track(function(changes) TestTracker.track(function(changes)
local added = 0 end)
local changed = 0
local removed = 0
for e, data in changes.added() do data.foo += 1
added+=1
TestTracker.track(function(changes)
for _ in changes.added() do
CHECK(false)
end end
local changed = 0
for e, old, new in changes.changed() do for e, old, new in changes.changed() do
CHECK(e == e1)
CHECK(new == data)
CHECK(old ~= new)
CHECK(diff(new, old))
changed +=1 changed +=1
end end
for e in changes.removed() do
removed+=1
end
CHECK(added == 0)
CHECK(changed == 1) CHECK(changed == 1)
CHECK(removed == 0)
end) end)
end
do CASE "removed"
local Test = world:component() :: Entity<{ foo: number }>
local TestTracker = ChangeTracker(world, Test)
world:remove(e, Test) local data = { foo = 11 }
local e1 = world:entity()
world:set(e1, Test, data)
TestTracker.track(function(changes) TestTracker.track(function(changes)
local added = 0 end)
local changed = 0
world:remove(e1, Test)
TestTracker.track(function(changes)
for _ in changes.added() do
CHECK(false)
end
for _ in changes.changed() do
CHECK(false)
end
local removed = 0 local removed = 0
for e, data in changes.added() do
added+=1
end
for e, old, new in changes.changed() do
changed+=1
end
for e in changes.removed() do for e in changes.removed() do
removed += 1 removed += 1
CHECK(e == e1)
end end
CHECK(added == 0)
CHECK(changed == 0)
CHECK(removed == 1) CHECK(removed == 1)
end) end)
end end
end) end)
FINISH() FINISH()