Skip to content

Commit 0f964fa

Browse files
committed
test: add automated test suite and CI workflow
- Add 'tests/' package with comprehensive coverage - Add GitHub Actions workflow ('test.yml') - Update README.md with testing status badge
1 parent dc37a29 commit 0f964fa

7 files changed

Lines changed: 586 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Testing
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Setup Odin
16+
uses: laytan/setup-odin@v2
17+
with:
18+
token: ${{ secrets.GITHUB_TOKEN }}
19+
20+
- name: Run Tests
21+
run: odin test tests

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
</picture>
88
</a>
99

10+
[![Testing Status](https://github.com/GuilHartt/muninn/actions/workflows/test.yml/badge.svg)](https://github.com/GuilHartt/muninn/actions/workflows/test.yml)
1011
[![License](https://img.shields.io/badge/license-zlib-blue)](LICENSE)
1112
[![Language](https://img.shields.io/badge/language-Odin-orange)](https://odin-lang.org/)
1213
[![GitHub](https://img.shields.io/badge/github-repo-blue?logo=github)](https://github.com/GuilHartt/muninn)

tests/entity.odin

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tests
2+
3+
import "core:testing"
4+
import "../ecs"
5+
6+
Position :: struct {
7+
x, y: f32,
8+
}
9+
10+
Tag :: struct {}
11+
12+
@(test)
13+
test_entity_lifecycle :: proc(t: ^testing.T) {
14+
world := ecs.create_world()
15+
defer ecs.destroy_world(world)
16+
17+
e := ecs.create_entity(world)
18+
testing.expect(t, ecs.is_alive(world, e), "Entity must be alive immediately after creation")
19+
20+
ecs.destroy_entity(world, e)
21+
testing.expect(t, !ecs.is_alive(world, e), "Entity must be dead after destruction")
22+
}
23+
24+
@(test)
25+
test_entity_generation :: proc(t: ^testing.T) {
26+
world := ecs.create_world()
27+
defer ecs.destroy_world(world)
28+
29+
e1 := ecs.create_entity(world)
30+
ecs.destroy_entity(world, e1)
31+
32+
e2 := ecs.create_entity(world)
33+
34+
testing.expect(t, e1 != e2, "New entity must have a different ID (generation) even if reusing index")
35+
testing.expect(t, !ecs.is_alive(world, e1), "Old entity handle must remain invalid")
36+
testing.expect(t, ecs.is_alive(world, e2), "New entity handle must be valid")
37+
}
38+
39+
@(test)
40+
test_component_add_get :: proc(t: ^testing.T) {
41+
world := ecs.create_world()
42+
defer ecs.destroy_world(world)
43+
44+
e := ecs.create_entity(world)
45+
ecs.add(world, e, Position{10, 20})
46+
47+
testing.expect(t, ecs.has(world, e, Position), "Entity must have Position component")
48+
49+
pos := ecs.get(world, e, Position)
50+
testing.expect(t, pos != nil, "Get should return a valid pointer")
51+
testing.expect(t, pos.x == 10 && pos.y == 20, "Component data must match initial value")
52+
}
53+
54+
@(test)
55+
test_component_set :: proc(t: ^testing.T) {
56+
world := ecs.create_world()
57+
defer ecs.destroy_world(world)
58+
59+
e := ecs.create_entity(world)
60+
ecs.add(world, e, Position{10, 20})
61+
62+
ecs.set(world, e, Position{30, 40})
63+
64+
pos := ecs.get(world, e, Position)
65+
testing.expect(t, pos.x == 30 && pos.y == 40, "Component data must be updated after set")
66+
}
67+
68+
@(test)
69+
test_component_remove :: proc(t: ^testing.T) {
70+
world := ecs.create_world()
71+
defer ecs.destroy_world(world)
72+
73+
e := ecs.create_entity(world)
74+
ecs.add(world, e, Position{10, 20})
75+
76+
ecs.remove(world, e, Position)
77+
78+
testing.expect(t, !ecs.has(world, e, Position), "Entity must not have Position component after remove")
79+
80+
record := world.entity_index[u32(e)]
81+
testing.expect(t, record.archetype == nil, "Entity archetype must be nil when empty")
82+
}
83+
84+
@(test)
85+
test_add_tag_type :: proc(t: ^testing.T) {
86+
world := ecs.create_world()
87+
defer ecs.destroy_world(world)
88+
89+
e := ecs.create_entity(world)
90+
91+
ecs.add(world, e, Tag)
92+
93+
val := ecs.get(world, e, Tag)
94+
testing.expect(t, val == nil, "Get must return nil for tags (zero-sized components)")
95+
96+
testing.expect(t, ecs.has(world, e, Tag), "Has must return true for tag")
97+
}
98+
99+
@(test)
100+
test_add_zero_sized_value :: proc(t: ^testing.T) {
101+
world := ecs.create_world()
102+
defer ecs.destroy_world(world)
103+
104+
e := ecs.create_entity(world)
105+
106+
ecs.add(world, e, Tag{})
107+
108+
val := ecs.get(world, e, Tag)
109+
testing.expect(t, val == nil, "Get must return nil for zero-sized value")
110+
111+
testing.expect(t, ecs.has(world, e, Tag), "Has must return true for zero-sized value")
112+
}
113+
114+
@(test)
115+
test_remove_tag :: proc(t: ^testing.T) {
116+
world := ecs.create_world()
117+
defer ecs.destroy_world(world)
118+
119+
e := ecs.create_entity(world)
120+
ecs.add(world, e, Tag)
121+
122+
testing.expect(t, ecs.has(world, e, Tag), "Entity must have tag initially")
123+
124+
ecs.remove(world, e, Tag)
125+
126+
testing.expect(t, !ecs.has(world, e, Tag), "Has must return false after removing tag")
127+
}

tests/iterator.odin

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package tests
2+
3+
import "core:testing"
4+
import "../ecs"
5+
6+
ValA :: struct { x: int }
7+
ValB :: struct { x: int }
8+
ValC :: struct { x: int }
9+
10+
@(test)
11+
test_iterator_single :: proc(t: ^testing.T) {
12+
world := ecs.create_world()
13+
defer ecs.destroy_world(world)
14+
15+
for i in 0..<10 {
16+
e := ecs.create_entity(world)
17+
ecs.add(world, e, ValA{i})
18+
}
19+
20+
q := ecs.query(world, ecs.with(ValA))
21+
22+
Ctx :: struct {
23+
count, sum: int,
24+
}
25+
ctx := Ctx{0, 0}
26+
27+
ecs.each(world, q, proc(e: ecs.Entity, a: ^ValA, c: ^Ctx) {
28+
c.count += 1
29+
c.sum += a.x
30+
}, &ctx)
31+
32+
testing.expect(t, ctx.count == 10, "Should iterate 10 entities")
33+
testing.expect(t, ctx.sum == 45, "Sum of 0..9 should be 45")
34+
}
35+
36+
@(test)
37+
test_iterator_multiple_components :: proc(t: ^testing.T) {
38+
world := ecs.create_world()
39+
defer ecs.destroy_world(world)
40+
41+
e := ecs.create_entity(world)
42+
ecs.add(world, e, ValA{10})
43+
ecs.add(world, e, ValB{20})
44+
ecs.add(world, e, ValC{30})
45+
46+
q := ecs.query(world, ecs.with(ValA), ecs.with(ValB), ecs.with(ValC))
47+
48+
called := false
49+
50+
ecs.each(world, q, proc(e: ecs.Entity, a: ^ValA, b: ^ValB, c: ^ValC, ctx: ^bool) {
51+
ctx^ = true
52+
a.x += 1
53+
b.x += 1
54+
c.x += 1
55+
}, &called)
56+
57+
testing.expect(t, called, "Iterator callback must be called")
58+
59+
val_a := ecs.get(world, e, ValA)
60+
testing.expect(t, val_a.x == 11, "Value A must be updated")
61+
62+
val_b := ecs.get(world, e, ValB)
63+
testing.expect(t, val_b.x == 21, "Value B must be updated")
64+
65+
val_c := ecs.get(world, e, ValC)
66+
testing.expect(t, val_c.x == 31, "Value C must be updated")
67+
}
68+
69+
@(test)
70+
test_iterator_multiple_archetypes :: proc(t: ^testing.T) {
71+
world := ecs.create_world()
72+
defer ecs.destroy_world(world)
73+
74+
for i in 0..<5 {
75+
e := ecs.create_entity(world)
76+
ecs.add(world, e, ValA{1})
77+
}
78+
79+
for i in 0..<5 {
80+
e := ecs.create_entity(world)
81+
ecs.add(world, e, ValA{1})
82+
ecs.add(world, e, ValB{2})
83+
}
84+
85+
q := ecs.query(world, ecs.with(ValA))
86+
87+
count := 0
88+
ecs.each(world, q, proc(e: ecs.Entity, a: ^ValA, ctx: ^int) {
89+
ctx^ += 1
90+
}, &count)
91+
92+
testing.expect(t, count == 10, "Iterator should cover all archetypes containing ValA")
93+
}
94+
95+
@(test)
96+
test_iterator_optional_component :: proc(t: ^testing.T) {
97+
world := ecs.create_world()
98+
defer ecs.destroy_world(world)
99+
100+
e1 := ecs.create_entity(world)
101+
ecs.add(world, e1, ValA{10})
102+
103+
e2 := ecs.create_entity(world)
104+
ecs.add(world, e2, ValA{20})
105+
ecs.add(world, e2, ValB{30})
106+
107+
q := ecs.query(world, ecs.with(ValA))
108+
109+
Check :: struct {
110+
found_nil, found_val: bool
111+
}
112+
113+
ctx := Check{false, false}
114+
115+
ecs.each(world, q, proc(e: ecs.Entity, a: ^ValA, b: ^ValB, c: ^Check) {
116+
if b == nil {
117+
c.found_nil = true
118+
} else {
119+
c.found_val = true
120+
b.x += 1
121+
}
122+
}, &ctx)
123+
124+
testing.expect(t, ctx.found_nil, "Should handle missing optional component as nil")
125+
testing.expect(t, ctx.found_val, "Should handle existing optional component correctly")
126+
}
127+
128+
@(test)
129+
test_iterator_no_context :: proc(t: ^testing.T) {
130+
world := ecs.create_world()
131+
defer ecs.destroy_world(world)
132+
133+
e := ecs.create_entity(world)
134+
ecs.add(world, e, ValA{1})
135+
136+
q := ecs.query(world, ecs.with(ValA))
137+
138+
ecs.each(world, q, proc(e: ecs.Entity, a: ^ValA) {
139+
a.x = 999
140+
})
141+
142+
val := ecs.get(world, e, ValA)
143+
testing.expect(t, val.x == 999, "Iterator without context should work")
144+
}

0 commit comments

Comments
 (0)