Repository: mlange-42/arche
Pull request #377 (2024-02-11 20:26:27)

Better errors, better error tests, build tag `debug`
* Tests all panics for the concrete error message
* Adds build tag `debug` for better error messages on queries, where we normally rely on out of bounds or nil pointer dereference
* Documents build tags

coveralls: ## Pull Request Test Coverage Report for [Build 7864495602](https://coveralls.io/builds/65628910)


* **0** of **8**   **(100.0%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/65628910/badge)](https://coveralls.io/builds/65628910) |
| :-- | --: |
| Change from base [Build 7863623919](https://coveralls.io/builds/65628191): |  0.0% |
| Covered Lines: | 6233 |
| Relevant Lines: | 6233 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e0c6e1..1be6eec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,13 @@
 ### Features
 
 * Adds method `Query.EntityAt()`, useful for things like random sampling of entities (#358)
+* Adds build tag `debug` to improve error messages in a few places where we rely on standard library panics for performance (#377)
 
 ### Documentation
 
 * Adds [BENCHMARKS.md](https://github.com/mlange-42/arche/blob/main/BENCHMARKS.md) for a tabular overview of the runtime cost of typical *Arche* ECS operations (#367, #372)
 * Link benchmarking code in `README.md` and benchmarking tables (#375)
+* Documents build tags `tiny` and `debug` in package docs of `ecs` (#377)
 
 ### Bugfixes
 
@@ -21,6 +23,7 @@
 * Adds benchmarks for query creation (#366)
 * Upgrade to Go 1.22 in CI (#376)
 * Renames directory `examples` to `_examples` to accommodate changed test coverage behaviour of Go 1.22 (#376)
+* In unit tests, error messages of all panics are asserted (#377)
 
 ## [[v0.10.1]](https://github.com/mlange-42/arche/compare/v0.10.0...v0.10.1)
 
diff --git a/ecs/cache.go b/ecs/cache.go
index abe8171..189ccd0 100644
--- a/ecs/cache.go
+++ b/ecs/cache.go
@@ -45,7 +45,7 @@ func newCache() Cache {
 //	query := world.Query(&cached)
 func (c *Cache) Register(f Filter) CachedFilter {
 	if _, ok := f.(*CachedFilter); ok {
-		panic("filter is already cached")
+		panic("filter is already registered")
 	}
 	id := c.intPool.Get()
 	c.filters = append(c.filters,
diff --git a/ecs/doc.go b/ecs/doc.go
index b7b57af..600c66f 100644
--- a/ecs/doc.go
+++ b/ecs/doc.go
@@ -54,4 +54,14 @@
 //   - Add components: [Relations.ExchangeBatch], [Relations.ExchangeBatchQ]
 //   - Remove components: [Relations.ExchangeBatch], [Relations.ExchangeBatchQ]
 //   - Exchange components: [Relations.ExchangeBatch], [Relations.ExchangeBatchQ]
+//
+// # Build tags
+//
+// Arche provides 2 build tags:
+//   - tiny -- Reduces the maximum number of components to 64, giving a performance boost for mask-related operations.
+//   - debug -- Improves error messages on [Query] misuse, at the cost of performance.
+//
+// Usage:
+//
+//	go build -tags tiny .
 package ecs
diff --git a/ecs/query.go b/ecs/query.go
index be9aa4f..72d7a5b 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -9,6 +9,9 @@ import (
 //
 // Create queries through the [World] using [World.Query]. See there for more details.
 //
+// In case you get error messages like "index out of range [-1]" or "invalid memory address or nil pointer dereference",
+// try running with build tag `debug`.
+//
 // See also the generic alternatives [github.com/mlange-42/arche/generic.Query1],
 // [github.com/mlange-42/arche/generic.Query2], etc.
 // For advanced filtering, see package [github.com/mlange-42/arche/filter].
@@ -78,6 +81,7 @@ func newBatchQuery(world *World, lockBit uint8, archetype *batchArchetypes) Quer
 //
 // Returns false if no next entity could be found.
 func (q *Query) Next() bool {
+	q.checkNext()
 	if q.entityIndex < q.entityIndexMax {
 		q.entityIndex++
 		return true
@@ -88,16 +92,19 @@ func (q *Query) Next() bool {
 
 // Has returns whether the current entity has the given component.
 func (q *Query) Has(comp ID) bool {
+	q.checkGet()
 	return q.access.HasComponent(comp)
 }
 
 // Get returns the pointer to the given component at the iterator's position.
 func (q *Query) Get(comp ID) unsafe.Pointer {
+	q.checkGet()
 	return q.access.Get(q.entityIndex, comp)
 }
 
 // Entity returns the entity at the iterator's position.
 func (q *Query) Entity() Entity {
+	q.checkGet()
 	return q.access.GetEntity(q.entityIndex)
 }
 
@@ -129,6 +136,7 @@ func (q *Query) EntityAt(index int) Entity {
 //
 // Panics if the entity does not have the given component, or if the component is not a [Relation].
 func (q *Query) Relation(comp ID) Entity {
+	q.checkGet()
 	if q.access.RelationComponent.id != comp.id {
 		panic(fmt.Sprintf("entity has no component %v, or it is not a relation component", q.world.registry.Types[comp.id]))
 	}
diff --git a/ecs/query_debug.go b/ecs/query_debug.go
new file mode 100644
index 0000000..53b5880
--- /dev/null
+++ b/ecs/query_debug.go
@@ -0,0 +1,17 @@
+//go:build debug
+
+package ecs
+
+const isDebug = true
+
+func (q *Query) checkNext() {
+	if q.nodeIndex < -1 {
+		panic("query iteration already finished")
+	}
+}
+
+func (q *Query) checkGet() {
+	if q.access == nil {
+		panic("query already iterated or iteration not started yet")
+	}
+}
diff --git a/ecs/query_no_debug.go b/ecs/query_no_debug.go
new file mode 100644
index 0000000..42a867c
--- /dev/null
+++ b/ecs/query_no_debug.go
@@ -0,0 +1,9 @@
+//go:build !debug
+
+package ecs
+
+const isDebug = false
+
+func (q *Query) checkNext() {}
+
+func (q *Query) checkGet() {}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c93f831..58226ff 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -69,7 +69,25 @@ jobs:
         go get .
     - name: Run Unit tests (tiny)
       run: |
-        go test -v -covermode atomic -coverprofile="coverage.out" ./...
+        go test -tags tiny -v -covermode atomic -coverprofile="coverage.out" ./...
+        go tool cover -func="coverage.out"
+
+  test_debug:
+    name: Run tests (debug)
+    runs-on: ubuntu-latest
+    steps:
+    - name: Set up Go
+      uses: actions/setup-go@v2
+      with:
+        go-version: '1.22.x'
+    - name: Check out code
+      uses: actions/checkout@v2
+    - name: Install dependencies
+      run: |
+        go get .
+    - name: Run Unit tests (debug)
+      run: |
+        go test -tags debug -v -covermode atomic -coverprofile="coverage.out" ./...
         go tool cover -func="coverage.out"
 
   lint:
diff --git a/ecs/archetype_test.go b/ecs/archetype_test.go
index 10c74c7..ade52dc 100644
--- a/ecs/archetype_test.go
+++ b/ecs/archetype_test.go
@@ -58,7 +58,7 @@ func TestArchetype(t *testing.T) {
 	assert.Equal(t, 5, pos0.Y)
 	assert.Equal(t, 6, rot0.Angle)
 
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "Invalid number of components", func() {
 		arch.Add(
 			newEntity(1),
 			Component{ID: id(0), Comp: &Position{4, 5}},
@@ -87,7 +87,7 @@ func TestNewArchetype(t *testing.T) {
 		{ID: id(1), Type: reflect.TypeOf(rotation{})},
 		{ID: id(0), Type: reflect.TypeOf(Position{})},
 	}
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "component arguments must be sorted by ID", func() {
 		node := newArchNode(All(id(0), id(1)), &nodeData{}, ID{}, false, 32, comps)
 		arch := archetype{}
 		data := archetypeData{}
diff --git a/ecs/builder_test.go b/ecs/builder_test.go
index 32fdd5f..a8ec99c 100644
--- a/ecs/builder_test.go
+++ b/ecs/builder_test.go
@@ -25,10 +25,10 @@ func TestBuilder(t *testing.T) {
 	e2 := w.NewEntity()
 	b1.Add(e2)
 
-	assert.Panics(t, func() { b1.New(target) })
-	assert.Panics(t, func() { b1.NewBatch(10, target) })
-	assert.Panics(t, func() { b1.NewBatchQ(10, target) })
-	assert.Panics(t, func() { b1.Add(e1, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.New(target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.NewBatch(10, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.NewBatchQ(10, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.Add(e1, target) })
 
 	b1.NewBatch(10)
 	q := b1.NewBatchQ(10)
@@ -44,10 +44,10 @@ func TestBuilder(t *testing.T) {
 	b1.Add(e2)
 	e2 = w.NewEntity()
 
-	assert.Panics(t, func() { b1.New(target) })
-	assert.Panics(t, func() { b1.NewBatch(10, target) })
-	assert.Panics(t, func() { b1.NewBatchQ(10, target) })
-	assert.Panics(t, func() { b1.Add(e2, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.New(target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.NewBatch(10, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.NewBatchQ(10, target) })
+	assert.PanicsWithValue(t, "can't set target entity: builder has no relation", func() { b1.Add(e2, target) })
 
 	b1.NewBatch(10)
 	q = b1.NewBatchQ(10)
diff --git a/ecs/cache_test.go b/ecs/cache_test.go
index 770bfbb..67952e6 100644
--- a/ecs/cache_test.go
+++ b/ecs/cache_test.go
@@ -29,7 +29,7 @@ func TestFilterCache(t *testing.T) {
 	assert.Equal(t, 2, len(world.getArchetypes(&f1)))
 	assert.Equal(t, 1, len(world.getArchetypes(&f2)))
 
-	assert.Panics(t, func() { cache.Register(&f2) })
+	assert.PanicsWithValue(t, "filter is already registered", func() { cache.Register(&f2) })
 
 	e1 := cache.get(&f1)
 	e2 := cache.get(&f2)
@@ -43,8 +43,8 @@ func TestFilterCache(t *testing.T) {
 	assert.Equal(t, all1, ff1)
 	assert.Equal(t, all2, ff2)
 
-	assert.Panics(t, func() { cache.Unregister(&f1) })
-	assert.Panics(t, func() { cache.get(&f1) })
+	assert.PanicsWithValue(t, "no filter for id found to unregister", func() { cache.Unregister(&f1) })
+	assert.PanicsWithValue(t, "no filter for id found", func() { cache.get(&f1) })
 }
 
 func TestFilterCacheRelation(t *testing.T) {
diff --git a/ecs/pool_test.go b/ecs/pool_test.go
index e82d658..f1b42b7 100644
--- a/ecs/pool_test.go
+++ b/ecs/pool_test.go
@@ -23,7 +23,7 @@ func TestEntityPool(t *testing.T) {
 	}
 	assert.Equal(t, expectedAll, p.entities, "Wrong initial entities")
 
-	assert.Panics(t, func() { p.Recycle(p.entities[0]) })
+	assert.PanicsWithValue(t, "can't recycle reserved zero entity", func() { p.Recycle(p.entities[0]) })
 
 	e0 := p.entities[1]
 	p.Recycle(e0)
diff --git a/ecs/query_test.go b/ecs/query_test.go
index 369cc94..ae4d275 100644
--- a/ecs/query_test.go
+++ b/ecs/query_test.go
@@ -78,7 +78,11 @@ func TestQuery(t *testing.T) {
 	}
 	assert.Equal(t, 4, cnt)
 
-	assert.Panics(t, func() { q.Next() })
+	if isDebug {
+		assert.PanicsWithValue(t, "query iteration already finished", func() { q.Next() })
+	} else {
+		assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { q.Next() })
+	}
 
 	filter := All(rotID).Without(posID)
 	q = w.Query(&filter)
@@ -361,9 +365,9 @@ func TestQueryStep(t *testing.T) {
 	assert.Equal(t, Entity{10, 0}, q.Entity())
 
 	q = w.Query(All(posID))
-	assert.Panics(t, func() { q.Step(0) })
+	assert.PanicsWithValue(t, "step size must be positive", func() { q.Step(0) })
 	q.Step(2)
-	assert.Panics(t, func() { q.Step(0) })
+	assert.PanicsWithValue(t, "step size must be positive", func() { q.Step(0) })
 
 	q = w.Query(All(posID))
 	cnt = 0
@@ -389,13 +393,23 @@ func TestQueryClosed(t *testing.T) {
 	w.Add(e2, posID, rotID)
 
 	q := w.Query(All(posID, rotID))
-	assert.Panics(t, func() { q.Entity() })
-	assert.Panics(t, func() { q.Get(posID) })
-
+	if isDebug {
+		assert.PanicsWithValue(t, "query already iterated or iteration not started yet", func() { q.Entity() })
+		assert.PanicsWithValue(t, "query already iterated or iteration not started yet", func() { q.Get(posID) })
+	} else {
+		assert.PanicsWithError(t, "runtime error: invalid memory address or nil pointer dereference", func() { q.Entity() })
+		assert.PanicsWithError(t, "runtime error: invalid memory address or nil pointer dereference", func() { q.Get(posID) })
+	}
 	q.Close()
-	assert.Panics(t, func() { q.Entity() })
-	assert.Panics(t, func() { q.Get(posID) })
-	assert.Panics(t, func() { q.Next() })
+	if isDebug {
+		assert.PanicsWithValue(t, "query already iterated or iteration not started yet", func() { q.Entity() })
+		assert.PanicsWithValue(t, "query already iterated or iteration not started yet", func() { q.Get(posID) })
+		assert.PanicsWithValue(t, "query iteration already finished", func() { q.Next() })
+	} else {
+		assert.PanicsWithError(t, "runtime error: invalid memory address or nil pointer dereference", func() { q.Entity() })
+		assert.PanicsWithError(t, "runtime error: invalid memory address or nil pointer dereference", func() { q.Get(posID) })
+		assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { q.Next() })
+	}
 
 	assert.True(t, w.locks.locks.IsZero())
 
@@ -407,7 +421,12 @@ func TestQueryClosed(t *testing.T) {
 	}
 	assert.True(t, w.locks.locks.IsZero())
 	assert.Equal(t, 2, cnt)
-	assert.Panics(t, func() { q.Next() })
+
+	if isDebug {
+		assert.PanicsWithValue(t, "query iteration already finished", func() { q.Next() })
+	} else {
+		assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { q.Next() })
+	}
 
 	cnt = 0
 	excl := All(rotID).Exclusive()
@@ -418,7 +437,12 @@ func TestQueryClosed(t *testing.T) {
 	}
 	assert.True(t, w.locks.locks.IsZero())
 	assert.Equal(t, 0, cnt)
-	assert.Panics(t, func() { q.Next() })
+
+	if isDebug {
+		assert.PanicsWithValue(t, "query iteration already finished", func() { q.Next() })
+	} else {
+		assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { q.Next() })
+	}
 
 	cnt = 0
 	excl = All(posID).Exclusive()
@@ -429,7 +453,12 @@ func TestQueryClosed(t *testing.T) {
 	}
 	assert.True(t, w.locks.locks.IsZero())
 	assert.Equal(t, 1, cnt)
-	assert.Panics(t, func() { q.Next() })
+
+	if isDebug {
+		assert.PanicsWithValue(t, "query iteration already finished", func() { q.Next() })
+	} else {
+		assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { q.Next() })
+	}
 }
 
 func TestQueryNextArchetype(t *testing.T) {
@@ -447,7 +476,9 @@ func TestQueryNextArchetype(t *testing.T) {
 
 	assert.True(t, query.nextArchetype())
 	assert.False(t, query.nextArchetype())
-	assert.Panics(t, func() { query.nextArchetype() })
+
+	assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { query.nextArchetype() })
+
 }
 
 func TestQueryRelations(t *testing.T) {
@@ -472,9 +503,12 @@ func TestQueryRelations(t *testing.T) {
 		assert.Equal(t, targ, targ2)
 		assert.Equal(t, targ, query.relationUnchecked(relID))
 
-		assert.Panics(t, func() { query.Relation(rel2ID) })
-		assert.Panics(t, func() { query.Relation(posID) })
-		assert.Panics(t, func() { query.Relation(velID) })
+		assert.PanicsWithValue(t, "entity has no component ecs.testRelationB, or it is not a relation component",
+			func() { query.Relation(rel2ID) })
+		assert.PanicsWithValue(t, "entity has no component ecs.Position, or it is not a relation component",
+			func() { query.Relation(posID) })
+		assert.PanicsWithValue(t, "entity has no component ecs.Velocity, or it is not a relation component",
+			func() { query.Relation(velID) })
 	}
 }
 
@@ -521,11 +555,11 @@ func TestQueryEntityAt(t *testing.T) {
 
 	query := world.Query(All())
 
-	assert.Panics(t, func() { query.EntityAt(-1) })
+	assert.PanicsWithValue(t, "can't get entity at negative index", func() { query.EntityAt(-1) })
 	for i := 0; i < 52; i++ {
 		assert.Equal(t, Entity{eid(i + 1), 0}, query.EntityAt(i))
 	}
-	assert.Panics(t, func() { query.EntityAt(52) })
+	assert.PanicsWithValue(t, "query index out of range: index 52, length 52", func() { query.EntityAt(52) })
 	query.Close()
 
 	relFilter := NewRelationFilter(All(relID), parent2)
@@ -533,14 +567,14 @@ func TestQueryEntityAt(t *testing.T) {
 	for i := 0; i < 10; i++ {
 		assert.Equal(t, Entity{eid(i + 13), 0}, query.EntityAt(i))
 	}
-	assert.Panics(t, func() { query.EntityAt(10) })
+	assert.PanicsWithValue(t, "query index out of range: index 10, length 10", func() { query.EntityAt(10) })
 	query.Close()
 
 	query = world.Query(All(relID))
 	for i := 0; i < 40; i++ {
 		assert.Equal(t, Entity{eid(i + 13), 0}, query.EntityAt(i))
 	}
-	assert.Panics(t, func() { query.EntityAt(40) })
+	assert.PanicsWithValue(t, "query index out of range: index 40, length 40", func() { query.EntityAt(40) })
 	query.Close()
 
 	query = world.Batch().ExchangeQ(All(posID), nil, []ID{posID})
@@ -549,7 +583,7 @@ func TestQueryEntityAt(t *testing.T) {
 	for i := 0; i < 30; i++ {
 		assert.Equal(t, Entity{eid(i + 3), 0}, query.EntityAt(i))
 	}
-	assert.Panics(t, func() { query.EntityAt(30) })
+	assert.PanicsWithValue(t, "query index out of range: index 30, length 30", func() { query.EntityAt(30) })
 	query.Close()
 
 	f := All(relID)
@@ -559,7 +593,7 @@ func TestQueryEntityAt(t *testing.T) {
 	for i := 0; i < 40; i++ {
 		assert.Equal(t, (i+3)%10, int(query.EntityAt(i).id)%10)
 	}
-	assert.Panics(t, func() { query.EntityAt(40) })
+	assert.PanicsWithValue(t, "query index out of range: index 40, length 40", func() { query.EntityAt(40) })
 	query.Close()
 }
 
diff --git a/ecs/registry_test.go b/ecs/registry_test.go
index b32ba4d..45ba0ab 100644
--- a/ecs/registry_test.go
+++ b/ecs/registry_test.go
@@ -39,7 +39,7 @@ func TestComponentRegistryOverflow(t *testing.T) {
 
 	reg.registerComponent(reflect.TypeOf((*Position)(nil)).Elem(), 1)
 
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "exceeded the maximum of 1 component types or resource types", func() {
 		reg.registerComponent(reflect.TypeOf((*rotation)(nil)).Elem(), 1)
 	})
 }
diff --git a/ecs/relations_test.go b/ecs/relations_test.go
index 3f0fdb0..d4fcf5f 100644
--- a/ecs/relations_test.go
+++ b/ecs/relations_test.go
@@ -26,16 +26,16 @@ func TestRelationsExchange(t *testing.T) {
 	query.Close()
 
 	filter := ecs.All(posID, childID)
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "can't add relation: resulting entity has no component Velocity", func() {
 		w.Relations().ExchangeBatch(filter, nil, []ecs.ID{posID}, velID, parent2)
 	})
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "can't add relation: Velocity is not a relation component", func() {
 		w.Relations().ExchangeBatch(filter, []ecs.ID{velID}, []ecs.ID{posID}, velID, parent2)
 	})
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "exchange operation has no effect, but a relation is specified. Use Batch.SetRelation instead", func() {
 		w.Relations().ExchangeBatch(filter, nil, nil, childID, parent2)
 	})
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "exchange operation has no effect, but a relation is specified. Use Batch.SetRelation instead", func() {
 		_ = w.Relations().ExchangeBatchQ(filter, nil, nil, childID, parent2)
 	})
 
diff --git a/ecs/resources_test.go b/ecs/resources_test.go
index 56765c9..775a348 100644
--- a/ecs/resources_test.go
+++ b/ecs/resources_test.go
@@ -26,7 +26,7 @@ func TestResources(t *testing.T) {
 	assert.True(t, ok)
 	assert.Equal(t, Position{1, 2}, *pos)
 
-	assert.Panics(t, func() { res.Add(posID, &Position{1, 2}) })
+	assert.PanicsWithValue(t, "Resource of ID 0 was already added (type *ecs.Position)", func() { res.Add(posID, &Position{1, 2}) })
 
 	pos, ok = res.Get(posID).(*Position)
 	assert.True(t, ok)
@@ -36,7 +36,7 @@ func TestResources(t *testing.T) {
 	assert.True(t, res.Has(rotID))
 	res.Remove(rotID)
 	assert.False(t, res.Has(rotID))
-	assert.Panics(t, func() { res.Remove(rotID) })
+	assert.PanicsWithValue(t, "Resource of ID 1 is not present", func() { res.Remove(rotID) })
 }
 
 func TestResourcesReset(t *testing.T) {
diff --git a/ecs/util_test.go b/ecs/util_test.go
index 8e5e802..45b82f6 100644
--- a/ecs/util_test.go
+++ b/ecs/util_test.go
@@ -45,7 +45,8 @@ func TestLockMask(t *testing.T) {
 	locks.Unlock(l1)
 	assert.True(t, locks.IsLocked())
 
-	assert.Panics(t, func() { locks.Unlock(l1) })
+	assert.PanicsWithValue(t, "unbalanced unlock. Did you close a query that was already iterated?",
+		func() { locks.Unlock(l1) })
 
 	locks.Unlock(l2)
 	assert.False(t, locks.IsLocked())
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 6f403e4..f1787c8 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -14,8 +14,10 @@ import (
 func TestWorldConfig(t *testing.T) {
 	_ = NewWorld(NewConfig())
 
-	assert.Panics(t, func() { _ = NewWorld(Config{}) })
-	assert.Panics(t, func() { _ = NewWorld(Config{}, Config{}) })
+	assert.PanicsWithValue(t, "invalid CapacityIncrement in config, must be > 0",
+		func() { _ = NewWorld(Config{}) })
+	assert.PanicsWithValue(t, "can't use more than one Config",
+		func() { _ = NewWorld(Config{}, Config{}) })
 
 	world := NewWorld(
 		NewConfig().WithCapacityIncrement(32).WithRelationCapacityIncrement(8),
@@ -57,9 +59,9 @@ func TestWorldEntites(t *testing.T) {
 	w.RemoveEntity(newEntityGen(2, 1))
 	w.RemoveEntity(newEntityGen(1, 0))
 
-	assert.Panics(t, func() { w.RemoveEntity(newEntityGen(3, 0)) })
-	assert.Panics(t, func() { w.RemoveEntity(newEntityGen(2, 1)) })
-	assert.Panics(t, func() { w.RemoveEntity(newEntityGen(1, 0)) })
+	assert.PanicsWithValue(t, "can't remove a dead entity", func() { w.RemoveEntity(newEntityGen(3, 0)) })
+	assert.PanicsWithValue(t, "can't remove a dead entity", func() { w.RemoveEntity(newEntityGen(2, 1)) })
+	assert.PanicsWithValue(t, "can't remove a dead entity", func() { w.RemoveEntity(newEntityGen(1, 0)) })
 }
 
 func TestWorldNewEntites(t *testing.T) {
@@ -179,8 +181,8 @@ func TestWorldComponents(t *testing.T) {
 	w.Remove(e0)
 
 	w.RemoveEntity(e0)
-	assert.Panics(t, func() { w.Has(newEntityGen(1, 0), posID) })
-	assert.Panics(t, func() { w.Get(newEntityGen(1, 0), posID) })
+	assert.PanicsWithValue(t, "can't check for component of a dead entity", func() { w.Has(newEntityGen(1, 0), posID) })
+	assert.PanicsWithValue(t, "can't get component of a dead entity", func() { w.Get(newEntityGen(1, 0), posID) })
 }
 
 func TestWorldIds(t *testing.T) {
@@ -198,7 +200,7 @@ func TestWorldIds(t *testing.T) {
 
 	w.RemoveEntity(e1)
 
-	assert.Panics(t, func() { _ = w.Ids(e1) })
+	assert.PanicsWithValue(t, "can't get component IDs for a dead entity", func() { _ = w.Ids(e1) })
 }
 
 func TestWorldLabels(t *testing.T) {
@@ -267,21 +269,27 @@ func TestWorldExchange(t *testing.T) {
 	assert.True(t, w.Has(e1, rotID))
 	assert.True(t, w.Has(e1, velID))
 
-	assert.Panics(t, func() { w.Exchange(e1, []ID{velID}, []ID{}) })
-	assert.Panics(t, func() { w.Exchange(e1, []ID{}, []ID{posID}) })
+	assert.PanicsWithValue(t, "entity already has component of type ecs.Velocity, can't add",
+		func() { w.Exchange(e1, []ID{velID}, []ID{}) })
+	assert.PanicsWithValue(t, "entity does not have a component of type ecs.Position, can't remove",
+		func() { w.Exchange(e1, []ID{}, []ID{posID}) })
 
 	w.RemoveEntity(e0)
 	_ = w.NewEntity()
-	assert.Panics(t, func() { w.Exchange(e0, []ID{posID}, []ID{}) })
+	assert.PanicsWithValue(t, "can't exchange components on a dead entity",
+		func() { w.Exchange(e0, []ID{posID}, []ID{}) })
 
 	target := w.NewEntity()
 	e0 = w.NewEntity(rel1ID)
 
-	assert.Panics(t, func() { w.exchange(e0, []ID{rel2ID}, nil, rel2ID, true, target) })
-	assert.Panics(t, func() { w.exchange(e0, []ID{posID}, nil, posID, true, target) })
+	assert.PanicsWithValue(t, "entity already has a relation component",
+		func() { w.exchange(e0, []ID{rel2ID}, nil, rel2ID, true, target) })
+	assert.PanicsWithValue(t, "can't add relation: Position is not a relation component",
+		func() { w.exchange(e0, []ID{posID}, nil, posID, true, target) })
 
 	w.Remove(e0, rel1ID)
-	assert.Panics(t, func() { w.exchange(e0, []ID{posID}, nil, rel1ID, true, target) })
+	assert.PanicsWithValue(t, "can't add relation: resulting entity has no component testRelationA",
+		func() { w.exchange(e0, []ID{posID}, nil, rel1ID, true, target) })
 }
 
 func TestWorldExchangeRelation(t *testing.T) {
@@ -300,9 +308,10 @@ func TestWorldExchangeRelation(t *testing.T) {
 	assert.Equal(t, []ID{posID, rel1ID}, w.Ids(e0))
 	assert.Equal(t, e1, w.Relations().Get(e0, rel1ID))
 
-	assert.Panics(t, func() {
-		w.Relations().Exchange(e0, nil, nil, rel1ID, e2)
-	})
+	assert.PanicsWithValue(t, "exchange operation has no effect, but a relation is specified. Use World.Relation instead",
+		func() {
+			w.Relations().Exchange(e0, nil, nil, rel1ID, e2)
+		})
 
 	w.Relations().Exchange(e0, []ID{rel2ID}, []ID{rel1ID}, rel2ID, e2)
 	assert.Equal(t, []ID{posID, rel2ID}, w.Ids(e0))
@@ -460,7 +469,7 @@ func TestWorldAssignSet(t *testing.T) {
 	e0 := w.NewEntity()
 	e1 := w.NewEntity()
 
-	assert.Panics(t, func() { w.Assign(e0) })
+	assert.PanicsWithValue(t, "no components given to assign", func() { w.Assign(e0) })
 
 	w.Assign(e0, Component{posID, &Position{2, 3}})
 	pos := (*Position)(w.Get(e0, posID))
@@ -470,8 +479,8 @@ func TestWorldAssignSet(t *testing.T) {
 	pos = (*Position)(w.Get(e0, posID))
 	assert.Equal(t, 5, pos.X)
 
-	assert.Panics(t, func() { w.Assign(e0, Component{posID, &Position{2, 3}}) })
-	assert.Panics(t, func() { _ = (*Position)(w.copyTo(e1, posID, &Position{2, 3})) })
+	assert.PanicsWithValue(t, "entity already has component of type ecs.Position, can't add", func() { w.Assign(e0, Component{posID, &Position{2, 3}}) })
+	assert.PanicsWithValue(t, "can't copy component into entity that has no such component type", func() { _ = (*Position)(w.copyTo(e1, posID, &Position{2, 3})) })
 
 	e2 := w.NewEntity()
 	w.Assign(e2,
@@ -500,7 +509,7 @@ func TestWorldAssignSet(t *testing.T) {
 
 	w.RemoveEntity(e0)
 	_ = w.NewEntity()
-	assert.Panics(t, func() { w.Assign(e0, Component{posID, &Position{2, 3}}) })
+	assert.PanicsWithValue(t, "can't exchange components on a dead entity", func() { w.Assign(e0, Component{posID, &Position{2, 3}}) })
 }
 
 func TestWorldGetComponents(t *testing.T) {
@@ -539,12 +548,12 @@ func TestWorldGetComponents(t *testing.T) {
 	assert.Equal(t, &Position{100, 101}, pos1)
 
 	w.RemoveEntity(e0)
-	assert.Panics(t, func() { w.Get(e0, posID) })
-	assert.Panics(t, func() { w.Mask(e0) })
+	assert.PanicsWithValue(t, "can't get component of a dead entity", func() { w.Get(e0, posID) })
+	assert.PanicsWithValue(t, "can't get mask for a dead entity", func() { w.Mask(e0) })
 
 	_ = w.NewEntity(posID)
-	assert.Panics(t, func() { w.Get(e0, posID) })
-	assert.Panics(t, func() { w.Mask(e0) })
+	assert.PanicsWithValue(t, "can't get component of a dead entity", func() { w.Get(e0, posID) })
+	assert.PanicsWithValue(t, "can't get mask for a dead entity", func() { w.Mask(e0) })
 
 	pos1 = (*Position)(w.Get(e1, posID))
 	assert.Equal(t, &Position{100, 101}, pos1)
@@ -559,11 +568,13 @@ func TestWorldDuplicateComponents(t *testing.T) {
 	posID := ComponentID[Position](&w)
 	//rotID := ComponentID[rotation](&w)
 
-	assert.Panics(t, func() { w.NewEntity(posID, posID) })
-
+	assert.PanicsWithValue(t, "entity already has component of type ecs.Position, or it was added twice",
+		func() { w.NewEntity(posID, posID) })
 	e := w.NewEntity(posID)
-	assert.Panics(t, func() { w.Remove(e, posID, posID) })
-	assert.Panics(t, func() { w.Exchange(e, []ID{posID}, []ID{posID}) })
+	assert.PanicsWithValue(t, "entity does not have a component of type ecs.Position, can't remove",
+		func() { w.Remove(e, posID, posID) })
+	assert.PanicsWithValue(t, "component of type ecs.Position added and removed in the same exchange operation",
+		func() { w.Exchange(e, []ID{posID}, []ID{posID}) })
 }
 
 func TestWorldIter(t *testing.T) {
@@ -588,7 +599,12 @@ func TestWorldIter(t *testing.T) {
 			cnt++
 		}
 		assert.Equal(t, 1000, cnt)
-		assert.Panics(t, func() { query.Next() })
+
+		if isDebug {
+			assert.PanicsWithValue(t, "query iteration already finished", func() { query.Next() })
+		} else {
+			assert.PanicsWithError(t, "runtime error: index out of range [-1]", func() { query.Next() })
+		}
 	}
 
 	for i := 0; i < MaskTotalBits-1; i++ {
@@ -604,7 +620,7 @@ func TestWorldIter(t *testing.T) {
 	assert.Panics(t, func() { world.Query(All(posID, rotID)) })
 
 	query.Close()
-	assert.Panics(t, func() { query.Close() })
+	assert.PanicsWithValue(t, "unbalanced unlock. Did you close a query that was already iterated?", func() { query.Close() })
 }
 
 func TestWorldNewEntities(t *testing.T) {
@@ -623,7 +639,7 @@ func TestWorldNewEntities(t *testing.T) {
 	world.NewEntity(posID, rotID)
 	assert.Equal(t, 2, len(world.entities))
 
-	assert.Panics(t, func() { world.newEntitiesQuery(0, ID{}, false, Entity{}, posID, rotID) })
+	assert.PanicsWithValue(t, "can only create a positive number of entities", func() { world.newEntitiesQuery(0, ID{}, false, Entity{}, posID, rotID) })
 
 	query := world.newEntitiesQuery(100, ID{}, false, Entity{}, posID, rotID)
 	assert.Equal(t, 100, query.Count())
@@ -704,7 +720,7 @@ func TestWorldNewEntitiesWith(t *testing.T) {
 	world.NewEntity(posID, rotID)
 	assert.Equal(t, 1, len(events))
 
-	assert.Panics(t, func() { world.newEntitiesWithQuery(0, ID{}, false, Entity{}, comps...) })
+	assert.PanicsWithValue(t, "can only create a positive number of entities", func() { world.newEntitiesWithQuery(0, ID{}, false, Entity{}, comps...) })
 	assert.Equal(t, 1, len(events))
 
 	query := world.newEntitiesWithQuery(1, ID{}, false, Entity{})
@@ -850,10 +866,14 @@ func TestWorldRelationSet(t *testing.T) {
 		EventTypes:  event.TargetChanged,
 	}, events[len(events)-1])
 
-	assert.Panics(t, func() { world.Relations().Get(e1, rotID) })
-	assert.Panics(t, func() { world.Relations().Get(e1, rel2ID) })
-	assert.Panics(t, func() { world.Relations().Set(e1, rotID, Entity{}) })
-	assert.Panics(t, func() { world.Relations().Set(e1, rel2ID, Entity{}) })
+	assert.PanicsWithValue(t, "not a relation component: ecs.rotation",
+		func() { world.Relations().Get(e1, rotID) })
+	assert.PanicsWithValue(t, "entity does not have relation component ecs.testRelationB",
+		func() { world.Relations().Get(e1, rel2ID) })
+	assert.PanicsWithValue(t, "not a relation component: ecs.rotation",
+		func() { world.Relations().Set(e1, rotID, Entity{}) })
+	assert.PanicsWithValue(t, "entity does not have relation component ecs.testRelationB",
+		func() { world.Relations().Set(e1, rel2ID, Entity{}) })
 
 	// Should do nothing
 	world.Relations().Set(e1, relID, Entity{})
@@ -875,19 +895,26 @@ func TestWorldRelationSet(t *testing.T) {
 		EventTypes:  event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
-	assert.Panics(t, func() { world.Relations().Get(e2, relID) })
-	assert.Panics(t, func() { world.Relations().Set(e2, relID, Entity{}) })
+	assert.PanicsWithValue(t, "entity does not have relation component ecs.testRelationA",
+		func() { world.Relations().Get(e2, relID) })
+	assert.PanicsWithValue(t, "entity does not have relation component ecs.testRelationA",
+		func() { world.Relations().Set(e2, relID, Entity{}) })
 
-	assert.Panics(t, func() { world.NewEntity(relID, rel2ID) })
-	assert.Panics(t, func() { world.Add(e1, rel2ID) })
+	assert.PanicsWithValue(t, "entity already has a relation component",
+		func() { world.NewEntity(relID, rel2ID) })
+	assert.PanicsWithValue(t, "entity already has a relation component",
+		func() { world.Add(e1, rel2ID) })
 
 	world.RemoveEntity(e1)
-	assert.Panics(t, func() { world.Relations().Get(e1, relID) })
-	assert.Panics(t, func() { world.Relations().Set(e1, relID, targ) })
+	assert.PanicsWithValue(t, "can't get relation of a dead entity",
+		func() { world.Relations().Get(e1, relID) })
+	assert.PanicsWithValue(t, "can't set relation for a dead entity",
+		func() { world.Relations().Set(e1, relID, targ) })
 
 	e3 := world.NewEntity(relID, rotID)
 	world.RemoveEntity(targ)
-	assert.Panics(t, func() { world.Relations().Set(e3, relID, targ) })
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target",
+		func() { world.Relations().Set(e3, relID, targ) })
 
 	assert.Equal(t, int32(2), world.nodes.Get(2).archetypes.Len())
 	assert.True(t, world.nodes.Get(2).archetypes.Get(0).IsActive())
@@ -965,7 +992,7 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	world.RemoveEntity(targ3)
 	assert.Equal(t, 1204, len(events))
 
-	assert.Panics(t, func() {
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target", func() {
 		world.Batch().SetRelation(All(relID), relID, targ3)
 	})
 
@@ -1220,32 +1247,36 @@ func TestWorldRelationCreate(t *testing.T) {
 	world.RemoveEntity(dead)
 
 	world.newEntities(5, relID, true, alive, posID, relID)
-	assert.Panics(t, func() { world.newEntitiesNoNotify(5, relID, true, dead, posID, relID) })
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target",
+		func() { world.newEntitiesNoNotify(5, relID, true, dead, posID, relID) })
 
 	world.newEntityTarget(relID, alive, posID, relID)
-	assert.Panics(t, func() { world.newEntityTarget(relID, dead, posID, relID) })
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target",
+		func() { world.newEntityTarget(relID, dead, posID, relID) })
 
 	world.newEntitiesWith(5, relID, true, alive,
 		Component{ID: posID, Comp: &Position{}},
 		Component{ID: relID, Comp: &testRelationA{}},
 	)
-	assert.Panics(t, func() {
-		world.newEntitiesWith(5, relID, true, dead,
-			Component{ID: posID, Comp: &Position{}},
-			Component{ID: relID, Comp: &testRelationA{}},
-		)
-	})
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target",
+		func() {
+			world.newEntitiesWith(5, relID, true, dead,
+				Component{ID: posID, Comp: &Position{}},
+				Component{ID: relID, Comp: &testRelationA{}},
+			)
+		})
 
 	world.newEntityTargetWith(relID, alive,
 		Component{ID: posID, Comp: &Position{}},
 		Component{ID: relID, Comp: &testRelationA{}},
 	)
-	assert.Panics(t, func() {
-		world.newEntityTargetWith(relID, dead,
-			Component{ID: posID, Comp: &Position{}},
-			Component{ID: relID, Comp: &testRelationA{}},
-		)
-	})
+	assert.PanicsWithValue(t, "can't make a dead entity a relation target",
+		func() {
+			world.newEntityTargetWith(relID, dead,
+				Component{ID: posID, Comp: &Position{}},
+				Component{ID: relID, Comp: &testRelationA{}},
+			)
+		})
 }
 
 func TestWorldRelationMove(t *testing.T) {
@@ -1334,10 +1365,10 @@ func TestWorldLock(t *testing.T) {
 
 	query1 = world.Query(All(posID))
 
-	assert.Panics(t, func() { world.NewEntity() })
-	assert.Panics(t, func() { world.RemoveEntity(entity) })
-	assert.Panics(t, func() { world.Add(entity, rotID) })
-	assert.Panics(t, func() { world.Remove(entity, posID) })
+	assert.PanicsWithValue(t, "attempt to modify a locked world", func() { world.NewEntity() })
+	assert.PanicsWithValue(t, "attempt to modify a locked world", func() { world.RemoveEntity(entity) })
+	assert.PanicsWithValue(t, "attempt to modify a locked world", func() { world.Add(entity, rotID) })
+	assert.PanicsWithValue(t, "attempt to modify a locked world", func() { world.Remove(entity, posID) })
 }
 
 func TestWorldStats(t *testing.T) {
@@ -1417,7 +1448,8 @@ func TestWorldResources(t *testing.T) {
 	assert.True(t, ok)
 	assert.Equal(t, Position{1, 2}, *pos)
 
-	assert.Panics(t, func() { w.Resources().Add(posID, &Position{1, 2}) })
+	assert.PanicsWithValue(t, "Resource of ID 0 was already added (type *ecs.Position)",
+		func() { w.Resources().Add(posID, &Position{1, 2}) })
 
 	pos = GetResource[Position](&w)
 	assert.Equal(t, Position{1, 2}, *pos)
@@ -1426,7 +1458,7 @@ func TestWorldResources(t *testing.T) {
 	assert.True(t, w.Resources().Has(rotID))
 	w.Resources().Remove(rotID)
 	assert.False(t, w.Resources().Has(rotID))
-	assert.Panics(t, func() { w.Resources().Remove(rotID) })
+	assert.PanicsWithValue(t, "Resource of ID 1 is not present", func() { w.Resources().Remove(rotID) })
 }
 
 func TestWorldBatchRemove(t *testing.T) {
@@ -1786,9 +1818,10 @@ func TestWorldEntityDumpFail(t *testing.T) {
 	e1 := w2.NewEntity()
 	w2.RemoveEntity(e1)
 
-	assert.Panics(t, func() {
-		w2.LoadEntities(&eData)
-	})
+	assert.PanicsWithValue(t, "can set entity data only on a fresh or reset world",
+		func() {
+			w2.LoadEntities(&eData)
+		})
 }
 
 func TestWorldExtendLayouts(t *testing.T) {
@@ -1822,9 +1855,10 @@ func TestWorldExtendLayouts(t *testing.T) {
 	assert.Equal(t, 16, len(w.archetypes.Get(1).layouts))
 
 	lock := w.lock()
-	assert.Panics(t, func() {
-		ComponentID[testStruct16](&w)
-	})
+	assert.PanicsWithValue(t, "attempt to register a new component in a locked world",
+		func() {
+			ComponentID[testStruct16](&w)
+		})
 	w.unlock(lock)
 
 	id16 := ComponentID[testStruct16](&w)
diff --git a/generic/map_test.go b/generic/map_test.go
index 2474f14..079ddc7 100644
--- a/generic/map_test.go
+++ b/generic/map_test.go
@@ -32,13 +32,13 @@ func TestGenericMap(t *testing.T) {
 
 	get2 := NewMap[testStruct1](&w)
 	assert.Equal(t, ecs.ComponentID[testStruct1](&w), get2.ID())
-	assert.Panics(t, func() { get2.Set(e0, &testStruct1{}) })
+	assert.PanicsWithValue(t, "can't copy component into entity that has no such component type", func() { get2.Set(e0, &testStruct1{}) })
 
 	w.RemoveEntity(e0)
 	_ = w.NewEntity()
 
-	assert.Panics(t, func() { get.Has(e0) })
-	assert.Panics(t, func() { get.Get(e0) })
+	assert.PanicsWithValue(t, "can't check for component of a dead entity", func() { get.Has(e0) })
+	assert.PanicsWithValue(t, "can't get component of a dead entity", func() { get.Get(e0) })
 }
 
 func TestGenericMapRelations(t *testing.T) {