basic ECS spawner

This commit is contained in:
2026-01-15 15:27:48 +01:00
parent 24a781f36a
commit eb737b469c
860 changed files with 58621 additions and 32 deletions

View File

@@ -0,0 +1,147 @@
class_name TestArchetypeEdgeCacheBug
extends GdUnitTestSuite
## Test suite for archetype edge cache bug
##
## Tests that archetypes retrieved from edge cache are properly re-registered
## with the world when they were previously removed due to being empty.
##
## Bug sequence:
## 1. Entity A gets component added -> creates archetype X, cached edge
## 2. Entity A removed -> archetype X becomes empty, gets removed from world.archetypes
## 3. Entity B gets same component -> uses cached edge to archetype X
## 4. BUG: archetype X not in world.archetypes, so queries can't find Entity B
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## Test that archetypes retrieved from edge cache are re-registered with world
func test_archetype_reregistered_after_edge_cache_retrieval():
# ARRANGE: Create two entities with same initial components
var entity1 = Entity.new()
entity1.add_component(C_TestA.new())
world.add_entities([entity1])
var entity2 = Entity.new()
entity2.add_component(C_TestA.new())
world.add_entities([entity2])
# ACT 1: Add ComponentB to entity1 (creates new archetype + edge cache)
var comp_b1 = C_TestB.new()
entity1.add_component(comp_b1)
# Get the archetype signature for A+B combination
var archetype_with_b = world.entity_to_archetype[entity1]
var signature_with_b = archetype_with_b.signature
# Verify archetype is in world.archetypes
assert_bool(world.archetypes.has(signature_with_b)).is_true()
# ACT 2: Remove entity1 to make archetype empty (triggers cleanup)
world.remove_entity(entity1)
# Verify archetype was removed from world.archetypes when empty
assert_bool(world.archetypes.has(signature_with_b)).is_false()
# ACT 3: Add ComponentB to entity2 (should use edge cache)
# This is where the bug would occur - archetype retrieved from cache
# but not re-registered with world
var comp_b2 = C_TestB.new()
entity2.add_component(comp_b2)
# ASSERT: Archetype should be back in world.archetypes
assert_bool(world.archetypes.has(signature_with_b)).is_true()
# ASSERT: Query should find entity2
var query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
var results = query.execute()
assert_int(results.size()).is_equal(1)
assert_object(results[0]).is_same(entity2)
## Test that queries find entities in edge-cached archetypes
func test_query_finds_entities_in_edge_cached_archetype():
# This reproduces the exact projectile bug scenario
# ARRANGE: Create 3 projectiles
var projectile1 = Entity.new()
projectile1.add_component(C_TestA.new()) # Simulates C_Projectile
world.add_entities([projectile1])
var projectile2 = Entity.new()
projectile2.add_component(C_TestA.new())
world.add_entities([projectile2])
var projectile3 = Entity.new()
projectile3.add_component(C_TestA.new())
world.add_entities([projectile3])
# ACT 1: First projectile collides (adds ComponentB = C_Collision)
projectile1.add_component(C_TestB.new())
# Verify query finds it
var collision_query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
assert_int(collision_query.execute().size()).is_equal(1)
# ACT 2: First projectile processed and removed (empties collision archetype)
world.remove_entity(projectile1)
# ACT 3: Second projectile collides (edge cache used)
projectile2.add_component(C_TestB.new())
# ASSERT: Query should find second projectile (BUG: it wouldn't before fix)
var results = collision_query.execute()
assert_int(results.size()).is_equal(1)
assert_object(results[0]).is_same(projectile2)
# ACT 4: Third projectile also collides while second still exists
projectile3.add_component(C_TestB.new())
# ASSERT: Query should find both projectiles
results = collision_query.execute()
assert_int(results.size()).is_equal(2)
## Test rapid add/remove cycles don't lose archetypes
func test_rapid_archetype_cycling():
# Tests the exact pattern: create -> empty -> reuse via cache
var entities = []
for i in range(5):
var e = Entity.new()
e.add_component(C_TestA.new())
world.add_entities([e])
entities.append(e)
# Cycle through adding/removing ComponentB
for cycle in range(3):
# Add ComponentB to first entity (creates/reuses archetype)
entities[0].add_component(C_TestB.new())
# Query should find it
var query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
var results = query.execute()
assert_int(results.size()).is_equal(1)
# Remove entity (empties archetype)
world.remove_entity(entities[0])
# Create new entity for next cycle
entities[0] = Entity.new()
entities[0].add_component(C_TestA.new())
world.add_entities([entities[0]])
# Final cycle - should still work
entities[0].add_component(C_TestB.new())
var final_query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
assert_int(final_query.execute().size()).is_equal(1)

View File

@@ -0,0 +1 @@
uid://hphmrhtswrjq

View File

@@ -0,0 +1,156 @@
extends GdUnitTestSuite
## Test archetype-based system execution
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_archetype_system_processes_entities():
# Create test system that uses archetype mode
var test_system = ArchetypeTestSystem.new()
world.add_system(test_system)
# Create entities with components
var entity1 = Entity.new()
entity1.name = "Entity1"
world.add_entity(entity1, [C_TestA.new()])
var entity2 = Entity.new()
entity2.name = "Entity2"
world.add_entity(entity2, [C_TestA.new()])
# Process the system
world.process(0.1)
# Verify archetype method was called
assert_int(test_system.archetype_call_count).is_equal(1)
assert_int(test_system.entities_processed).is_equal(2)
func test_archetype_iteration_order_matches_iterate():
# System that checks component order
var test_system = ArchetypeOrderTestSystem.new()
world.add_system(test_system)
# Create entity with multiple components
var entity = Entity.new()
entity.name = "TestEntity"
world.add_entity(entity, [C_TestB.new(), C_TestA.new()])
# Process
world.process(0.1)
# Verify components were in correct order (as specified in iterate())
assert_bool(test_system.order_correct).is_true()
func test_archetype_processes_entities_with_extra_components():
# Query for A and B, but entity has A, B, and C
var test_system = ArchetypeSubsetTestSystem.new()
world.add_system(test_system)
# Entity has MORE components than query asks for
var entity = Entity.new()
entity.name = "ExtraComponents"
world.add_entity(entity, [C_TestA.new(), C_TestB.new(), C_TestC.new()])
# Should still match and process
world.process(0.1)
assert_int(test_system.entities_processed).is_equal(1)
func test_archetype_processes_multiple_archetypes():
# System that tracks archetype calls
var test_system = ArchetypeMultipleArchetypesTestSystem.new()
world.add_system(test_system)
# Create entities with different component combinations
# Archetype 1: [A, B]
var entity1 = Entity.new()
world.add_entity(entity1, [C_TestA.new(), C_TestB.new()])
var entity2 = Entity.new()
world.add_entity(entity2, [C_TestA.new(), C_TestB.new()])
# Archetype 2: [A, B, C]
var entity3 = Entity.new()
world.add_entity(entity3, [C_TestA.new(), C_TestB.new(), C_TestC.new()])
# Process
world.process(0.1)
# Should be called once per archetype
assert_int(test_system.archetype_call_count).is_equal(2)
assert_int(test_system.total_entities_processed).is_equal(3)
func test_archetype_column_data_is_correct():
# System that verifies column data
var test_system = ArchetypeColumnDataTestSystem.new()
world.add_system(test_system)
# Create entities with specific values
var entity1 = Entity.new()
var comp_a1 = C_TestA.new()
comp_a1.value = 10
world.add_entity(entity1, [comp_a1])
var entity2 = Entity.new()
var comp_a2 = C_TestA.new()
comp_a2.value = 20
world.add_entity(entity2, [comp_a2])
# Process
world.process(0.1)
# Verify column had correct values
assert_array(test_system.values_seen).contains_exactly([10, 20])
func test_archetype_modifies_components():
# System that modifies component values
var test_system = ArchetypeModifyTestSystem.new()
world.add_system(test_system)
var entity = Entity.new()
var comp = C_TestA.new()
comp.value = 5
world.add_entity(entity, [comp])
# Process multiple times
world.process(0.1)
world.process(0.1)
world.process(0.1)
# Value should have been incremented each time
# Get the component from the entity (not the local reference)
var updated_comp = entity.get_component(C_TestA)
assert_int(updated_comp.value).is_equal(8)
func test_archetype_works_without_iterate_call():
# System that doesn't call iterate() still works, just gets empty components array
var test_system = ArchetypeNoIterateSystem.new()
world.add_system(test_system)
var entity = Entity.new()
world.add_entity(entity, [C_TestA.new()])
# Should work fine - system can use get_component() instead
world.process(0.1)
# System should have processed the entity
assert_int(test_system.processed).is_equal(1)

View File

@@ -0,0 +1 @@
uid://cem3jyvifqys

View File

@@ -0,0 +1,371 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_complex_nested_relationships_serialization():
# Create a complex hierarchy: Player -> Weapon -> Attachment
# This tests multi-level relationship auto-inclusion
# 1. Create Player entity
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new()) # Player-specific component
# 2. Create Weapon entity with weapon-specific components
var weapon = Entity.new()
weapon.name = "AssaultRifle"
weapon.add_component(C_TestB.new()) # Weapon component
weapon.add_component(C_TestC.new()) # Damage component
# 3. Create Attachment entity
var attachment = Entity.new()
attachment.name = "RedDotSight"
attachment.add_component(C_TestD.new()) # Attachment component
attachment.add_component(C_TestE.new()) # Accuracy modifier component
# 4. Create another attachment for testing multiple relationships
var attachment2 = Entity.new()
attachment2.name = "Silencer"
attachment2.add_component(C_TestF.new()) # Another attachment component
# 5. Set up relationships: Player -> Weapon -> Attachments
var player_weapon_rel = Relationship.new(C_TestA.new(), weapon) # Player equipped with weapon
player.add_relationship(player_weapon_rel)
var weapon_sight_rel = Relationship.new(C_TestB.new(), attachment) # Weapon has sight
weapon.add_relationship(weapon_sight_rel)
var weapon_silencer_rel = Relationship.new(C_TestC.new(), attachment2) # Weapon has silencer
weapon.add_relationship(weapon_silencer_rel)
# 6. Add entities to world (don't add to scene tree to preserve names)
world.add_entity(player)
world.add_entity(weapon)
world.add_entity(attachment)
world.add_entity(attachment2)
# Store original UUIDs for verification
var player_id = player.id
var weapon_id = weapon.id
var attachment_id = attachment.id
var attachment2_id = attachment2.id
print("=== BEFORE SERIALIZATION ===")
print("Player UUID: ", player_id)
print("Weapon UUID: ", weapon_id)
print("Attachment UUID: ", attachment_id)
print("Attachment2 UUID: ", attachment2_id)
print("Player relationships: ", player.relationships.size())
print("Weapon relationships: ", weapon.relationships.size())
# 7. Serialize ONLY the player (should auto-include weapon and attachments)
var query = world.query.with_all([C_TestA]) # Only matches player
var serialized_data = ECS.serialize(query)
print("=== SERIALIZATION RESULTS ===")
print("Total entities serialized: ", serialized_data.entities.size())
# 8. Verify serialization results
assert_that(serialized_data.entities).has_size(4) # All 4 entities should be included
# Count auto-included vs original entities
var auto_included_count = 0
var original_count = 0
var player_data = null
var weapon_data = null
var attachment_data = null
var attachment2_data = null
for entity_data in serialized_data.entities:
print("Entity: ", entity_data.entity_name, " - Auto-included: ", entity_data.auto_included, " - id: ", entity_data.id)
if entity_data.auto_included:
auto_included_count += 1
else:
original_count += 1
# Find specific entities for detailed verification
match entity_data.entity_name:
"Player":
player_data = entity_data
"AssaultRifle":
weapon_data = entity_data
"RedDotSight":
attachment_data = entity_data
"Silencer":
attachment2_data = entity_data
# Verify auto-inclusion flags
assert_that(original_count).is_equal(1) # Only player from original query
assert_that(auto_included_count).is_equal(3) # Weapon and both attachments auto-included
# Verify specific entity data
assert_that(player_data).is_not_null()
assert_that(player_data.auto_included).is_false() # Player was in original query
assert_that(player_data.relationships).has_size(1) # Player -> Weapon relationship
assert_that(weapon_data).is_not_null()
assert_that(weapon_data.auto_included).is_true() # Weapon was auto-included
assert_that(weapon_data.relationships).has_size(2) # Weapon -> Attachments relationships
assert_that(attachment_data).is_not_null()
assert_that(attachment_data.auto_included).is_true() # Attachment was auto-included
assert_that(attachment2_data).is_not_null()
assert_that(attachment2_data.auto_included).is_true() # Attachment2 was auto-included
# 9. Save and load the serialized data
var file_path = "res://reports/test_complex_relationships.tres"
ECS.save(serialized_data, file_path)
# 10. Clear the world to simulate fresh start
world.purge(false)
assert_that(world.entities).has_size(0)
assert_that(world.entity_id_registry).has_size(0)
# 11. Deserialize and add back to world
var deserialized_entities = ECS.deserialize(file_path)
print("=== DESERIALIZATION RESULTS ===")
print("Deserialized entities: ", deserialized_entities.size())
assert_that(deserialized_entities).has_size(4)
# Add all entities back to world (don't add to scene tree to avoid naming conflicts)
for entity in deserialized_entities:
world.add_entity(entity, null, false)
# 12. Verify world state after deserialization
assert_that(world.entities).has_size(4)
assert_that(world.entity_id_registry).has_size(4)
# Find entities by UUID to verify they're properly restored
var restored_player = world.get_entity_by_id(player_id)
var restored_weapon = world.get_entity_by_id(weapon_id)
var restored_attachment = world.get_entity_by_id(attachment_id)
var restored_attachment2 = world.get_entity_by_id(attachment2_id)
print("=== RESTORED ENTITIES ===")
print("Player found: ", restored_player != null, " - Name: ", restored_player.name if restored_player else "null")
print("Weapon found: ", restored_weapon != null, " - Name: ", restored_weapon.name if restored_weapon else "null")
print("Attachment found: ", restored_attachment != null, " - Name: ", restored_attachment.name if restored_attachment else "null")
print("Attachment2 found: ", restored_attachment2 != null, " - Name: ", restored_attachment2.name if restored_attachment2 else "null")
# Verify all entities were found
assert_that(restored_player).is_not_null()
assert_that(restored_weapon).is_not_null()
assert_that(restored_attachment).is_not_null()
assert_that(restored_attachment2).is_not_null()
# Verify entity names are preserved
assert_that(restored_player.name).is_equal("Player")
assert_that(restored_weapon.name).is_equal("AssaultRifle")
assert_that(restored_attachment.name).is_equal("RedDotSight")
assert_that(restored_attachment2.name).is_equal("Silencer")
# 13. Verify relationships are intact
print("=== RELATIONSHIP VERIFICATION ===")
print("Player relationships: ", restored_player.relationships.size())
print("Weapon relationships: ", restored_weapon.relationships.size())
# Player should have 1 relationship to weapon
assert_that(restored_player.relationships).has_size(1)
var player_rel = restored_player.relationships[0]
assert_that(player_rel.target).is_equal(restored_weapon)
print("Player -> Weapon relationship intact: ", player_rel.target.name)
# Weapon should have 2 relationships to attachments
assert_that(restored_weapon.relationships).has_size(2)
var weapon_targets = []
var weapon_target_entities = []
for rel in restored_weapon.relationships:
weapon_target_entities.append(rel.target)
weapon_targets.append(rel.target.name)
print("Weapon -> ", rel.target.name, " relationship intact")
# Verify weapon is connected to both attachments
assert_that(weapon_target_entities).contains(restored_attachment)
assert_that(weapon_target_entities).contains(restored_attachment2)
assert_that(weapon_targets).contains("RedDotSight")
assert_that(weapon_targets).contains("Silencer")
# 14. Verify components are preserved
assert_that(restored_player.has_component(C_TestA)).is_true()
assert_that(restored_weapon.has_component(C_TestB)).is_true()
assert_that(restored_weapon.has_component(C_TestC)).is_true()
assert_that(restored_attachment.has_component(C_TestD)).is_true()
assert_that(restored_attachment.has_component(C_TestE)).is_true()
assert_that(restored_attachment2.has_component(C_TestF)).is_true()
print("=== TEST PASSED: Complex nested relationships preserved! ===")
world.remove_entities(deserialized_entities)
func test_relationship_replacement_with_id_collision():
# Test that when entities with relationships are replaced via UUID collision,
# the relationships update correctly to point to the new entities
# 1. Create initial setup: Player -> Weapon
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new())
player.set("id", "player-id-123")
var old_weapon = Entity.new()
old_weapon.name = "OldWeapon"
old_weapon.add_component(C_TestB.new())
old_weapon.set("id", "weapon-id-456")
var player_weapon_rel = Relationship.new(C_TestA.new(), old_weapon)
player.add_relationship(player_weapon_rel)
world.add_entity(player)
world.add_entity(old_weapon)
# Verify initial relationship
assert_that(player.relationships).has_size(1)
assert_that(player.relationships[0].target).is_equal(old_weapon)
assert_that(player.relationships[0].target.name).is_equal("OldWeapon")
# 2. Serialize the current state
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_replacement_relationships.tres"
ECS.save(serialized_data, file_path)
# 3. Create "updated" entities with same UUIDs but different data
var new_weapon = Entity.new()
new_weapon.name = "NewUpgradedWeapon"
new_weapon.add_component(C_TestB.new())
new_weapon.add_component(C_TestC.new()) # Added component
new_weapon.set("id", "weapon-id-456") # Same UUID!
# 4. Add new weapon (should replace old weapon)
world.add_entity(new_weapon)
# Verify replacement occurred
assert_that(world.entities).has_size(2) # Still only 2 entities
var current_weapon = world.get_entity_by_id("weapon-id-456")
assert_that(current_weapon).is_equal(new_weapon)
assert_that(current_weapon.name).is_equal("NewUpgradedWeapon")
assert_that(current_weapon.has_component(C_TestC)).is_true()
# 5. NOTE: When we replace an entity, existing relationships still point to the old entity object
# This is expected behavior - the relationship contains a direct Entity reference
# To update relationships, we would need to re-serialize/deserialize or manually update them
print("Current relationship target: ", player.relationships[0].target.name)
print("Expected: Relationship still points to old entity until re-serialized")
print("=== Relationship correctly updated after entity replacement ===")
# 6. Now test loading the old save file (should replace with old state)
var loaded_entities = ECS.deserialize(file_path)
for entity in loaded_entities:
world.add_entity(entity) # Should trigger replacements
# Verify entities were replaced with old state
var final_weapon = world.get_entity_by_id("weapon-id-456")
print("Final weapon name: ", final_weapon.name)
assert_that(final_weapon.has_component(C_TestC)).is_false() # Lost the added component
# Verify relationship points to restored weapon
var final_player = world.get_entity_by_id("player-id-123")
assert_that(final_player.relationships).has_size(1)
assert_that(final_player.relationships[0].target).is_equal(final_weapon)
print("Final relationship target name: ", final_player.relationships[0].target.name)
print("=== Save/Load replacement cycle completed successfully ===")
func test_partial_serialization_auto_inclusion():
# Test that we can serialize a subset of entities and auto-include dependencies
# while excluding unrelated entities
# Create multiple independent entity groups
# Group 1: Player -> Weapon -> Attachment (should be included)
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new())
var weapon = Entity.new()
weapon.name = "Weapon"
weapon.add_component(C_TestB.new())
var attachment = Entity.new()
attachment.name = "Attachment"
attachment.add_component(C_TestC.new())
player.add_relationship(Relationship.new(C_TestA.new(), weapon))
weapon.add_relationship(Relationship.new(C_TestB.new(), attachment))
# Group 2: Enemy -> EnemyWeapon (should NOT be included)
var enemy = Entity.new()
enemy.name = "Enemy"
enemy.add_component(C_TestD.new()) # Different component type
var enemy_weapon = Entity.new()
enemy_weapon.name = "EnemyWeapon"
enemy_weapon.add_component(C_TestE.new())
enemy.add_relationship(Relationship.new(C_TestD.new(), enemy_weapon))
# Group 3: Standalone entity (should NOT be included)
var standalone = Entity.new()
standalone.name = "Standalone"
standalone.add_component(C_TestF.new())
# Add all entities to world (don't add to scene tree)
world.add_entity(player)
world.add_entity(weapon)
world.add_entity(attachment)
world.add_entity(enemy)
world.add_entity(enemy_weapon)
world.add_entity(standalone)
assert_that(world.entities).has_size(6)
# Serialize ONLY entities with C_TestA (just the player)
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
print("=== PARTIAL SERIALIZATION RESULTS ===")
print("Total entities in world: ", world.entities.size())
print("Entities serialized: ", serialized_data.entities.size())
# Should include Player + Weapon + Attachment (3 total) but NOT Enemy group or Standalone
assert_that(serialized_data.entities).has_size(3)
var serialized_names = []
for entity_data in serialized_data.entities:
serialized_names.append(entity_data.entity_name)
print("Serialized: ", entity_data.entity_name, " (auto-included: ", entity_data.auto_included, ")")
# Verify correct entities were included
assert_that(serialized_names).contains("Player")
assert_that(serialized_names).contains("Weapon")
assert_that(serialized_names).contains("Attachment")
# Verify incorrect entities were excluded
assert_that(serialized_names.has("Enemy")).is_false()
assert_that(serialized_names.has("EnemyWeapon")).is_false()
assert_that(serialized_names.has("Standalone")).is_false()
# Verify auto-inclusion flags
var player_data = serialized_data.entities.filter(func(e): return e.entity_name == "Player")[0]
var weapon_data = serialized_data.entities.filter(func(e): return e.entity_name == "Weapon")[0]
var attachment_data = serialized_data.entities.filter(func(e): return e.entity_name == "Attachment")[0]
assert_that(player_data.auto_included).is_false() # Original query
assert_that(weapon_data.auto_included).is_true() # Auto-included via Player relationship
assert_that(attachment_data.auto_included).is_true() # Auto-included via Weapon relationship
print("=== Partial serialization with auto-inclusion working correctly ===")

View File

@@ -0,0 +1 @@
uid://bdpuk46wqnhuw

View File

@@ -0,0 +1,163 @@
extends GdUnitTestSuite
func test_component_key_is_set_correctly():
# Create an instance of a concrete Component subclass
var component = C_TestA.new()
# The key should be set to the resource path of the component's script
var expected_key = component.get_script().resource_path
assert_str("res://addons/gecs/tests/components/c_test_a.gd").is_equal(expected_key)
func test_component_query_matcher_equality():
# Test _eq operator
var component = C_TestA.new(42)
# Should match exact value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_eq": 42}})).is_true()
# Should not match different value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_eq": 10}})).is_false()
func test_component_query_matcher_inequality():
# Test _ne operator
var component = C_TestA.new(42)
# Should match different value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 10}})).is_true()
# Should not match same value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 42}})).is_false()
func test_component_query_matcher_greater_than():
# Test _gt and _gte operators
var component = C_TestA.new(50)
# _gt tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 49}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 50}})).is_false()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 51}})).is_false()
# _gte tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 49}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 50}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 51}})).is_false()
func test_component_query_matcher_less_than():
# Test _lt and _lte operators
var component = C_TestA.new(50)
# _lt tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 51}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 50}})).is_false()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 49}})).is_false()
# _lte tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 51}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 50}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 49}})).is_false()
func test_component_query_matcher_array_membership():
# Test _in and _nin operators
var component = C_TestA.new(42)
# _in tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_in": [40, 41, 42]}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_in": [1, 2, 3]}})).is_false()
# _nin tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_nin": [1, 2, 3]}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_nin": [40, 41, 42]}})).is_false()
func test_component_query_matcher_custom_function():
# Test func operator
var component = C_TestA.new(42)
# Custom function that checks if value is even
var is_even = func(val): return val % 2 == 0
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": is_even}})).is_true()
# Custom function that checks if value is odd
var is_odd = func(val): return val % 2 == 1
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": is_odd}})).is_false()
# Custom function with complex logic
var in_range = func(val): return val >= 40 and val <= 50
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": in_range}})).is_true()
func test_component_query_matcher_multiple_operators():
# Test combining multiple operators (all must pass)
var component = C_TestA.new(50)
# Should match: value >= 40 AND value <= 60
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 40, "_lte": 60}})).is_true()
# Should not match: value >= 40 AND value <= 45
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 40, "_lte": 45}})).is_false()
# Should match: value != 0 AND value > 30
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 0, "_gt": 30}})).is_true()
func test_component_query_matcher_falsy_values():
# Test that falsy values (0, false, null) are handled correctly
var component_zero = C_TestA.new(0)
# Should match 0 exactly
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_eq": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_eq": 1}})).is_false()
# Should handle 0 in ranges
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_gte": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_lte": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_gt": 0}})).is_false()
# Should handle negative numbers
var component_negative = C_TestA.new(-5)
assert_bool(ComponentQueryMatcher.matches_query(component_negative, {"value": {"_eq": -5}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_negative, {"value": {"_lt": 0}})).is_true()
func test_component_query_matcher_empty_query():
# Empty query should match any component
var component = C_TestA.new(42)
assert_bool(ComponentQueryMatcher.matches_query(component, {})).is_true()
func test_component_query_matcher_nonexistent_property():
# Should return false if property doesn't exist
var component = C_TestA.new(42)
assert_bool(ComponentQueryMatcher.matches_query(component, {"nonexistent": {"_eq": 10}})).is_false()
func test_component_query_matcher_multiple_properties():
# Test querying multiple properties at once
var component = C_TestD.new(5) # Has 'points' property
# Both properties must match
assert_bool(ComponentQueryMatcher.matches_query(component, {
"points": {"_eq": 5}
})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {
"points": {"_eq": 10}
})).is_false()
func test_component_serialization():
# Create an instance of a concrete Component subclass
var component_a = C_TestA.new(42)
var component_b = C_TestD.new(1)
# Serialize the component
var serialized_data_a = component_a.serialize()
var serialized_data_b = component_b.serialize()
# Check if the serialized data matches the expected values
assert_int(serialized_data_a["value"]).is_equal(42)
assert_int(serialized_data_b["points"]).is_equal(1)

View File

@@ -0,0 +1 @@
uid://4nqun3t8nb18

View File

@@ -0,0 +1,178 @@
extends GdUnitTestSuite
# Test suite for System debug tracking (lastRunData)
var world: World
func before_test():
world = World.new()
world.name = "TestWorld"
Engine.get_main_loop().root.add_child(world)
ECS.world = world
func after_test():
ECS.world = null
if is_instance_valid(world):
world.queue_free()
func test_debug_tracking_process_mode():
# Enable debug mode for these tests
ECS.debug = true
# Create entities
for i in range(10):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
world.add_entity(entity)
# Create system with PROCESS execution method
var system = ProcessSystem.new()
world.add_system(system)
# Process once
world.process(0.016)
# Debug: Print what's in lastRunData
print("DEBUG: ECS.debug = ", ECS.debug)
print("DEBUG: lastRunData = ", system.lastRunData)
print("DEBUG: lastRunData keys = ", system.lastRunData.keys())
# Verify debug data
assert_that(system.lastRunData.has("system_name")).is_true()
assert_that(system.lastRunData.has("frame_delta")).is_true()
assert_that(system.lastRunData.has("entity_count")).is_true()
assert_that(system.lastRunData.has("execution_time_ms")).is_true()
# Verify values
assert_that(system.lastRunData["frame_delta"]).is_equal(0.016)
assert_that(system.lastRunData["entity_count"]).is_equal(10)
assert_that(system.lastRunData["execution_time_ms"]).is_greater(0.0)
assert_that(system.lastRunData["parallel"]).is_equal(false)
# Store first execution time
var first_exec_time = system.lastRunData["execution_time_ms"]
# Process again
world.process(0.032)
# Verify time is different (not accumulating)
var second_exec_time = system.lastRunData["execution_time_ms"]
assert_that(system.lastRunData["frame_delta"]).is_equal(0.032)
# Times should be similar but not identical (and definitely not accumulated)
# If accumulating, second would be ~2x first
assert_that(second_exec_time).is_less(first_exec_time * 1.5)
print("First exec: %.3f ms, Second exec: %.3f ms" % [first_exec_time, second_exec_time])
func test_debug_tracking_subsystems():
# Enable debug mode for these tests
ECS.debug = true
# Create entities
for i in range(10):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
entity.add_component(C_DebugTrackingTestB.new())
world.add_entity(entity)
# Create system with SUBSYSTEMS execution method
var system = SubsystemsTestSystem.new()
world.add_system(system)
# Process once
world.process(0.016)
# Verify debug data
assert_that(system.lastRunData["execution_time_ms"]).is_greater(0.0)
# Verify subsystem data
assert_that(system.lastRunData.has(0)).is_true()
assert_that(system.lastRunData.has(1)).is_true()
# First subsystem
assert_that(system.lastRunData[0]["entity_count"]).is_equal(10)
# Second subsystem
assert_that(system.lastRunData[1]["entity_count"]).is_equal(10)
print("Subsystem 0: %s" % [system.lastRunData[0]])
print("Subsystem 1: %s" % [system.lastRunData[1]])
func test_debug_disabled_has_no_data():
# Disable debug mode
ECS.debug = false
# Create entities
for i in range(5):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
world.add_entity(entity)
# Create system
var system = ProcessSystem.new()
world.add_system(system)
# Process
world.process(0.016)
# lastRunData should be empty or not updated when debug is off
# (It might still exist from a previous run, but shouldn't be updated)
var initial_data = system.lastRunData.duplicate()
# Process again
world.process(0.016)
# Data should not change (because ECS.debug = false)
assert_that(system.lastRunData).is_equal(initial_data)
print("With ECS.debug=false, lastRunData remains unchanged: %s" % [system.lastRunData])
# Test system - PROCESS mode
class ProcessSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestA])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestA)
comp.value += delta
# Test system - unified process
class ProcessAllSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestB])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestB)
comp.count += 1
# Test system - batch processing with iterate
class ProcessBatchSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestA]).iterate([C_DebugTrackingTestA])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
var test_a_components = components[0]
for i in range(entities.size()):
test_a_components[i].value += delta
# Test system - SUBSYSTEMS mode
class SubsystemsTestSystem extends System:
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_DebugTrackingTestA]), process_sub],
[ECS.world.query.with_all([C_DebugTrackingTestB]).iterate([C_DebugTrackingTestB]), batch_sub]
]
func process_sub(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestA)
comp.value += delta
func batch_sub(entities: Array[Entity], components: Array, delta: float) -> void:
if components.size() > 0 and components[0].size() > 0:
var test_b_components = components[0]
for i in range(entities.size()):
test_b_components[i].count += 1

View File

@@ -0,0 +1 @@
uid://bk45ditcdxhih

View File

@@ -0,0 +1,164 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
# TODO: We need to add the world here becuase remove fails because we don't have access to world
func test_add_and_get_component():
var entity = auto_free(TestA.new())
var comp = C_TestA.new()
entity.add_component(comp)
# Test that the component was added
assert_bool(entity.has_component(C_TestA)).is_true()
# Test retrieving the component
var retrieved_component = entity.get_component(C_TestA)
assert_str(type_string(typeof(retrieved_component))).is_equal(type_string(typeof(comp)))
# Components need default values on init or they will error
# FIXME: How can we catch this in the code?
func test_add_entity_with_component_with_no_defaults_in_init():
var entity = auto_free(Entity.new())
# this line will lead to crash (the _init parameters has no default value)
assert_error(func(): entity.add_component(C_TestH.new(57)))
func test_add_multiple_components_and_has():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
entity.add_components([comp1, comp2])
# Test that the components were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_false()
func test_remove_component():
var entity = auto_free(TestB.new())
var comp = C_TestB.new()
entity.add_component(comp)
entity.remove_component(C_TestB)
# Test that the component was removed
assert_bool(entity.has_component(C_TestB)).is_false()
func test_add_get_has_relationship():
var entitya = auto_free(TestC.new())
var entityb = auto_free(TestC.new())
var r_testa_entitya = Relationship.new(C_TestA.new(), entitya)
# Add the relationship
entityb.add_relationship(r_testa_entitya)
# Test that the relationship was added
# With the actual relationship
assert_bool(entityb.has_relationship(r_testa_entitya)).is_true()
# with a matching relationship
assert_bool(entityb.has_relationship(Relationship.new(C_TestA.new(), entitya))).is_true()
# Test retrieving the relationship
# with the actual relationship
var inst_retrieved_relationship = entityb.get_relationship(r_testa_entitya)
assert_str(type_string(typeof(inst_retrieved_relationship))).is_equal(
type_string(typeof(r_testa_entitya))
)
# with a matching relationship
var class_retrieved_relationship = entityb.get_relationship(
Relationship.new(C_TestA.new(), entitya)
)
assert_str(type_string(typeof(class_retrieved_relationship))).is_equal(
type_string(typeof(r_testa_entitya))
)
assert_str(type_string(typeof(class_retrieved_relationship))).is_equal(
type_string(typeof(Relationship.new(C_TestA.new(), entitya)))
)
func test_add_and_remove_component():
var entity = auto_free(TestB.new())
for i in range(99):
var comp = C_TestB.new()
entity.add_component(comp)
entity.remove_component(C_TestB)
print('_component_path_cache size=', entity._component_path_cache.size())
# Test memory leak
assert_int(entity._component_path_cache.size()).is_equal(0)
func test_remove_components_with_scripts():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Verify all were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_true()
# Remove multiple components by Script class
entity.remove_components([C_TestA, C_TestB])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()
func test_remove_components_with_instances():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Verify all were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_true()
# Remove multiple components by instance
entity.remove_components([comp1, comp2])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()
func test_remove_components_mixed():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Remove with mixed Script and instance
entity.remove_components([C_TestA, comp2])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()

View File

@@ -0,0 +1 @@
uid://dh1uujht5xew7

View File

@@ -0,0 +1,246 @@
extends GdUnitTestSuite
## Test suite for the Entity ID system functionality
## Tests auto-generation, custom IDs, singleton behavior, and world-level enforcement
var world: World
func before_test():
world = World.new()
world.name = "TestWorld"
add_child(world)
ECS.world = world
func after_test():
if is_instance_valid(world):
world.queue_free()
await await_idle_frame()
func test_entity_id_auto_generation():
# Test that entities auto-generate IDs in _enter_tree
var entity = Entity.new()
entity.name = "TestEntity"
# ID should be empty before entering tree
assert_str(entity.id).is_empty()
# Add to tree - triggers _enter_tree and ID generation
world.add_entity(entity)
# ID should now be auto-generated
assert_str(entity.id).is_not_empty()
assert_bool(entity.id.length() > 0).is_true()
# Should not change ID on subsequent checks
var first_id = entity.id
var second_id = entity.id
assert_str(second_id).is_equal(first_id)
func test_entity_custom_id():
# Test custom ID functionality for singleton entities
var entity = Entity.new()
entity.name = "SingletonEntity"
# Set custom ID before adding to world
entity.id = "singleton_player"
assert_str(entity.id).is_equal("singleton_player")
# Add to world - should preserve custom ID
world.add_entity(entity)
assert_str(entity.id).is_equal("singleton_player")
# Custom ID should not change on subsequent access
var same_id = entity.id
assert_str(same_id).is_equal("singleton_player")
func test_world_id_tracking():
# Test that World tracks IDs and provides lookup functionality
var entity1 = Entity.new()
entity1.name = "Entity1"
entity1.id = "test_id_1"
var entity2 = Entity.new()
entity2.name = "Entity2"
entity2.id = "test_id_2"
# Add entities to world
world.add_entity(entity1)
world.add_entity(entity2)
# Test lookup by ID
assert_object(world.get_entity_by_id("test_id_1")).is_same(entity1)
assert_object(world.get_entity_by_id("test_id_2")).is_same(entity2)
assert_object(world.get_entity_by_id("nonexistent")).is_null()
# Test has_entity_with_id
assert_bool(world.has_entity_with_id("test_id_1")).is_true()
assert_bool(world.has_entity_with_id("test_id_2")).is_true()
assert_bool(world.has_entity_with_id("nonexistent")).is_false()
func test_world_id_replacement():
# Test singleton behavior - entities with same ID replace existing ones
# Create first entity with custom ID
var entity1 = Entity.new()
entity1.name = "FirstEntity"
entity1.id = "singleton_player"
var comp1 = C_TestA.new()
comp1.value = 100
entity1.add_component(comp1)
world.add_entity(entity1)
# Verify it's in the world
assert_int(world.entities.size()).is_equal(1)
assert_object(world.get_entity_by_id("singleton_player")).is_same(entity1)
# Create second entity with same ID
var entity2 = Entity.new()
entity2.name = "ReplacementEntity"
entity2.id = "singleton_player"
var comp2 = C_TestA.new()
comp2.value = 200
entity2.add_component(comp2)
# Add to world - should replace first entity
world.add_entity(entity2)
# Should still have only one entity
assert_int(world.entities.size()).is_equal(1)
# Should be the new entity
var found_entity = world.get_entity_by_id("singleton_player")
assert_object(found_entity).is_same(entity2)
assert_str(found_entity.name).is_equal("ReplacementEntity")
# Verify component value is from new entity
var comp = found_entity.get_component(C_TestA) as C_TestA
assert_int(comp.value).is_equal(200)
func test_auto_generated_id_tracking():
# Test that auto-generated IDs are also tracked by the world
var entity = Entity.new()
entity.name = "AutoIDEntity"
# Don't set custom ID - let it auto-generate
world.add_entity(entity)
# Should have auto-generated ID
assert_str(entity.id).is_not_empty()
# Should be trackable by ID
assert_object(world.get_entity_by_id(entity.id)).is_same(entity)
assert_bool(world.has_entity_with_id(entity.id)).is_true()
func test_id_generation_format():
# Test that generated IDs follow expected GUID format
var entity = Entity.new()
# Add to tree to trigger ID generation
world.add_entity(entity)
var id = entity.id
assert_str(id).is_not_empty()
assert_bool(id.contains("-")).is_true()
var parts = id.split("-")
assert_int(parts.size()).is_equal(5)
# All parts should be valid hex strings
for part in parts:
assert_bool(part.is_valid_hex_number()).is_true()
func test_id_uniqueness():
# Test that multiple entities get unique IDs
var ids = {}
var entities = []
# Generate 100 entities with auto IDs
for i in range(100):
var entity = Entity.new()
entity.name = "Entity%d" % i
world.add_entity(entity)
entities.append(entity)
# Should not have seen this ID before
assert_bool(ids.has(entity.id)).is_false()
ids[entity.id] = true
# All IDs should be unique
assert_int(ids.size()).is_equal(100)
func test_remove_entity_clears_id_registry():
# Test that removing entities clears them from ID registry
var entity = Entity.new()
entity.name = "TestEntity"
entity.id = "test_remove_id"
world.add_entity(entity)
assert_bool(world.has_entity_with_id("test_remove_id")).is_true()
world.remove_entity(entity)
assert_bool(world.has_entity_with_id("test_remove_id")).is_false()
assert_object(world.get_entity_by_id("test_remove_id")).is_null()
func test_id_system_comprehensive_demo():
# Comprehensive test demonstrating all ID system features
# Test 1: Auto ID generation
var auto_entity = Entity.new()
auto_entity.name = "AutoIDEntity"
world.add_entity(auto_entity)
var generated_id = auto_entity.id
assert_str(generated_id).is_not_empty() # Should auto-generate
assert_bool(generated_id.contains("-")).is_true() # Should have correct GUID format
# Should still have the same ID
assert_str(auto_entity.id).is_equal(generated_id)
# Test 2: Custom ID singleton behavior
var player1 = Entity.new()
player1.name = "Player1"
player1.id = "singleton_player"
var comp1 = C_TestA.new()
comp1.value = 100
player1.add_component(comp1)
world.add_entity(player1)
assert_int(world.entities.size()).is_equal(2) # auto_entity + player1
assert_object(world.get_entity_by_id("singleton_player")).is_same(player1)
# Add second entity with same ID - should replace first
var player2 = Entity.new()
player2.name = "Player2"
player2.id = "singleton_player"
var comp2 = C_TestA.new()
comp2.value = 200
player2.add_component(comp2)
world.add_entity(player2)
assert_int(world.entities.size()).is_equal(2) # Should still be 2 (replacement occurred)
var found_entity = world.get_entity_by_id("singleton_player")
assert_object(found_entity).is_same(player2) # Should be the new entity
assert_str(found_entity.name).is_equal("Player2")
var found_comp = found_entity.get_component(C_TestA) as C_TestA
assert_int(found_comp.value).is_equal(200) # Should have new entity's data
# Test 3: Multiple entity tracking
var tracked_entities = []
for i in range(3):
var entity = Entity.new()
entity.name = "TrackedEntity%d" % i
entity.id = "tracked_%d" % i
tracked_entities.append(entity)
world.add_entity(entity)
# Verify all are tracked
for i in range(3):
var id = "tracked_%d" % i
assert_bool(world.has_entity_with_id(id)).is_true()
assert_object(world.get_entity_by_id(id)).is_same(tracked_entities[i])
# Test 4: ID registry cleanup on removal
world.remove_entity(tracked_entities[1])
assert_bool(world.has_entity_with_id("tracked_1")).is_false()
assert_object(world.get_entity_by_id("tracked_1")).is_null()
# Others should still exist
assert_bool(world.has_entity_with_id("tracked_0")).is_true()
assert_bool(world.has_entity_with_id("tracked_2")).is_true()

View File

@@ -0,0 +1 @@
uid://d3n5subrpobw3

View File

@@ -0,0 +1,392 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
world.purge(false)
func test_observer_receive_component_changed():
world.add_system(TestASystem.new())
var test_a_observer = TestAObserver.new()
world.add_observer(test_a_observer)
# Create entities with the required components
var entity_a = TestA.new()
entity_a.name = "a"
entity_a.add_component(C_TestA.new())
var entity_b = TestB.new()
entity_b.name = "b"
entity_b.add_component(C_TestA.new())
entity_b.add_component(C_TestB.new())
# issue #43
var entity_a2 = TestA.new()
entity_a2.name = "a"
entity_a2.add_component(C_TestA.new())
world.get_node(world.entity_nodes_root).add_child(entity_a2)
world.add_entity(entity_a2, null, false)
assert_int(test_a_observer.added_count).is_equal(1)
# Add some entities before systems
world.add_entities([entity_a, entity_b])
assert_int(test_a_observer.added_count).is_equal(3)
# Run the systems once
print('process 1st')
world.process(0.1)
# Check the event_count
assert_int(test_a_observer.event_count).is_equal(2)
# Run the systems again
print('process 2nd')
world.process(0.1)
# Check the event_count
assert_int(test_a_observer.event_count).is_equal(4)
## Test that observers detect when a component is added to an entity
func test_observer_on_component_added():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity without the component
var entity = Entity.new()
world.add_entity(entity)
# Verify observer hasn't fired yet
assert_int(observer.added_count).is_equal(0)
# Add the watched component
var component = C_ObserverTest.new()
entity.add_component(component)
# Verify observer detected the addition
assert_int(observer.added_count).is_equal(1)
assert_object(observer.last_added_entity).is_equal(entity)
## Test that observers detect when a component is removed from an entity
func test_observer_on_component_removed():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
# Verify observer detected the addition
assert_int(observer.added_count).is_equal(1)
# Reset and remove the component
observer.reset()
entity.remove_component(C_ObserverTest)
# Verify observer detected the removal
assert_int(observer.removed_count).is_equal(1)
assert_object(observer.last_removed_entity).is_equal(entity)
assert_int(observer.added_count).is_equal(0) # Should remain 0 after reset
## Test that observers detect property changes on watched components
func test_observer_on_component_changed():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new(0, "initial")
entity.add_component(component)
world.add_entity(entity)
# Reset the observer (it may have fired on add)
observer.reset()
# Change the value property (this will emit property_changed signal)
component.value = 42
# Verify observer detected the change
assert_int(observer.changed_count).is_equal(1)
assert_object(observer.last_changed_entity).is_equal(entity)
assert_str(observer.last_changed_property).is_equal("value")
assert_int(observer.last_old_value).is_equal(0)
assert_int(observer.last_new_value).is_equal(42)
# Change another property
component.name_prop = "changed"
# Verify observer detected the second change
assert_int(observer.changed_count).is_equal(2)
assert_str(observer.last_changed_property).is_equal("name_prop")
assert_str(observer.last_old_value).is_equal("initial")
assert_str(observer.last_new_value).is_equal("changed")
## Test that observers respect query filters (only match entities that pass the query)
func test_observer_respects_query_filter():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only health component (should NOT match - needs both components)
var entity_only_health = Entity.new()
entity_only_health.add_component(C_ObserverHealth.new())
world.add_entity(entity_only_health)
# Observer should NOT have fired (doesn't match query)
assert_int(health_observer.health_added_count).is_equal(0)
# Create entity with both components (should match)
var entity_both = Entity.new()
entity_both.add_component(C_ObserverTest.new())
entity_both.add_component(C_ObserverHealth.new())
world.add_entity(entity_both)
# Observer should have fired now (matches query)
assert_int(health_observer.health_added_count).is_equal(1)
## Test that multiple observers can watch the same component
func test_multiple_observers_same_component():
var observer1 = O_ObserverTest.new()
var observer2 = O_ObserverTest.new()
world.add_observer(observer1)
world.add_observer(observer2)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
# Both observers should have detected the addition
assert_int(observer1.added_count).is_equal(1)
assert_int(observer2.added_count).is_equal(1)
# Change the component
observer1.reset()
observer2.reset()
component.value = 100
# Both observers should have detected the change
assert_int(observer1.changed_count).is_equal(1)
assert_int(observer2.changed_count).is_equal(1)
## Test that observers can track multiple property changes
func test_observer_tracks_multiple_changes():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new(0, "start")
entity.add_component(component)
world.add_entity(entity)
observer.reset()
# Make multiple changes
component.value = 10
component.value = 20
component.name_prop = "middle"
component.value = 30
# Should have detected all 4 changes
assert_int(observer.changed_count).is_equal(4)
## Test observer with health component and query matching
func test_observer_health_low_health_alert():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with both components
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
var health = C_ObserverHealth.new(100)
entity.add_component(health)
world.add_entity(entity)
health_observer.reset()
# Reduce health gradually
health.health = 50
assert_int(health_observer.health_changed_count).is_equal(1)
assert_int(health_observer.low_health_alerts.size()).is_equal(0)
health.health = 25 # Below threshold
assert_int(health_observer.health_changed_count).is_equal(2)
assert_int(health_observer.low_health_alerts.size()).is_equal(1)
assert_object(health_observer.low_health_alerts[0]).is_equal(entity)
## Test that observer doesn't fire when entity doesn't match query
func test_observer_ignores_non_matching_entities():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only C_ObserverTest (not both components)
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
# Try to add C_ObserverHealth to a different entity that doesn't have C_ObserverTest
var entity2 = Entity.new()
entity2.add_component(C_ObserverHealth.new())
world.add_entity(entity2)
# Observer should not have fired (entity2 doesn't match query)
assert_int(health_observer.health_added_count).is_equal(0)
## Test observer detects component addition before entity is added to world
func test_observer_component_added_before_entity_added():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity and add component BEFORE adding to world
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
# Observer shouldn't have fired yet
assert_int(observer.added_count).is_equal(0)
# Now add to world
world.add_entity(entity)
# Observer should fire now
assert_int(observer.added_count).is_equal(1)
## Test observer with component replacement
func test_observer_component_replacement():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
var component1 = C_ObserverTest.new(10, "first")
entity.add_component(component1)
world.add_entity(entity)
assert_int(observer.added_count).is_equal(1)
# Replace the component (add_component on same type replaces)
var component2 = C_ObserverTest.new(20, "second")
entity.add_component(component2)
# Should trigger both removed and added
assert_int(observer.removed_count).is_equal(1)
assert_int(observer.added_count).is_equal(2)
## Test that property changes without signal emission don't trigger observer
func test_observer_ignores_direct_property_changes():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
observer.reset()
# Directly set the property WITHOUT using the setter
# This bypasses the property_changed signal
# Note: In GDScript, using the property name always calls the setter,
# so we need to access the internal variable directly
# For this test, we're verifying that ONLY setters that emit signals work
# Using the setter (should trigger)
component.value = 42
assert_int(observer.changed_count).is_equal(1)
# The framework correctly requires explicit signal emission in setters
## Test observer with entity that starts matching query after component addition
func test_observer_entity_becomes_matching():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only one component
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
# Health observer shouldn't fire (needs both components)
assert_int(health_observer.health_added_count).is_equal(0)
# Add the second component
entity.add_component(C_ObserverHealth.new())
# Now health observer should fire
assert_int(health_observer.health_added_count).is_equal(1)
## Test removing observer from world
func test_remove_observer():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
assert_int(observer.added_count).is_equal(1)
# Remove the observer
world.remove_observer(observer)
# Add another entity - observer should not fire
var entity2 = Entity.new()
entity2.add_component(C_ObserverTest.new())
world.add_entity(entity2)
# Count should still be 1 (not 2)
assert_int(observer.added_count).is_equal(1)
## Test observer with multiple entities
func test_observer_with_multiple_entities():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create multiple entities
for i in range(5):
var entity = Entity.new()
entity.add_component(C_ObserverTest.new(i))
world.add_entity(entity)
# Should have detected all 5 additions
assert_int(observer.added_count).is_equal(5)
observer.reset()
# Get all entities and modify their components
var entities = world.query.with_all([C_ObserverTest]).execute()
for entity in entities:
var comp = entity.get_component(C_ObserverTest)
comp.value = comp.value + 100
# Should have detected all 5 changes
assert_int(observer.changed_count).is_equal(5)

View File

@@ -0,0 +1 @@
uid://jr1qceldoims

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://b06t0s7ajwlme

View File

@@ -0,0 +1,30 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_all_vs_any_distinct_cache_key():
var qb_all = world.query.with_all([C_DomainTestA, C_DomainTestB])
var key_all = qb_all.get_cache_key()
var qb_any = world.query.with_any([C_DomainTestA, C_DomainTestB])
var key_any = qb_any.get_cache_key()
assert_int(key_all).is_not_equal(key_any)
func test_all_vs_mixed_not_colliding():
var qb1 = world.query.with_all([C_DomainTestA]).with_any([C_DomainTestB])
var qb2 = world.query.with_all([C_DomainTestA, C_DomainTestB])
assert_int(qb1.get_cache_key()).is_not_equal(qb2.get_cache_key())
func test_any_vs_exclude_not_colliding():
var qb3 = world.query.with_any([C_DomainTestA])
var qb4 = world.query.with_none([C_DomainTestA])
assert_int(qb3.get_cache_key()).is_not_equal(qb4.get_cache_key())

View File

@@ -0,0 +1 @@
uid://dw542afdb7ydt

View File

@@ -0,0 +1,81 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
# Preload component scripts to ensure availability
const C_PermA = preload("res://addons/gecs/tests/components/c_perm_a.gd")
const C_PermB = preload("res://addons/gecs/tests/components/c_perm_b.gd")
const C_PermC = preload("res://addons/gecs/tests/components/c_perm_c.gd")
const C_PermD = preload("res://addons/gecs/tests/components/c_perm_d.gd")
const C_PermE = preload("res://addons/gecs/tests/components/c_perm_e.gd")
const C_PermF = preload("res://addons/gecs/tests/components/c_perm_f.gd")
const ALL = [C_PermA, C_PermB]
const ANY = [C_PermC, C_PermD]
const NONE = [C_PermE, C_PermF]
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func _both_orders(arr: Array) -> Array:
if arr.size() < 2:
return [arr]
var rev = arr.duplicate(); rev.reverse()
return [arr, rev]
func _cache_key(all: Array, any: Array, none: Array) -> int:
return world.query.with_all(all).with_any(any).with_none(none).get_cache_key()
func test_permutation_invariance_all_any_none():
var keys = []
for all_var in _both_orders(ALL):
for any_var in _both_orders(ANY):
for none_var in _both_orders(NONE):
keys.append(_cache_key(all_var, any_var, none_var))
var first = keys[0]
for k in keys:
assert_int(k).is_equal(first)
func test_cross_domain_differentiation():
var k1 = _cache_key([C_PermA, C_PermB], [C_PermC, C_PermD], [C_PermE, C_PermF])
# Move C_PermB to ANY domain should change key
var k2 = _cache_key([C_PermA], [C_PermB, C_PermC, C_PermD], [C_PermE, C_PermF])
assert_int(k1).is_not_equal(k2)
func test_empty_domain_variants_unique():
var k_all_only = world.query.with_all([C_PermA, C_PermB]).get_cache_key()
var k_any_only = world.query.with_any([C_PermA, C_PermB]).get_cache_key()
var k_none_only = world.query.with_none([C_PermA, C_PermB]).get_cache_key()
assert_int(k_all_only).is_not_equal(k_any_only)
assert_int(k_all_only).is_not_equal(k_none_only)
assert_int(k_any_only).is_not_equal(k_none_only)
func test_domain_swaps_stability():
# Swapping order inside a single domain should not change key
var k_orig = _cache_key(ALL, ANY, NONE)
var all_rev = ALL.duplicate(); all_rev.reverse()
var any_rev = ANY.duplicate(); any_rev.reverse()
var none_rev = NONE.duplicate(); none_rev.reverse()
var k_rev_combo = _cache_key(all_rev, any_rev, none_rev)
assert_int(k_orig).is_equal(k_rev_combo)
func test_single_component_domains_invariance():
# Reduce domains to single components, permutations collapse
var k1 = _cache_key([C_PermA], [C_PermC], [C_PermE])
var k2 = _cache_key([C_PermA], [C_PermC], [C_PermE])
assert_int(k1).is_equal(k2)
func test_mixed_add_remove_domain_changes():
# Adding a component to ANY changes key; removing restores original
var base = _cache_key(ALL, [C_PermC], NONE)
var added = _cache_key(ALL, [C_PermC, C_PermD], NONE)
assert_int(base).is_not_equal(added)
var restored = _cache_key(ALL, [C_PermC], NONE)
assert_int(restored).is_equal(base)

View File

@@ -0,0 +1 @@
uid://bl360eyrl22in

View File

@@ -0,0 +1,78 @@
extends GdUnitTestSuite
# Verifies that with_all([...]) matches entities regardless of component order both in
# entity component-add order and query component array order. Uses 15 distinct component types.
var runner: GdUnitSceneRunner
var world: World
var ALL_COMPONENT_TYPES = [
C_OrderTestA, C_OrderTestB, C_OrderTestC, C_OrderTestD, C_OrderTestE,
C_OrderTestF, C_OrderTestG, C_OrderTestH, C_OrderTestI, C_OrderTestJ,
C_OrderTestK, C_OrderTestL, C_OrderTestM, C_OrderTestN, C_OrderTestO
]
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func _make_entity_with_components(order: Array) -> Entity:
var e = Entity.new()
for comp_type in order:
var comp = comp_type.new()
e.add_component(comp)
return e
func test_with_all_order_independent():
# Create entities with different component insertion orders
var shuffled1 = ALL_COMPONENT_TYPES.duplicate()
shuffled1.shuffle()
var shuffled2 = ALL_COMPONENT_TYPES.duplicate()
shuffled2.shuffle()
var shuffled3 = ALL_COMPONENT_TYPES.duplicate()
shuffled3.shuffle()
var e1 = _make_entity_with_components(shuffled1)
var e2 = _make_entity_with_components(shuffled2)
var e3 = _make_entity_with_components(shuffled3)
world.add_entities([e1, e2, e3])
# Build multiple queries with different ordering of with_all component arrays
var q_base = world.query.with_all(ALL_COMPONENT_TYPES).execute()
var rev = ALL_COMPONENT_TYPES.duplicate()
rev.reverse()
var q_rev = world.query.with_all(rev).execute()
var alt = ALL_COMPONENT_TYPES.duplicate(); alt.shuffle()
var q_alt = world.query.with_all(alt).execute()
# All queries should match all entities
assert_int(q_base.size()).is_equal(3)
assert_int(q_rev.size()).is_equal(3)
assert_int(q_alt.size()).is_equal(3)
# Ensure same entity set (order may differ). Convert to Set of instance IDs.
var set_base = q_base.map(func(e): return e.get_instance_id())
var set_rev = q_rev.map(func(e): return e.get_instance_id())
var set_alt = q_alt.map(func(e): return e.get_instance_id())
set_base.sort(); set_rev.sort(); set_alt.sort()
assert_array(set_base).is_equal(set_rev)
assert_array(set_base).is_equal(set_alt)
func test_cache_key_consistency():
# Verify cache key identical for different ordering
var qb1 = QueryBuilder.new(world).with_all(ALL_COMPONENT_TYPES.duplicate())
var key1 = qb1.get_cache_key()
var rev2 = ALL_COMPONENT_TYPES.duplicate()
rev2.reverse()
var qb2 = QueryBuilder.new(world).with_all(rev2)
var key2 = qb2.get_cache_key()
var shuffled = ALL_COMPONENT_TYPES.duplicate(); shuffled.shuffle()
var qb3 = QueryBuilder.new(world).with_all(shuffled)
var key3 = qb3.get_cache_key()
assert_int(key1).is_equal(key2)
assert_int(key1).is_equal(key3)

View File

@@ -0,0 +1 @@
uid://c8hseirwjt6oi

View File

@@ -0,0 +1,165 @@
extends GdUnitTestSuite
const C_TestA = preload("res://addons/gecs/tests/components/c_test_a.gd")
const C_TestB = preload("res://addons/gecs/tests/components/c_test_b.gd")
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
world.purge(false)
func test_relationship_string_representation():
# Test that two semantically identical relationships produce the same string
var rel1 = Relationship.new(C_TestA.new(10), null)
var rel2 = Relationship.new(C_TestA.new(10), null)
# These should produce the same string representation for cache keys
var str1 = str(rel1)
var str2 = str(rel2)
print("rel1 string: ", str1)
print("rel2 string: ", str2)
# They won't be equal as objects (different instances)
assert_bool(rel1 == rel2).is_false()
# But they should match semantically
assert_bool(rel1.matches(rel2)).is_true()
# The problem: their string representations are different!
# This breaks query caching
print("Strings equal? ", str1 == str2)
func test_relationship_with_entity_targets():
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.name = "entity1"
entity2.name = "entity2"
world.add_entity(entity1)
world.add_entity(entity2)
var rel1 = Relationship.new(C_TestA.new(), entity1)
var rel2 = Relationship.new(C_TestA.new(), entity1)
var rel3 = Relationship.new(C_TestA.new(), entity2)
print("rel1 with entity1: ", str(rel1))
print("rel2 with entity1: ", str(rel2))
print("rel3 with entity2: ", str(rel3))
# Should match same entity
assert_bool(rel1.matches(rel2)).is_true()
# Should not match different entity
assert_bool(rel1.matches(rel3)).is_false()
func test_query_cache_key_with_relationships():
# This test shows the actual problem with query caching
var entity = Entity.new()
world.add_entity(entity)
entity.add_component(C_TestA.new(5))
entity.add_relationship(Relationship.new(C_TestB.new(), null))
# These two queries are semantically identical
var query1 = world.query.with_relationship([Relationship.new(C_TestB.new(), null)])
var query2 = world.query.with_relationship([Relationship.new(C_TestB.new(), null)])
var key1 = query1.to_string()
var key2 = query2.to_string()
print("Query1 cache key: ", key1)
print("Query2 cache key: ", key2)
# These SHOULD be the same for proper caching
# But they're probably not because Relationship lacks to_string()
print("Cache keys equal? ", key1 == key2)
func test_relationship_matching_with_multiple_relationships():
# Test that relationship matching works regardless of order in relationships list
var target_entity = Entity.new()
target_entity.name = "target"
world.add_entity(target_entity)
var entity = Entity.new()
entity.name = "test_entity"
world.add_entity(entity)
# Add multiple relationships in specific order
entity.add_relationship(Relationship.new(C_TestA.new(1), target_entity))
entity.add_relationship(Relationship.new(C_TestA.new(2), target_entity))
entity.add_relationship(Relationship.new(C_TestB.new(99), target_entity))
print("Entity relationships count: ", entity.relationships.size())
# Try to find the C_TestB relationship - it's at index 2
var has_testb = entity.has_relationship(Relationship.new(C_TestB.new(), target_entity))
print("Has C_TestB relationship (at end of list): ", has_testb)
assert_bool(has_testb).is_true()
# Now try when C_TestB is first
var entity2 = Entity.new()
entity2.name = "test_entity2"
world.add_entity(entity2)
entity2.add_relationship(Relationship.new(C_TestB.new(99), target_entity))
entity2.add_relationship(Relationship.new(C_TestA.new(1), target_entity))
entity2.add_relationship(Relationship.new(C_TestA.new(2), target_entity))
var has_testb2 = entity2.has_relationship(Relationship.new(C_TestB.new(), target_entity))
print("Has C_TestB relationship (at start of list): ", has_testb2)
assert_bool(has_testb2).is_true()
# Test the actual relationship objects match
for i in range(entity.relationships.size()):
var rel = entity.relationships[i]
var test_rel = Relationship.new(C_TestB.new(), target_entity)
print("Relationship[", i, "] matches test_rel: ", rel.matches(test_rel))
print(" - Relation types: ", rel.relation.get_script().resource_path, " vs ", test_rel.relation.get_script().resource_path)
print(" - Target IDs: ", rel.target.id if rel.target else "null", " vs ", test_rel.target.id if test_rel.target else "null")
print(" - Targets same instance: ", rel.target == test_rel.target)
func test_query_with_multiple_relationships():
# Test that queries find entities even when they have multiple relationships
var target_entity = Entity.new()
target_entity.name = "target"
world.add_entity(target_entity)
var entity1 = Entity.new()
entity1.name = "entity1_single_rel"
world.add_entity(entity1)
entity1.add_relationship(Relationship.new(C_TestB.new(1), target_entity))
var entity2 = Entity.new()
entity2.name = "entity2_multi_rel"
world.add_entity(entity2)
entity2.add_relationship(Relationship.new(C_TestA.new(1), target_entity))
entity2.add_relationship(Relationship.new(C_TestA.new(2), target_entity))
entity2.add_relationship(Relationship.new(C_TestB.new(99), target_entity))
var entity3 = Entity.new()
entity3.name = "entity3_no_testb"
world.add_entity(entity3)
entity3.add_relationship(Relationship.new(C_TestA.new(5), target_entity))
print("\n=== Query Test ===")
print("entity1 relationships: ", entity1.relationships.size())
print("entity2 relationships: ", entity2.relationships.size())
print("entity3 relationships: ", entity3.relationships.size())
# Query for entities with C_TestB relationship
var query = world.query.with_relationship([Relationship.new(C_TestB.new(), target_entity)])
var results = Array(query.execute())
print("\nQuery results count: ", results.size())
for ent in results:
print(" - Found: ", ent.name)
# Both entity1 and entity2 should be found
assert_bool(results.has(entity1)).is_true()
assert_bool(results.has(entity2)).is_true()
assert_bool(results.has(entity3)).is_false()
assert_int(results.size()).is_equal(2)

View File

@@ -0,0 +1 @@
uid://bpc0k3us7q6cd

View File

@@ -0,0 +1,302 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_serialize_entity_with_basic_relationship():
# Create two entities with a basic relationship
var entity_a = Entity.new()
entity_a.name = "EntityA"
entity_a.add_component(C_TestA.new())
var entity_b = Entity.new()
entity_b.name = "EntityB"
entity_b.add_component(C_TestB.new())
# Create relationship: A -> B
var relationship = Relationship.new(C_TestC.new(), entity_b)
entity_a.add_relationship(relationship)
world.add_entity(entity_a)
world.add_entity(entity_b)
# Serialize only entity A (entity B should be auto-included)
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
# Validate serialization
assert_that(serialized_data).is_not_null()
assert_that(serialized_data.entities).has_size(2) # Both A and B should be included
# Check that entity B is marked as auto-included
var entity_a_data = serialized_data.entities.filter(func(e): return e.entity_name == "EntityA")[0]
var entity_b_data = serialized_data.entities.filter(func(e): return e.entity_name == "EntityB")[0]
assert_that(entity_a_data.auto_included).is_false() # Original query entity
assert_that(entity_b_data.auto_included).is_true() # Auto-included dependency
# Check relationship data
assert_that(entity_a_data.relationships).has_size(1)
var rel_data = entity_a_data.relationships[0]
assert_that(rel_data.target_type).is_equal("Entity")
assert_that(rel_data.target_entity_id).is_equal(entity_b.id)
func test_deserialize_entity_with_basic_relationship():
# Create and serialize entities with relationship
var entity_a = Entity.new()
entity_a.name = "EntityA"
entity_a.add_component(C_TestA.new())
var entity_b = Entity.new()
entity_b.name = "EntityB"
entity_b.add_component(C_TestB.new())
var relationship = Relationship.new(C_TestC.new(), entity_b)
entity_a.add_relationship(relationship)
world.add_entity(entity_a)
world.add_entity(entity_b)
# Serialize
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
# Save and load
var file_path = "res://reports/test_relationship_basic.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
# Validate deserialization
assert_that(deserialized_entities).has_size(2)
var des_entity_a = deserialized_entities.filter(func(e): return e.name == "EntityA")[0]
var des_entity_b = deserialized_entities.filter(func(e): return e.name == "EntityB")[0]
# Check that relationships are restored
assert_that(des_entity_a.relationships).has_size(1)
var des_relationship = des_entity_a.relationships[0]
assert_that(des_relationship.target).is_equal(des_entity_b)
# Cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_circular_relationships():
# Create entities with circular relationships: A -> B -> A
var entity_a = Entity.new()
entity_a.name = "EntityA"
entity_a.add_component(C_TestA.new())
var entity_b = Entity.new()
entity_b.name = "EntityB"
entity_b.add_component(C_TestB.new())
# Create circular relationships
var rel_a_to_b = Relationship.new(C_TestC.new(), entity_b)
var rel_b_to_a = Relationship.new(C_TestD.new(), entity_a)
entity_a.add_relationship(rel_a_to_b)
entity_b.add_relationship(rel_b_to_a)
world.add_entity(entity_a)
world.add_entity(entity_b)
# Serialize starting from entity A
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
# Should include both entities (no infinite loop)
assert_that(serialized_data.entities).has_size(2)
# Deserialize and validate
var file_path = "res://reports/test_relationship_circular.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
assert_that(deserialized_entities).has_size(2)
var des_a = deserialized_entities.filter(func(e): return e.name == "EntityA")[0]
var des_b = deserialized_entities.filter(func(e): return e.name == "EntityB")[0]
# Validate circular relationships are restored
assert_that(des_a.relationships).has_size(1)
assert_that(des_b.relationships).has_size(1)
assert_that(des_a.relationships[0].target).is_equal(des_b)
assert_that(des_b.relationships[0].target).is_equal(des_a)
# Cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_component_target_relationship():
# Create entity with component-based relationship
var entity = Entity.new()
entity.name = "EntityWithComponentRel"
entity.add_component(C_TestA.new())
# Create relationship with Component target
var target_component = C_TestB.new()
# Note: Components don't have a 'name' property, so we don't set it
var relationship = Relationship.new(C_TestC.new(), target_component)
entity.add_relationship(relationship)
world.add_entity(entity)
# Serialize and deserialize
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_relationship_component.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
# Validate
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.relationships).has_size(1)
var des_relationship = des_entity.relationships[0]
assert_that(des_relationship.target is C_TestB).is_true()
# Cleanup
auto_free(des_entity)
func test_script_target_relationship():
# Create entity with script archetype relationship
var entity = Entity.new()
entity.name = "EntityWithScriptRel"
entity.add_component(C_TestA.new())
# Create relationship with Script target
var relationship = Relationship.new(C_TestC.new(), C_TestB)
entity.add_relationship(relationship)
world.add_entity(entity)
# Serialize and deserialize
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_relationship_script.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
# Validate
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.relationships).has_size(1)
var des_relationship = des_entity.relationships[0]
assert_that(des_relationship.target).is_equal(C_TestB)
# Cleanup
auto_free(des_entity)
func test_id_persistence_across_save_load_cycles():
# Create entity and save its UUID
var entity = Entity.new()
entity.name = "UUIDTestEntity"
entity.add_component(C_TestA.new())
world.add_entity(entity)
var original_id = entity.id
# Serialize, save, and load multiple times
var query = world.query.with_all([C_TestA])
for cycle in range(3):
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_id_cycle_" + str(cycle) + ".tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.id).is_equal(original_id)
# Cleanup
auto_free(des_entity)
func test_deep_relationship_chain():
# Create a chain: A -> B -> C -> D
var entities = []
for i in range(4):
var entity = Entity.new()
entity.name = "Entity" + String.num(i)
entity.add_component(C_TestA.new())
entities.append(entity)
world.add_entity(entity)
# Create chain relationships
for i in range(3):
var relationship = Relationship.new(C_TestC.new(), entities[i + 1])
entities[i].add_relationship(relationship)
# Serialize starting from first entity only - create a query that matches just the first entity
# We'll use a unique component for the first entity
entities[0].add_component(C_TestE.new()) # Add unique component to first entity
var query = world.query.with_all([C_TestE])
var serialized_data = ECS.serialize(query)
# Should auto-include entire chain
assert_that(serialized_data.entities).has_size(4)
# Verify auto-inclusion flags
var auto_included_count = 0
var original_entity_count = 0
for entity_data in serialized_data.entities:
if entity_data.auto_included:
auto_included_count += 1
else:
original_entity_count += 1
assert_that(original_entity_count).is_equal(1) # Only one entity from original query
assert_that(auto_included_count).is_equal(3) # Three entities auto-included
# Test deserialization
var file_path = "res://reports/test_relationship_chain.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
assert_that(deserialized_entities).has_size(4)
# Cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_backward_compatibility_no_relationships():
# Test that entities without relationships still work
var entity = Entity.new()
entity.name = "NoRelationshipEntity"
entity.add_component(C_TestA.new())
world.add_entity(entity)
# Serialize and deserialize
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_no_relationships.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
# Should work normally
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.name).is_equal("NoRelationshipEntity")
assert_that(des_entity.relationships).has_size(0)
assert_that(des_entity.id).is_not_equal("")
# Cleanup
auto_free(des_entity)

View File

@@ -0,0 +1 @@
uid://d1xbolxpx2n81

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://ddcusum4gp5im

View File

@@ -0,0 +1,62 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_serialize_basic_entity():
# Create a simple entity with one component
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_SerializationTest.new())
world.add_entity(entity)
# Serialize the entity
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Basic validation
assert_that(serialized_data).is_not_null()
assert_that(serialized_data.version).is_equal("0.2")
assert_that(serialized_data.entities).has_size(1)
print("Serialized data: ", JSON.stringify(serialized_data, "\t"))
func test_save_and_load_simple():
# Create a simple entity
var entity = Entity.new()
entity.name = "SaveLoadTest"
entity.add_component(C_SerializationTest.new(123, 4.56, "save_load_test", false))
world.add_entity(entity)
# Serialize and save
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_simple.tres"
ECS.save(serialized_data, file_path)
# Load and deserialize
var deserialized_entities = ECS.deserialize(file_path)
# Validate
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.name).is_equal("SaveLoadTest")
# Use auto_free for cleanup
for _entity in deserialized_entities:
auto_free(_entity)
# Keep file for inspection in reports directory

View File

@@ -0,0 +1 @@
uid://2coo3k0qawx

View File

@@ -0,0 +1,358 @@
extends GdUnitTestSuite
## Test suite for subsystem component modification propagation
## Tests that when subsystem A modifies entity components (causing archetype moves),
## subsystem B can see those changes in the same frame
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## ===============================
## COMPONENT ADDITION PROPAGATION
## ===============================
## Test that components added by subsystem A are visible to subsystem B in the same frame
func test_subsystem_component_addition_propagation():
# Create entities with only component A
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_component(C_OrderTestA.new())
entity2.add_component(C_OrderTestA.new())
entity3.add_component(C_OrderTestA.new())
world.add_entities([entity1, entity2, entity3])
# Create system with two subsystems:
# Subsystem 1: Find entities with A, add B
# Subsystem 2: Find entities with B, increment counter
var system = ComponentAdditionPropagationSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Subsystem 1 processed 3 entities (added B to all of them)
assert_int(system.subsystem1_count).is_equal(3)
# Verify: Subsystem 2 processed 3 entities (saw all B components added by subsystem 1)
assert_int(system.subsystem2_count).is_equal(3)
# Verify: All entities now have both A and B
assert_bool(entity1.has_component(C_OrderTestA)).is_true()
assert_bool(entity1.has_component(C_OrderTestB)).is_true()
assert_bool(entity2.has_component(C_OrderTestA)).is_true()
assert_bool(entity2.has_component(C_OrderTestB)).is_true()
assert_bool(entity3.has_component(C_OrderTestA)).is_true()
assert_bool(entity3.has_component(C_OrderTestB)).is_true()
## Test that components removed by subsystem A are not visible to subsystem B in the same frame
func test_subsystem_component_removal_propagation():
# Create entities with both A and B
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_component(C_OrderTestA.new())
entity1.add_component(C_OrderTestB.new())
entity2.add_component(C_OrderTestA.new())
entity2.add_component(C_OrderTestB.new())
entity3.add_component(C_OrderTestA.new())
entity3.add_component(C_OrderTestB.new())
world.add_entities([entity1, entity2, entity3])
# Create system with two subsystems:
# Subsystem 1: Find entities with A, remove A
# Subsystem 2: Find entities with A, increment counter (should see none)
var system = ComponentRemovalPropagationSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Subsystem 1 processed 3 entities (removed A from all of them)
assert_int(system.subsystem1_count).is_equal(3)
# Verify: Subsystem 2 processed 0 entities (no entities have A anymore)
assert_int(system.subsystem2_count).is_equal(0)
# Verify: All entities still have B but not A
assert_bool(entity1.has_component(C_OrderTestA)).is_false()
assert_bool(entity1.has_component(C_OrderTestB)).is_true()
assert_bool(entity2.has_component(C_OrderTestA)).is_false()
assert_bool(entity2.has_component(C_OrderTestB)).is_true()
assert_bool(entity3.has_component(C_OrderTestA)).is_false()
assert_bool(entity3.has_component(C_OrderTestB)).is_true()
## Test that component modifications causing archetype moves are handled correctly
func test_subsystem_archetype_move_propagation():
# Create entities with different starting components
var entity1 = Entity.new() # Has A
var entity2 = Entity.new() # Has A
var entity3 = Entity.new() # Has B
var entity4 = Entity.new() # Has B
entity1.add_component(C_OrderTestA.new())
entity2.add_component(C_OrderTestA.new())
entity3.add_component(C_OrderTestB.new())
entity4.add_component(C_OrderTestB.new())
world.add_entities([entity1, entity2, entity3, entity4])
# Create system with three subsystems:
# Subsystem 1: Find entities with A, add B (archetype move from A to A+B)
# Subsystem 2: Find entities with B but not A, add A (archetype move from B to A+B)
# Subsystem 3: Find entities with A+B, increment counter (should see all 4)
var system = ArchetypeMovePropagationSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Subsystem 1 processed 2 entities (entity1, entity2)
assert_int(system.subsystem1_count).is_equal(2)
# Verify: Subsystem 2 processed 2 entities (entity3, entity4)
assert_int(system.subsystem2_count).is_equal(2)
# Verify: Subsystem 3 processed 4 entities (all entities now have A+B)
assert_int(system.subsystem3_count).is_equal(4)
# Verify: All entities now have both A and B
assert_bool(entity1.has_component(C_OrderTestA)).is_true()
assert_bool(entity1.has_component(C_OrderTestB)).is_true()
assert_bool(entity2.has_component(C_OrderTestA)).is_true()
assert_bool(entity2.has_component(C_OrderTestB)).is_true()
assert_bool(entity3.has_component(C_OrderTestA)).is_true()
assert_bool(entity3.has_component(C_OrderTestB)).is_true()
assert_bool(entity4.has_component(C_OrderTestA)).is_true()
assert_bool(entity4.has_component(C_OrderTestB)).is_true()
## Test that entities are not double-processed when moving between archetypes
func test_subsystem_no_double_processing():
# Create entities with A
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.add_component(C_OrderTestA.new())
entity2.add_component(C_OrderTestA.new())
world.add_entities([entity1, entity2])
# Create system with one subsystem that adds B to entities with A
# This causes archetype move from A to A+B
# System should NOT process the same entity twice
var system = NoDoubleProcessingSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Each entity processed exactly once
assert_int(system.entity1_process_count).is_equal(1)
assert_int(system.entity2_process_count).is_equal(1)
## Test that multiple archetype moves in sequence are handled correctly
func test_subsystem_multiple_archetype_moves():
# Create entity with A
var entity = Entity.new()
entity.add_component(C_OrderTestA.new())
world.add_entity(entity)
# Create system with subsystems that progressively add components:
# Subsystem 1: A -> add B (A+B)
# Subsystem 2: A+B -> add C (A+B+C)
# Subsystem 3: A+B+C -> increment counter
var system = MultipleArchetypeMovesSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Each subsystem processed the entity
assert_int(system.subsystem1_count).is_equal(1)
assert_int(system.subsystem2_count).is_equal(1)
assert_int(system.subsystem3_count).is_equal(1)
# Verify: Entity has all three components
assert_bool(entity.has_component(C_OrderTestA)).is_true()
assert_bool(entity.has_component(C_OrderTestB)).is_true()
assert_bool(entity.has_component(C_OrderTestC)).is_true()
## Test with many entities to ensure archetype moves scale correctly
func test_subsystem_archetype_move_at_scale():
# Create 100 entities with A
var entities = []
for i in 100:
var entity = Entity.new()
entity.add_component(C_OrderTestA.new())
entities.append(entity)
world.add_entities(entities)
# Create system that adds B to all entities with A
var system = ComponentAdditionPropagationSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: Subsystem 1 processed 100 entities
assert_int(system.subsystem1_count).is_equal(100)
# Verify: Subsystem 2 processed 100 entities (saw all B components)
assert_int(system.subsystem2_count).is_equal(100)
# Verify: All entities have both A and B
for entity in entities:
assert_bool(entity.has_component(C_OrderTestA)).is_true()
assert_bool(entity.has_component(C_OrderTestB)).is_true()
## ===============================
## TEST HELPER SYSTEMS
## ===============================
## System that adds components in subsystem 1 and checks them in subsystem 2
class ComponentAdditionPropagationSystem extends System:
var subsystem1_count = 0
var subsystem2_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA]), add_component_b],
[ECS.world.query.with_all([C_OrderTestB]), count_component_b]
]
func add_component_b(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.add_component(C_OrderTestB.new())
subsystem1_count += 1
func count_component_b(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - called once per archetype
# So we need to accumulate the count across all archetype calls
subsystem2_count += entities.size()
## System that removes components in subsystem 1 and checks them in subsystem 2
class ComponentRemovalPropagationSystem extends System:
var subsystem1_count = 0
var subsystem2_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA]), remove_component_a],
[ECS.world.query.with_all([C_OrderTestA]), count_component_a]
]
func remove_component_a(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.remove_component(C_OrderTestA)
subsystem1_count += 1
func count_component_a(entities: Array[Entity], components: Array, delta: float):
subsystem2_count += entities.size()
## System that moves entities between archetypes
class ArchetypeMovePropagationSystem extends System:
var subsystem1_count = 0
var subsystem2_count = 0
var subsystem3_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA]).with_none([C_OrderTestB]), add_b_to_a],
[ECS.world.query.with_all([C_OrderTestB]).with_none([C_OrderTestA]), add_a_to_b],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]), count_both]
]
func add_b_to_a(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.add_component(C_OrderTestB.new())
subsystem1_count += 1
func add_a_to_b(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.add_component(C_OrderTestA.new())
subsystem2_count += 1
func count_both(entities: Array[Entity], components: Array, delta: float):
subsystem3_count += entities.size()
## System that tracks individual entity processing to detect double-processing
class NoDoubleProcessingSystem extends System:
var entity1_process_count = 0
var entity2_process_count = 0
var tracked_entities = {}
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA]), process_entities]
]
func process_entities(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
# Track which entity is being processed
if not tracked_entities.has(entity):
tracked_entities[entity] = 0
tracked_entities[entity] += 1
# Count for first two entities
var keys = tracked_entities.keys()
if entity == keys[0]:
entity1_process_count = tracked_entities[entity]
elif keys.size() > 1 and entity == keys[1]:
entity2_process_count = tracked_entities[entity]
# Add B to trigger archetype move
if not entity.has_component(C_OrderTestB):
entity.add_component(C_OrderTestB.new())
## System that performs multiple sequential archetype moves
class MultipleArchetypeMovesSystem extends System:
var subsystem1_count = 0
var subsystem2_count = 0
var subsystem3_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA]).with_none([C_OrderTestB]), add_b],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]).with_none([C_OrderTestC]), add_c],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB, C_OrderTestC]), count_all]
]
func add_b(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.add_component(C_OrderTestB.new())
subsystem1_count += 1
func add_c(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
entity.add_component(C_OrderTestC.new())
subsystem2_count += 1
func count_all(entities: Array[Entity], components: Array, delta: float):
subsystem3_count += entities.size()
## ===============================
## TEST HELPER COMPONENTS
## ===============================
## Using existing component classes from addons/gecs/tests/components/
## - C_OrderTestA
## - C_OrderTestB
## - C_OrderTestC

View File

@@ -0,0 +1 @@
uid://dbnfyv4w0xa14

View File

@@ -0,0 +1,447 @@
extends GdUnitTestSuite
## Test suite for multi-entity subsystem propagation (projectile scenario)
## Tests that when subsystem A adds components to MULTIPLE entities,
## subsystem B sees ALL of them in the same frame
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## Test the exact projectile scenario: travelling entities that collide get C_Collision added,
## then collision subsystem processes all entities with C_Collision
func test_projectile_collision_propagation():
# Create 3 "projectiles" with base components (simulating travelling projectiles)
var projectile1 = Entity.new()
var projectile2 = Entity.new()
var projectile3 = Entity.new()
projectile1.add_component(C_OrderTestA.new()) # Represents C_Projectile
projectile1.add_component(C_OrderTestB.new()) # Represents C_Velocity
projectile2.add_component(C_OrderTestA.new())
projectile2.add_component(C_OrderTestB.new())
projectile3.add_component(C_OrderTestA.new())
projectile3.add_component(C_OrderTestB.new())
world.add_entities([projectile1, projectile2, projectile3])
# System simulates: travelling_subsys adds collision, then collision_subsys processes them
var system = ProjectileCollisionSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: travelling_subsys saw 3 entities
assert_int(system.travelling_count).is_equal(3)
# Verify: travelling_subsys added collision to all 3
assert_int(system.collisions_added).is_equal(3)
# CRITICAL: collision_subsys should see ALL 3 entities with collision
assert_int(system.collision_count).is_equal(3)
# Verify: All entities have the collision component
assert_bool(projectile1.has_component(C_OrderTestC)).is_true()
assert_bool(projectile2.has_component(C_OrderTestC)).is_true()
assert_bool(projectile3.has_component(C_OrderTestC)).is_true()
## Test with many entities (10 projectiles) to ensure it scales
func test_projectile_collision_propagation_at_scale():
var projectiles = []
# Create 10 projectiles
for i in 10:
var projectile = Entity.new()
projectile.add_component(C_OrderTestA.new())
projectile.add_component(C_OrderTestB.new())
projectiles.append(projectile)
world.add_entities(projectiles)
var system = ProjectileCollisionSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: travelling_subsys saw 10 entities
assert_int(system.travelling_count).is_equal(10)
# Verify: travelling_subsys added collision to all 10
assert_int(system.collisions_added).is_equal(10)
# CRITICAL: collision_subsys should see ALL 10 entities
assert_int(system.collision_count).is_equal(10)
# Verify: All entities have collision
for projectile in projectiles:
assert_bool(projectile.has_component(C_OrderTestC)).is_true()
## Test when only SOME entities collide (partial propagation)
func test_projectile_partial_collision_propagation():
var projectile1 = Entity.new()
var projectile2 = Entity.new()
var projectile3 = Entity.new()
var projectile4 = Entity.new()
# All start with A+B
projectile1.add_component(C_OrderTestA.new())
projectile1.add_component(C_OrderTestB.new())
projectile2.add_component(C_OrderTestA.new())
projectile2.add_component(C_OrderTestB.new())
projectile3.add_component(C_OrderTestA.new())
projectile3.add_component(C_OrderTestB.new())
projectile4.add_component(C_OrderTestA.new())
projectile4.add_component(C_OrderTestB.new())
world.add_entities([projectile1, projectile2, projectile3, projectile4])
# System that only adds collision to SOME entities
var system = ProjectilePartialCollisionSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: travelling_subsys saw 4 entities
assert_int(system.travelling_count).is_equal(4)
# Verify: only 2 collisions added (system logic adds every other)
assert_int(system.collisions_added).is_equal(2)
# CRITICAL: collision_subsys should see exactly 2 entities
assert_int(system.collision_count).is_equal(2)
## Test entities that add collision and then get removed in collision handler
func test_projectile_collision_then_removal():
var projectiles = []
# Create 5 projectiles
for i in 5:
var projectile = Entity.new()
projectile.add_component(C_OrderTestA.new())
projectile.add_component(C_OrderTestB.new())
projectiles.append(projectile)
world.add_entities(projectiles)
var system = ProjectileCollisionRemovalSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: travelling_subsys saw 5 entities
assert_int(system.travelling_count).is_equal(5)
# Verify: collision_subsys saw 5 entities
assert_int(system.collision_count).is_equal(5)
# Verify: All 5 entities were removed
assert_int(system.entities_removed.size()).is_equal(5)
# Verify: Entities are no longer in the world
var remaining = world.query.with_all([C_OrderTestA]).execute()
assert_int(remaining.size()).is_equal(0)
## Test EXACT projectile scenario: travelling adds collision, collision subsys processes and removes
## This tests with multiple entities being processed together and removed in batch
func test_exact_projectile_scenario_with_batch_removal():
var projectiles = []
# Create 5 projectiles (simulating multiple projectiles fired)
for i in 5:
var projectile = Entity.new()
projectile.name = "Projectile_%d" % i
projectile.add_component(C_OrderTestA.new()) # C_Projectile
projectile.add_component(C_OrderTestB.new()) # C_Velocity
projectiles.append(projectile)
world.add_entities(projectiles)
# System that mimics your exact code structure
var system = ExactProjectileSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: All projectiles were seen in travelling
assert_int(system.travelling_count).is_equal(5)
# CRITICAL: All projectiles should be seen in collision subsystem
assert_int(system.collision_count).is_equal(5)
# Verify: All projectiles were removed
var remaining_projectiles = world.query.with_all([C_OrderTestA]).execute()
assert_int(remaining_projectiles.size()).is_equal(0)
# Verify: No projectiles with collision component remain either
var remaining_with_collision = world.query.with_all([C_OrderTestA, C_OrderTestC]).execute()
assert_int(remaining_with_collision.size()).is_equal(0)
## Test multiple frames to see if entities accumulate (your actual bug)
func test_multiple_frames_with_projectiles():
# Frame 1: Fire 3 projectiles
var frame1_projectiles = []
for i in 3:
var p = Entity.new()
p.name = "Frame1_Projectile_%d" % i
p.add_component(C_OrderTestA.new())
p.add_component(C_OrderTestB.new())
frame1_projectiles.append(p)
world.add_entities(frame1_projectiles)
var system = ExactProjectileSystem.new()
world.add_system(system)
# Process frame 1
world.process(0.016)
assert_int(world.query.with_all([C_OrderTestA]).execute().size()).is_equal(0)
# Frame 2: Fire 4 more projectiles
var frame2_projectiles = []
for i in 4:
var p = Entity.new()
p.name = "Frame2_Projectile_%d" % i
p.add_component(C_OrderTestA.new())
p.add_component(C_OrderTestB.new())
frame2_projectiles.append(p)
world.add_entities(frame2_projectiles)
# Process frame 2
world.process(0.016)
assert_int(world.query.with_all([C_OrderTestA]).execute().size()).is_equal(0)
# Frame 3: Fire 5 more projectiles (this is where it might break)
var frame3_projectiles = []
for i in 5:
var p = Entity.new()
p.name = "Frame3_Projectile_%d" % i
p.add_component(C_OrderTestA.new())
p.add_component(C_OrderTestB.new())
frame3_projectiles.append(p)
world.add_entities(frame3_projectiles)
# Process frame 3
world.process(0.016)
# CRITICAL: All projectiles should be removed, none should accumulate
var remaining = world.query.with_all([C_OrderTestA]).execute()
assert_int(remaining.size()).is_equal(0)
## REGRESSION TEST: Cache invalidation when removing entities
## This test ensures that _remove_entity_from_archetype() invalidates the query cache.
## Without cache invalidation, queries in subsequent frames would return stale archetype references.
##
## BUG: If _remove_entity_from_archetype() doesn't call _invalidate_cache(), then:
## 1. Frame N: Entities removed, cache still points to old archetype state
## 2. Frame N+1: Query uses stale cache, processes wrong/deleted entities
func test_cache_invalidation_on_entity_removal():
# Frame 1: Create and remove entities
var frame1_entities = []
for i in 3:
var e = Entity.new()
e.name = "Frame1_Entity_%d" % i
e.add_component(C_OrderTestA.new())
frame1_entities.append(e)
world.add_entities(frame1_entities)
# Verify entities exist
var query_result = world.query.with_all([C_OrderTestA]).execute()
assert_int(query_result.size()).is_equal(3)
# Remove all entities - this MUST invalidate the cache
world.remove_entities(frame1_entities)
# Verify entities are gone
query_result = world.query.with_all([C_OrderTestA]).execute()
assert_int(query_result.size()).is_equal(0)
# Frame 2: Create NEW entities with same components
var frame2_entities = []
for i in 5:
var e = Entity.new()
e.name = "Frame2_Entity_%d" % i
e.add_component(C_OrderTestA.new())
frame2_entities.append(e)
world.add_entities(frame2_entities)
# CRITICAL: Query should return ONLY the 5 new entities, not stale references
query_result = world.query.with_all([C_OrderTestA]).execute()
assert_int(query_result.size()).is_equal(5)
# Verify the entities in the result are the NEW ones, not deleted ones
for entity in query_result:
assert_bool(frame2_entities.has(entity)).is_true()
assert_str(entity.name).starts_with("Frame2_Entity_")
## Test with entities in different starting archetypes (some have extra components)
func test_projectile_mixed_archetypes():
# 2 basic projectiles (A+B)
var projectile1 = Entity.new()
var projectile2 = Entity.new()
projectile1.add_component(C_OrderTestA.new())
projectile1.add_component(C_OrderTestB.new())
projectile2.add_component(C_OrderTestA.new())
projectile2.add_component(C_OrderTestB.new())
# 2 "special" projectiles with extra component (different archetype)
var projectile3 = Entity.new()
var projectile4 = Entity.new()
var extra_comp1 = C_DomainTestA.new()
var extra_comp2 = C_DomainTestA.new()
projectile3.add_component(C_OrderTestA.new())
projectile3.add_component(C_OrderTestB.new())
projectile3.add_component(extra_comp1)
projectile4.add_component(C_OrderTestA.new())
projectile4.add_component(C_OrderTestB.new())
projectile4.add_component(extra_comp2)
world.add_entities([projectile1, projectile2, projectile3, projectile4])
var system = ProjectileCollisionSystem.new()
world.add_system(system)
# Process system once
world.process(0.016)
# Verify: ALL 4 entities were processed despite different starting archetypes
assert_int(system.travelling_count).is_equal(4)
assert_int(system.collisions_added).is_equal(4)
assert_int(system.collision_count).is_equal(4)
## ===============================
## TEST HELPER SYSTEMS
## ===============================
## Simulates the ProjectileSystem: travelling_subsys adds collision, collision_subsys processes
class ProjectileCollisionSystem extends System:
var travelling_count = 0
var collisions_added = 0
var collision_count = 0
func sub_systems() -> Array[Array]:
return [
# Travelling subsystem: entities with A+B (no C yet)
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]), travelling_subsys],
# Collision subsystem: entities with A+B+C
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB, C_OrderTestC]), collision_subsys]
]
func travelling_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - called once per archetype
# Accumulate count across all archetype calls
travelling_count += entities.size()
# Simulate all entities colliding and getting C_Collision (OrderTestC)
for entity in entities:
entity.add_component(C_OrderTestC.new())
collisions_added += 1
func collision_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - called once per archetype
# Accumulate count across all archetype calls
collision_count += entities.size()
# Just count how many we see
## System where only SOME entities collide
class ProjectilePartialCollisionSystem extends System:
var travelling_count = 0
var collisions_added = 0
var collision_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]), travelling_subsys],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB, C_OrderTestC]), collision_subsys]
]
func travelling_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - accumulate across archetype calls
travelling_count += entities.size()
# Only add collision to every other entity
var collide = true
for entity in entities:
if collide:
entity.add_component(C_OrderTestC.new())
collisions_added += 1
collide = !collide
func collision_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - accumulate across archetype calls
collision_count += entities.size()
## System that removes entities after collision handling
class ProjectileCollisionRemovalSystem extends System:
var travelling_count = 0
var collision_count = 0
var entities_removed = []
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]), travelling_subsys],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB, C_OrderTestC]), collision_subsys]
]
func travelling_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - accumulate across archetype calls
travelling_count += entities.size()
for entity in entities:
entity.add_component(C_OrderTestC.new())
func collision_subsys(entities: Array[Entity], components: Array, delta: float):
# Subsystems work like regular systems - accumulate across archetype calls
collision_count += entities.size()
# Track entities and remove them (simulating projectile destruction)
for entity in entities:
entities_removed.append(entity)
# Remove all at once (like your real code does)
ECS.world.remove_entities(entities)
## System that exactly mirrors your ProjectileSystem structure
class ExactProjectileSystem extends System:
var travelling_count = 0
var collision_count = 0
func sub_systems() -> Array[Array]:
return [
# IMPORTANT: Travelling MUST run first to add collision component
# Then collision handler can see all entities with collision
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB]), travelling_subsys],
[ECS.world.query.with_all([C_OrderTestA, C_OrderTestB, C_OrderTestC]), projectile_collision_subsys]
]
func travelling_subsys(entities: Array[Entity], components: Array, delta: float):
travelling_count = entities.size()
# Simulate all projectiles colliding
for e_projectile in entities:
# Add collision component (simulating move_and_slide collision)
e_projectile.add_component(C_OrderTestC.new())
func projectile_collision_subsys(entities: Array[Entity], components: Array, delta: float):
collision_count = entities.size()
# Remove all projectiles that collided (matching your exact code)
ECS.world.remove_entities(entities)

View File

@@ -0,0 +1 @@
uid://6yo4w3eupvbq

View File

@@ -0,0 +1,133 @@
extends GdUnitTestSuite
const C_TestA = preload("res://addons/gecs/tests/components/c_test_a.gd")
const C_TestB = preload("res://addons/gecs/tests/components/c_test_b.gd")
const C_Interacting = preload("res://addons/gecs/tests/components/c_test_c.gd")
const C_HasActiveItem = preload("res://addons/gecs/tests/components/c_test_d.gd")
var runner: GdUnitSceneRunner
var world: World
var test_system: TestSubsystemRelationships
# Track what was found in each subsystem for assertions
var subsystem1_found = []
var subsystem2_found = []
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
subsystem1_found.clear()
subsystem2_found.clear()
world.purge(false)
# Test system that uses subsystems (like InteractionsSystem)
class TestSubsystemRelationships extends System:
var test_suite
func sub_systems():
return [
# Subsystem 1: Check for entities with C_TestB and NO C_Interacting
[ECS.world.query.with_relationship([Relationship.new(C_TestB.new())]).with_none([C_Interacting]), process_can_interact],
# Subsystem 2: Check for entities with C_TestA (any)
[ECS.world.query.with_relationship([Relationship.new(C_TestA.new())]), process_being_interacted],
]
func process_can_interact(entities: Array[Entity], components: Array, delta: float):
print("Subsystem 1 processing: ", entities.size(), " entities")
for entity in entities:
print(" - Subsystem 1 found: ", entity.name)
test_suite.subsystem1_found.append(entity)
func process_being_interacted(entities: Array[Entity], components: Array, delta: float):
print("Subsystem 2 processing: ", entities.size(), " entities")
for entity in entities:
print(" - Subsystem 2 found: ", entity.name)
test_suite.subsystem2_found.append(entity)
func test_subsystem_with_existing_relationship_blocks_new_relationship_query():
# Exact scenario: Player has C_HasActiveItem, walks into area getting C_CanInteractWith
# Subsystem queries for C_CanInteractWith but doesn't find player!
# Create and add our test system with subsystems
test_system = TestSubsystemRelationships.new()
test_system.test_suite = self
world.add_system(test_system)
var target = Entity.new()
target.name = "interactable"
world.add_entity(target)
var player = Entity.new()
player.name = "player"
world.add_entity(player)
print("\n=== Phase 1: Player has C_HasActiveItem (simulating equipped weapon) ===")
# Player already has a relationship (simulating C_HasActiveItem from equipped weapon)
player.add_relationship(Relationship.new(C_HasActiveItem.new(1), target))
print("Player relationships: ", player.relationships.size())
# Process subsystems - should find nothing yet
subsystem1_found.clear()
subsystem2_found.clear()
world.process(0.016)
print("\nAfter first process:")
print("Subsystem1 found (C_TestB): ", subsystem1_found.size())
print("Subsystem2 found (C_TestA): ", subsystem2_found.size())
print("\n=== Phase 2: Player walks into area, gets C_CanInteractWith (C_TestB) ===")
# Player walks into interaction area and gets C_CanInteractWith relationship
player.add_relationship(Relationship.new(C_TestB.new(99), target))
print("Player relationships: ", player.relationships.size())
print("Player has C_TestB: ", player.has_relationship(Relationship.new(C_TestB.new())))
# Process subsystems again - BUG: might not find player in subsystem1!
subsystem1_found.clear()
subsystem2_found.clear()
world.process(0.016)
print("\nAfter second process:")
print("Subsystem1 found (C_TestB): ", subsystem1_found.size())
for ent in subsystem1_found:
print(" - ", ent.name)
print("Subsystem2 found (C_TestA): ", subsystem2_found.size())
# CRITICAL: Subsystem1 should have found the player!
assert_bool(subsystem1_found.has(player)).is_true()
func test_subsystem_without_existing_relationship_works():
# Control test: Same scenario but WITHOUT the C_HasActiveItem first
# This should work fine
# Create and add our test system with subsystems
test_system = TestSubsystemRelationships.new()
test_system.test_suite = self
world.add_system(test_system)
var target = Entity.new()
target.name = "interactable"
world.add_entity(target)
var player = Entity.new()
player.name = "player"
world.add_entity(player)
print("\n=== Control: Player gets C_TestB WITHOUT existing C_HasActiveItem ===")
# Player walks into interaction area and gets C_CanInteractWith relationship
player.add_relationship(Relationship.new(C_TestB.new(99), target))
print("Player relationships: ", player.relationships.size())
print("Player has C_TestB: ", player.has_relationship(Relationship.new(C_TestB.new())))
# Process subsystems - should find player!
subsystem1_found.clear()
subsystem2_found.clear()
world.process(0.016)
print("\nAfter process:")
print("Subsystem1 found (C_TestB): ", subsystem1_found.size())
for ent in subsystem1_found:
print(" - ", ent.name)
# This should work
assert_bool(subsystem1_found.has(player)).is_true()

View File

@@ -0,0 +1 @@
uid://fo54moeskub

View File

@@ -0,0 +1,524 @@
extends GdUnitTestSuite
## Comprehensive test suite for System.sub_systems() functionality
## Tests execution methods, callable signatures, caching, error handling, and execution order
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## ===============================
## SUBSYSTEM EXECUTION WITH DIFFERENT EXECUTION METHODS
## ===============================
## Test subsystem with PROCESS execution method
func test_subsystem_process_execution():
# Create entities
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.add_component(C_SubsystemTestA.new())
entity2.add_component(C_SubsystemTestA.new())
world.add_entities([entity1, entity2])
# Create system with PROCESS subsystem
var system = SubsystemProcessTest.new()
world.add_system(system)
# Process system
world.process(0.016)
# Verify: process_subsystem called once per entity
assert_int(system.call_count).is_equal(2)
assert_array(system.entities_processed).contains_exactly([entity1, entity2])
## Test subsystem with PROCESS_ALL execution method
func test_subsystem_process_all_execution():
# Create entities
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.add_component(C_SubsystemTestA.new())
entity2.add_component(C_SubsystemTestA.new())
world.add_entities([entity1, entity2])
# Create system with PROCESS_ALL subsystem
var system = SubsystemProcessAllTest.new()
world.add_system(system)
# Process system
world.process(0.016)
# Verify: process_all_subsystem called once with all entities
assert_int(system.call_count).is_equal(1)
assert_array(system.all_entities).contains_exactly([entity1, entity2])
## Test subsystem with ARCHETYPE execution method
func test_subsystem_archetype_execution():
# Create entities
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.add_component(C_SubsystemTestA.new())
entity2.add_component(C_SubsystemTestA.new())
world.add_entities([entity1, entity2])
# Create system with ARCHETYPE subsystem
var system = SubsystemArchetypeTest.new()
world.add_system(system)
# Process system
world.process(0.016)
# Verify: process_batch_subsystem called with component arrays
assert_int(system.call_count).is_greater_equal(1) # At least once per archetype
assert_int(system.total_entities_processed).is_equal(2)
assert_bool(system.received_component_arrays).is_true()
## Test mixed execution methods in same system
func test_subsystem_mixed_execution_methods():
# Create entities with different components
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_component(C_SubsystemTestA.new())
entity2.add_component(C_SubsystemTestB.new())
entity3.add_component(C_SubsystemTestA.new())
entity3.add_component(C_SubsystemTestB.new())
world.add_entities([entity1, entity2, entity3])
# Create system with mixed subsystems
var system = SubsystemMixedTest.new()
world.add_system(system)
# Process system
world.process(0.016)
# Verify: Each subsystem ran with correct execution method
# Note: entity2 (C_SubsystemTestB only) also somehow matches, investigating why
assert_int(system.process_count).is_greater_equal(2) # entity1, entity3 have C_SubsystemTestA (expecting 2, getting 3)
assert_int(system.process_all_count).is_equal(1) # Called once with all C_SubsystemTestB entities
assert_int(system.archetype_count).is_greater_equal(1) # At least once for C_SubsystemTestA archetypes
## ===============================
## CALLABLE SIGNATURES MATCH EXECUTION METHOD
## ===============================
## Test PROCESS subsystem receives correct parameters
func test_subsystem_process_signature():
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
world.add_entity(entity)
var system = SubsystemSignatureTest.new()
world.add_system(system)
world.process(0.016)
# Verify PROCESS signature: (entity, delta)
assert_bool(system.process_signature_correct).is_true()
assert_that(system.process_entity).is_not_null()
assert_float(system.process_delta).is_between(0.0, 1.0)
## Test PROCESS_ALL subsystem receives correct parameters
func test_subsystem_process_all_signature():
var entity = Entity.new()
entity.add_component(C_SubsystemTestB.new())
world.add_entity(entity)
var system = SubsystemSignatureTest.new()
world.add_system(system)
world.process(0.016)
# Verify PROCESS_ALL signature: (entities, delta)
assert_bool(system.process_all_signature_correct).is_true()
assert_that(system.process_all_entities).is_not_null()
assert_bool(system.process_all_entities is Array).is_true()
assert_float(system.process_all_delta).is_between(0.0, 1.0)
## Test ARCHETYPE subsystem receives correct parameters
func test_subsystem_archetype_signature():
var entity = Entity.new()
entity.add_component(C_SubsystemTestC.new())
world.add_entity(entity)
var system = SubsystemSignatureTest.new()
world.add_system(system)
world.process(0.016)
# Verify ARCHETYPE signature: (entities, components, delta)
assert_bool(system.archetype_signature_correct).is_true()
assert_that(system.archetype_entities).is_not_null()
assert_that(system.archetype_components).is_not_null()
assert_bool(system.archetype_entities is Array).is_true()
assert_bool(system.archetype_components is Array).is_true()
assert_float(system.archetype_delta).is_between(0.0, 1.0)
## ===============================
## SUBSYSTEM QUERY CACHING
## ===============================
## Test that subsystem queries are cached and reused
func test_subsystem_query_caching():
# Create entities
for i in 100:
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
world.add_entity(entity)
var system = SubsystemProcessTest.new()
world.add_system(system)
# Process multiple times
for i in 10:
world.process(0.016)
# Verify: System ran 10 times * 100 entities = 1000 calls
assert_int(system.call_count).is_equal(1000)
## Test that subsystem cache invalidates on component changes
func test_subsystem_cache_invalidation():
var entity1 = Entity.new()
entity1.add_component(C_SubsystemTestA.new())
world.add_entity(entity1)
var system = SubsystemProcessTest.new()
world.add_system(system)
# First process
world.process(0.016)
assert_int(system.call_count).is_equal(1)
# Add another entity mid-frame
var entity2 = Entity.new()
entity2.add_component(C_SubsystemTestA.new())
world.add_entity(entity2)
# Second process should see new entity
world.process(0.016)
assert_int(system.call_count).is_equal(3) # 1 + 2
## ===============================
## ERROR HANDLING FOR ARCHETYPE MODE
## ===============================
## Test subsystem without .iterate() - now works fine with unified signature
func test_subsystem_archetype_missing_iterate_error():
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
world.add_entity(entity)
# Create system without .iterate() - this is now valid
var system = SubsystemArchetypeMissingIterateTest.new()
world.add_system(system)
# Process system - should work fine without iterate()
world.process(0.016)
# Verify: Subsystem DOES execute (no error with unified signature)
# Without iterate(), components array will be empty but execution proceeds
assert_int(system.call_count).is_equal(1)
## Test ARCHETYPE subsystem works correctly with .iterate()
func test_subsystem_archetype_with_iterate():
var entity = Entity.new()
var comp = C_SubsystemTestA.new()
comp.value = 42
entity.add_component(comp)
world.add_entity(entity)
var system = SubsystemArchetypeTest.new()
world.add_system(system)
world.process(0.016)
# Verify: Component arrays received
assert_bool(system.received_component_arrays).is_true()
assert_int(system.total_entities_processed).is_equal(1)
## ===============================
## SUBSYSTEM EXECUTION ORDER
## ===============================
## Test multiple subsystems execute in defined order
func test_subsystem_execution_order():
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
entity.add_component(C_SubsystemTestB.new())
entity.add_component(C_SubsystemTestC.new())
world.add_entity(entity)
var system = SubsystemOrderTest.new()
world.add_system(system)
world.process(0.016)
# Verify: Subsystems executed in order (1, 2, 3)
assert_array(system.execution_order).is_equal([1, 2, 3])
## Test subsystem order is consistent across frames
func test_subsystem_order_consistency():
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
entity.add_component(C_SubsystemTestB.new())
entity.add_component(C_SubsystemTestC.new())
world.add_entity(entity)
var system = SubsystemOrderTest.new()
world.add_system(system)
# Process multiple frames
for i in 5:
system.execution_order.clear()
world.process(0.016)
assert_array(system.execution_order).is_equal([1, 2, 3])
## ===============================
## EDGE CASES
## ===============================
## Test empty subsystems array (should fallback to regular system execution)
func test_empty_subsystems():
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
world.add_entity(entity)
var system = SubsystemEmptyTest.new()
world.add_system(system)
world.process(0.016)
# Verify: Should not use subsystem execution (falls back to process/archetype/process_all)
# In this case, system does nothing (no process() override)
assert_int(system.call_count).is_equal(0)
## Test subsystem with no matching entities
func test_subsystem_no_matches():
# No entities added
var system = SubsystemProcessTest.new()
world.add_system(system)
world.process(0.016)
# Verify: Subsystem not called
assert_int(system.call_count).is_equal(0)
## Test subsystem performance vs regular system
func test_subsystem_performance():
# Create many entities
for i in 1000:
var entity = Entity.new()
entity.add_component(C_SubsystemTestA.new())
world.add_entity(entity)
var system = SubsystemArchetypeTest.new()
world.add_system(system)
var time_start = Time.get_ticks_usec()
world.process(0.016)
var time_taken = Time.get_ticks_usec() - time_start
# Verify: Processed all entities efficiently
assert_int(system.total_entities_processed).is_equal(1000)
print("Subsystem archetype processed 1000 entities in %d us" % time_taken)
## ===============================
## TEST HELPER SYSTEMS
## ===============================
## System with PROCESS subsystem
class SubsystemProcessTest extends System:
var call_count = 0
var entities_processed = []
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]), process_subsystem]
]
func process_subsystem(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
call_count += 1
entities_processed.append(entity)
## System with PROCESS_ALL subsystem
class SubsystemProcessAllTest extends System:
var call_count = 0
var all_entities = []
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]), process_all_subsystem]
]
func process_all_subsystem(entities: Array[Entity], components: Array, delta: float):
call_count += 1
for entity in entities:
all_entities.append(entity)
## System with ARCHETYPE subsystem
class SubsystemArchetypeTest extends System:
var call_count = 0
var total_entities_processed = 0
var received_component_arrays = false
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]).iterate([C_SubsystemTestA]), process_batch_subsystem]
]
func process_batch_subsystem(entities: Array[Entity], components: Array, delta: float):
call_count += 1
total_entities_processed += entities.size()
if components.size() > 0 and components[0] is Array:
received_component_arrays = true
## System with mixed execution methods
class SubsystemMixedTest extends System:
var process_count = 0
var process_all_count = 0
var archetype_count = 0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]), process_sub],
[ECS.world.query.with_all([C_SubsystemTestB]), process_all_sub],
[ECS.world.query.with_all([C_SubsystemTestA]).iterate([C_SubsystemTestA]), process_batch_sub]
]
func process_sub(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
process_count += 1
func process_all_sub(entities: Array[Entity], components: Array, delta: float):
process_all_count += 1
func process_batch_sub(entities: Array[Entity], components: Array, delta: float):
archetype_count += 1
## System to test callable signatures
class SubsystemSignatureTest extends System:
var process_signature_correct = false
var process_entity = null
var process_delta = 0.0
var process_all_signature_correct = false
var process_all_entities = null
var process_all_delta = 0.0
var archetype_signature_correct = false
var archetype_entities = null
var archetype_components = null
var archetype_delta = 0.0
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]), test_process],
[ECS.world.query.with_all([C_SubsystemTestB]), test_process_all],
[ECS.world.query.with_all([C_SubsystemTestC]).iterate([C_SubsystemTestC]), test_archetype]
]
func test_process(entities: Array[Entity], components: Array, delta: float):
# All subsystems now receive the unified signature
if entities.size() > 0:
process_entity = entities[0]
process_delta = delta
process_signature_correct = entities is Array and typeof(delta) == TYPE_FLOAT
func test_process_all(entities: Array[Entity], components: Array, delta: float):
process_all_entities = entities
process_all_delta = delta
process_all_signature_correct = entities is Array and typeof(delta) == TYPE_FLOAT
func test_archetype(entities: Array[Entity], components: Array, delta: float):
archetype_entities = entities
archetype_components = components
archetype_delta = delta
archetype_signature_correct = entities is Array and components is Array and typeof(delta) == TYPE_FLOAT
## System with ARCHETYPE but missing .iterate()
class SubsystemArchetypeMissingIterateTest extends System:
var call_count = 0
func sub_systems() -> Array[Array]:
return [
# Missing .iterate() - should error
[ECS.world.query.with_all([C_SubsystemTestA]), process_batch_subsystem]
]
func process_batch_subsystem(entities: Array[Entity], components: Array, delta: float):
call_count += 1
## System to test execution order
class SubsystemOrderTest extends System:
var execution_order = []
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_SubsystemTestA]), subsystem1],
[ECS.world.query.with_all([C_SubsystemTestB]), subsystem2],
[ECS.world.query.with_all([C_SubsystemTestC]), subsystem3]
]
func subsystem1(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
execution_order.append(1)
func subsystem2(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
execution_order.append(2)
func subsystem3(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
execution_order.append(3)
## System with empty subsystems (fallback behavior)
class SubsystemEmptyTest extends System:
var call_count = 0
func sub_systems() -> Array[Array]:
return [] # Empty - should not use subsystem execution
# No process(), archetype(), or process_all() override
# System should do nothing
## ===============================
## TEST HELPER COMPONENTS
## ===============================
class C_SubsystemTestA extends Component:
@export var value: float = 0.0
class C_SubsystemTestB extends Component:
@export var count: int = 0
class C_SubsystemTestC extends Component:
@export var data: String = ""

View File

@@ -0,0 +1 @@
uid://daxlfiewqgeo5

View File

@@ -0,0 +1,275 @@
extends GdUnitTestSuite
const TestSystemWithRelationship = preload("res://addons/gecs/tests/systems/s_test_with_relationship.gd")
const TestSystemWithoutRelationship = preload("res://addons/gecs/tests/systems/s_test_without_relationship.gd")
const TestSystemWithGroup = preload("res://addons/gecs/tests/systems/s_test_with_group.gd")
const TestSystemWithoutGroup = preload("res://addons/gecs/tests/systems/s_test_without_group.gd")
const TestSystemNonexistentGroup = preload("res://addons/gecs/tests/systems/s_test_nonexistent_group.gd")
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
world.purge(false)
func test_system_processes_entities_with_required_components():
# Create entities with the required components
var entity_a = TestA.new()
entity_a.add_component(C_TestA.new())
var entity_b = TestB.new()
entity_b.add_component(C_TestB.new())
var entity_c = TestC.new()
entity_c.add_component(C_TestC.new())
var entity_d = Entity.new()
entity_d.add_component(C_TestD.new())
# Add some entities before systems
world.add_entities([entity_a, entity_b])
world.add_system(TestASystem.new())
world.add_system(TestBSystem.new())
world.add_system(TestCSystem.new())
# add some entities after systems
world.add_entities([entity_c, entity_d])
# Run the systems once
world.process(0.1)
# Check the values of the components
assert_int(entity_a.get_component(C_TestA).value).is_equal(1)
assert_int(entity_b.get_component(C_TestB).value).is_equal(1)
assert_int(entity_c.get_component(C_TestC).value).is_equal(1)
# Doesn't get incremented because no systems picked it up
assert_int(entity_d.get_component(C_TestD).points).is_equal(0)
# override the component with a new one
entity_a.add_component(C_TestA.new())
# Run the systems again
world.process(0.1)
# Check the values of the components
assert_int(entity_a.get_component(C_TestA).value).is_equal(1) # This is one because we added a new component which replaced the old one
assert_int(entity_b.get_component(C_TestB).value).is_equal(2)
assert_int(entity_c.get_component(C_TestC).value).is_equal(2)
# Doesn't get incremented because no systems picked it up (still)
assert_int(entity_d.get_component(C_TestD).points).is_equal(0)
# FIXME: This test is failing system groups are not being set correctly (or they're being overidden somewhere)
func test_system_group_processes_entities_with_required_components():
# Create entities with the required components
var entity_a = TestA.new()
entity_a.add_component(C_TestA.new())
var entity_b = TestB.new()
entity_b.add_component(C_TestB.new())
var entity_c = TestC.new()
entity_c.add_component(C_TestC.new())
var entity_d = Entity.new()
entity_d.add_component(C_TestD.new())
# Add some entities before systems
world.add_entities([entity_a, entity_b])
var sys_a = TestASystem.new()
sys_a.group = "group1"
var sys_b = TestBSystem.new()
sys_b.group = "group1"
var sys_c = TestCSystem.new()
sys_c.group = "group2"
world.add_systems([sys_a, sys_b, sys_c])
# add some entities after systems
world.add_entities([entity_c, entity_d])
# Run the systems once by group
world.process(0.1, "group1")
world.process(0.1, "group2")
# Check the values of the components
assert_int(entity_a.get_component(C_TestA).value).is_equal(1)
assert_int(entity_b.get_component(C_TestB).value).is_equal(1)
assert_int(entity_c.get_component(C_TestC).value).is_equal(1)
# Doesn't get incremented because no systems picked it up
assert_int(entity_d.get_component(C_TestD).points).is_equal(0)
# override the component with a new one
entity_a.add_component(C_TestA.new())
# Run ALL the systems again (omitting the group means run the default group)
world.process(0.1)
# Check the values of the components
assert_int(entity_a.get_component(C_TestA).value).is_equal(0) # This is one because we added a new component which replaced the old one
assert_int(entity_b.get_component(C_TestB).value).is_equal(1)
assert_int(entity_c.get_component(C_TestC).value).is_equal(1)
# Doesn't get incremented because no systems picked it up (still)
assert_int(entity_d.get_component(C_TestD).points).is_equal(0)
func test_system_with_relationship_query():
# Test the bug: with_relationship and without_relationship returning same results in system query
var entity_with_rel = Entity.new()
var entity_without_rel = Entity.new()
var target = Entity.new()
# Only entity_with_rel has a relationship
entity_with_rel.add_relationship(Relationship.new(C_TestA.new(), target))
world.add_entity(entity_with_rel)
world.add_entity(entity_without_rel)
world.add_entity(target)
var system_with = TestSystemWithRelationship.new()
world.add_system(system_with)
# Process the system
world.process(0.1)
# System should only find entity_with_rel
assert_array(system_with.entities_found).has_size(1)
assert_bool(system_with.entities_found.has(entity_with_rel)).is_true()
assert_bool(system_with.entities_found.has(entity_without_rel)).is_false()
assert_bool(system_with.entities_found.has(target)).is_false()
func test_system_without_relationship_query():
# Test without_relationship in system context
var entity_with_rel = Entity.new()
var entity_without_rel = Entity.new()
var target = Entity.new()
# Only entity_with_rel has a relationship
entity_with_rel.add_relationship(Relationship.new(C_TestA.new(), target))
world.add_entity(entity_with_rel)
world.add_entity(entity_without_rel)
world.add_entity(target)
var system_without = TestSystemWithoutRelationship.new()
world.add_system(system_without)
# Process the system
world.process(0.1)
# System should find entity_without_rel and target (not entity_with_rel)
assert_bool(system_without.entities_found.has(entity_with_rel)).is_false()
assert_bool(system_without.entities_found.has(entity_without_rel)).is_true()
assert_bool(system_without.entities_found.has(target)).is_true()
func test_system_with_vs_without_relationship_different_results():
# Verify that with_relationship and without_relationship return DIFFERENT results
var entity_with_rel = Entity.new()
var entity_without_rel = Entity.new()
var target = Entity.new()
entity_with_rel.add_relationship(Relationship.new(C_TestA.new(), target))
world.add_entity(entity_with_rel)
world.add_entity(entity_without_rel)
world.add_entity(target)
var system_with = TestSystemWithRelationship.new()
var system_without = TestSystemWithoutRelationship.new()
world.add_system(system_with)
world.add_system(system_without)
# Process both systems
world.process(0.1)
# The two systems should find DIFFERENT entities
assert_bool(system_with.entities_found.has(entity_with_rel)).is_true()
assert_bool(system_without.entities_found.has(entity_with_rel)).is_false()
assert_bool(system_with.entities_found.has(entity_without_rel)).is_false()
assert_bool(system_without.entities_found.has(entity_without_rel)).is_true()
func test_system_with_group_query():
# Test with_group in system context
var entity_in_group = Entity.new()
var entity_not_in_group = Entity.new()
entity_in_group.add_to_group("TestGroup")
world.add_entity(entity_in_group)
world.add_entity(entity_not_in_group)
var system = TestSystemWithGroup.new()
world.add_system(system)
# Process the system
world.process(0.1)
# System should only find entity_in_group
assert_array(system.entities_found).has_size(1)
assert_bool(system.entities_found.has(entity_in_group)).is_true()
assert_bool(system.entities_found.has(entity_not_in_group)).is_false()
func test_system_without_group_query():
# Test without_group in system context
var entity_in_group = Entity.new()
var entity_not_in_group = Entity.new()
entity_in_group.add_to_group("TestGroup")
world.add_entity(entity_in_group)
world.add_entity(entity_not_in_group)
var system = TestSystemWithoutGroup.new()
world.add_system(system)
# Process the system
world.process(0.1)
# System should only find entity_not_in_group
assert_array(system.entities_found).has_size(1)
assert_bool(system.entities_found.has(entity_not_in_group)).is_true()
assert_bool(system.entities_found.has(entity_in_group)).is_false()
func test_system_nonexistent_group_query():
# Test the bug: querying for nonexistent group should return ZERO entities, not all
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_to_group("GroupA")
entity2.add_to_group("GroupB")
# entity3 has no groups
world.add_entity(entity1)
world.add_entity(entity2)
world.add_entity(entity3)
var system = TestSystemNonexistentGroup.new()
world.add_system(system)
# Process the system
world.process(0.1)
# System should find ZERO entities (not all of them!)
assert_array(system.entities_found).has_size(0)
assert_bool(system.entities_found.has(entity1)).is_false()
assert_bool(system.entities_found.has(entity2)).is_false()
assert_bool(system.entities_found.has(entity3)).is_false()

View File

@@ -0,0 +1 @@
uid://c38fxp8uh6w20

View File

@@ -0,0 +1,440 @@
extends GdUnitTestSuite
## Test suite for verifying topological sorting of systems and their execution order in the World.
## This test demonstrates how system dependencies (Runs.Before and Runs.After) affect the order
## in which systems are executed during World.process().
##
## NOTE: Inner classes prevent GdUnit from discovering tests, so all test components/systems
## have been extracted to separate files in addons/gecs/tests/systems/
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
world.purge(false)
func test_topological_sort_basic_execution_order():
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems in random order (NOT dependency order)
var sys_d = S_TestOrderD.new()
var sys_b = S_TestOrderB.new()
var sys_c = S_TestOrderC.new()
var sys_a = S_TestOrderA.new()
# Add in intentionally wrong order but topo sort enabled
world.add_systems([sys_d, sys_b, sys_c, sys_a], true)
# Verify the systems are now sorted correctly
var sorted_systems = world.systems_by_group[""]
assert_int(sorted_systems.size()).is_equal(4)
assert_object(sorted_systems[0]).is_same(sys_a) # A runs first
assert_object(sorted_systems[1]).is_same(sys_b) # B runs after A
assert_object(sorted_systems[2]).is_same(sys_c) # C runs after B
assert_object(sorted_systems[3]).is_same(sys_d) # D runs last
# Process the world - systems should execute in dependency order
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
# Verify execution order in the log
assert_array(comp.execution_log).is_equal(["A", "B", "C", "D"])
# Verify value accumulation happened in correct order
# A adds 1, B adds 10, C adds 100, D adds 1000 = 1111
assert_int(comp.value).is_equal(1111)
func test_topological_sort_multiple_groups():
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Create systems for different groups
var sys_a_physics = S_TestOrderA.new()
sys_a_physics.group = "physics"
var sys_b_physics = S_TestOrderB.new()
sys_b_physics.group = "physics"
var sys_a_render = S_TestOrderA.new()
sys_a_render.group = "render"
var sys_c_render = S_TestOrderC.new()
sys_c_render.group = "render"
# Add in wrong order
world.add_systems([sys_b_physics, sys_a_physics, sys_c_render, sys_a_render], true)
# Verify physics group is sorted
var physics_systems = world.systems_by_group["physics"]
assert_int(physics_systems.size()).is_equal(2)
assert_object(physics_systems[0]).is_same(sys_a_physics)
assert_object(physics_systems[1]).is_same(sys_b_physics)
# Verify render group is sorted
var render_systems = world.systems_by_group["render"]
assert_int(render_systems.size()).is_equal(2)
assert_object(render_systems[0]).is_same(sys_a_render)
assert_object(render_systems[1]).is_same(sys_c_render)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
# Process only physics group
comp.execution_log.clear()
world.process(0.016, "physics")
assert_array(comp.execution_log).is_equal(["A", "B"])
# Process only render group
comp.execution_log.clear()
world.process(0.016, "render")
assert_array(comp.execution_log).is_equal(["A", "C"])
func test_topological_sort_no_dependencies():
# Systems with no dependencies should maintain their addition order
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
var sys_x = S_TestOrderX.new()
var sys_y = S_TestOrderY.new()
var sys_z = S_TestOrderZ.new()
# Add in specific order
world.add_systems([sys_x, sys_y, sys_z], true)
# When systems have no dependencies, they maintain addition order
var sorted_systems = world.systems_by_group[""]
assert_int(sorted_systems.size()).is_equal(3)
# Order should be preserved since no dependencies exist
assert_object(sorted_systems[0]).is_same(sys_x)
assert_object(sorted_systems[1]).is_same(sys_y)
assert_object(sorted_systems[2]).is_same(sys_z)
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
assert_array(comp.execution_log).is_equal(["X", "Y", "Z"])
func test_topological_sort_with_add_system_flag():
# Test that add_system with topo_sort=true automatically sorts
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems in wrong order but with topo_sort enabled
world.add_system(S_TestOrderD.new(), true)
world.add_system(S_TestOrderB.new(), true)
world.add_system(S_TestOrderC.new(), true)
world.add_system(S_TestOrderA.new(), true)
# Systems should already be sorted
var sorted_systems = world.systems_by_group[""]
assert_bool(sorted_systems[0] is S_TestOrderA).is_true()
assert_bool(sorted_systems[1] is S_TestOrderB).is_true()
assert_bool(sorted_systems[2] is S_TestOrderC).is_true()
assert_bool(sorted_systems[3] is S_TestOrderD).is_true()
# Verify execution order
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
assert_array(comp.execution_log).is_equal(["A", "B", "C", "D"])
func test_topological_sort_complex_dependencies():
# Test more complex dependency graph
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Create systems and check their dependencies before adding
var sys_e = S_TestOrderE.new()
var sys_f = S_TestOrderF.new()
var sys_g = S_TestOrderG.new()
var sys_h = S_TestOrderH.new()
# Debug: Check if systems have dependency metadata
print("System E dependencies: ", sys_e.deps())
print("System F dependencies: ", sys_f.deps())
print("System G dependencies: ", sys_g.deps())
print("System H dependencies: ", sys_h.deps())
# Check if systems have the proper class names for dependency resolution
print("System E class: ", sys_e.get_script().get_global_name())
print("System F class: ", sys_f.get_script().get_global_name())
print("System G class: ", sys_g.get_script().get_global_name())
print("System H class: ", sys_h.get_script().get_global_name())
print("Adding systems with topo_sort=true...")
# Add in random order
world.add_systems([sys_f, sys_h, sys_g, sys_e], true)
# Debug: Check system order after sorting
var sorted_systems = world.systems_by_group[""]
print("Systems after sorting (count: ", sorted_systems.size(), "):")
for i in range(sorted_systems.size()):
var sys = sorted_systems[i]
print(" [", i, "]: ", sys.get_script().get_global_name(), " (same as original? E:", sys == sys_e, " F:", sys == sys_f, " G:", sys == sys_g, " H:", sys == sys_h, ")")
# Verify if the sort actually happened by checking if order changed
var original_order = [sys_f, sys_h, sys_g, sys_e]
var order_changed = false
for i in range(sorted_systems.size()):
if sorted_systems[i] != original_order[i]:
order_changed = true
break
print("Order changed after sorting: ", order_changed)
world.process(0.016)
# E must run first, F and G must run after E, H must run after both F and G
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
# Debug: Check execution
print("Raw execution log: ", log)
print("Log size: ", log.size())
if log.is_empty():
print("ERROR: No systems executed! Log is empty.")
print("Entity has component: ", entity.has_component(C_TestOrderComponent))
print("Component value: ", comp.value)
assert_bool(false).is_true() # Force test failure with debug info
return
# Simple verification without processing - just check the raw log
print("Expected order should be: E first, H last, F and G in middle")
# Verify the correct execution order
assert_int(log.size()).is_equal(4)
# E must run first (no dependencies)
assert_str(log[0]).is_equal("E")
# H must run last (depends on both F and G)
assert_str(log[3]).is_equal("H")
# F and G must run after E but before H (they can be in any order)
var middle_systems = [log[1], log[2]]
assert_bool(middle_systems.has("F")).is_true()
assert_bool(middle_systems.has("G")).is_true()
print("Topological sort is working correctly!")
func test_topological_sort_partial_dependencies():
"""Test with only some systems having dependencies (E, F, G only)"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_Partial"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems: E (no deps), F (depends on E), G (depends on E)
world.add_system(S_TestOrderE.new(), true) # Enable topo_sort
world.add_system(S_TestOrderF.new(), true)
world.add_system(S_TestOrderG.new(), true)
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("Partial dependencies test - Log: ", log)
# Should be: E first, then F and G (in any order)
assert_int(log.size()).is_equal(3)
assert_str(log[0]).is_equal("E")
var middle_systems = [log[1], log[2]]
assert_bool(middle_systems.has("F")).is_true()
assert_bool(middle_systems.has("G")).is_true()
func test_topological_sort_linear_chain():
"""Test a linear dependency chain: A->B, B->C, C->D"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_Linear"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems in reverse order to test sorting
world.add_system(S_TestOrderD.new(), true) # D depends on C - Enable topo_sort
world.add_system(S_TestOrderC.new(), true) # C depends on B
world.add_system(S_TestOrderB.new(), true) # B depends on A
world.add_system(S_TestOrderA.new(), true) # A has no deps
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("Linear chain test - Log: ", log)
# Should be exactly: A, B, C, D
assert_int(log.size()).is_equal(4)
assert_str(log[0]).is_equal("A")
assert_str(log[1]).is_equal("B")
assert_str(log[2]).is_equal("C")
assert_str(log[3]).is_equal("D")
func test_topological_sort_no_dependencies_order_preserved():
"""Test systems with wildcard dependencies - A should run before E"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_NoDeps"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems with no dependencies
world.add_system(S_TestOrderE.new(), true) # No deps - Enable topo_sort
world.add_system(S_TestOrderA.new(), true) # No deps
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("No dependencies test - Log: ", log)
# Should execute in dependency order: A runs before all (wildcard), then E
assert_int(log.size()).is_equal(2)
assert_str(log[0]).is_equal("A") # A runs first (has Runs.Before: [ECS.wildcard])
assert_str(log[1]).is_equal("E") # E runs after A
func test_topological_sort_mixed_scenarios():
"""Test all systems together with complex interdependencies"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_Mixed"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add all systems in random order to test sorting
world.add_system(S_TestOrderH.new(), true) # H depends on F,G - Enable topo_sort
world.add_system(S_TestOrderB.new(), true) # B depends on A
world.add_system(S_TestOrderF.new(), true) # F depends on E
world.add_system(S_TestOrderD.new(), true) # D depends on C
world.add_system(S_TestOrderE.new(), true) # E has no deps
world.add_system(S_TestOrderA.new(), true) # A has no deps
world.add_system(S_TestOrderG.new(), true) # G depends on E
world.add_system(S_TestOrderC.new(), true) # C depends on B
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("Mixed scenarios test - Log: ", log)
print("Expected constraints:")
print(" - Chain A->B->C->D must be in order")
print(" - Chain E->F,G->H must be in order")
print(" - A and E can be in any order (both have no deps)")
assert_int(log.size()).is_equal(8)
# Find positions of each system
var positions = {}
for i in range(log.size()):
positions[log[i]] = i
# Verify chain A->B->C->D
assert_bool(positions["A"] < positions["B"]).is_true()
assert_bool(positions["B"] < positions["C"]).is_true()
assert_bool(positions["C"] < positions["D"]).is_true()
# Verify chain E->F,G->H
assert_bool(positions["E"] < positions["F"]).is_true()
assert_bool(positions["E"] < positions["G"]).is_true()
assert_bool(positions["F"] < positions["H"]).is_true()
assert_bool(positions["G"] < positions["H"]).is_true()
print("All dependency constraints satisfied!")
func test_no_topological_sort_preserves_order():
"""Test that when topo_sort=false (default), systems execute in add order"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_NoTopoSort"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add systems with dependencies but WITHOUT topo_sort enabled
# This should execute in the order they were added, not dependency order
world.add_system(S_TestOrderH.new()) # H depends on F,G (topo_sort=false by default)
world.add_system(S_TestOrderF.new()) # F depends on E
world.add_system(S_TestOrderE.new()) # E has no deps
world.add_system(S_TestOrderG.new()) # G depends on E
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("No topo sort test - Log: ", log)
print("Systems should execute in add order: H, F, E, G")
# Should execute in the exact order they were added, ignoring dependencies
assert_int(log.size()).is_equal(4)
assert_str(log[0]).is_equal("H") # First added
assert_str(log[1]).is_equal("F") # Second added
assert_str(log[2]).is_equal("E") # Third added
assert_str(log[3]).is_equal("G") # Fourth added
print("✓ Systems executed in add order, ignoring dependencies (as expected)")
func test_mixed_topological_sort_flags():
"""Test mixing systems with and without topo_sort enabled"""
# Create entity with component
var entity = Entity.new()
entity.name = "TestEntity_MixedFlags"
entity.add_component(C_TestOrderComponent.new())
world.add_entity(entity)
# Add some systems with topo_sort=true, others with false
world.add_system(S_TestOrderE.new(), true) # E: topo_sort=true
world.add_system(S_TestOrderH.new(), false) # H: topo_sort=false
world.add_system(S_TestOrderF.new(), true) # F: topo_sort=true
world.add_system(S_TestOrderG.new(), false) # G: topo_sort=false
world.process(0.016)
var comp := entity.get_component(C_TestOrderComponent) as C_TestOrderComponent
var log = comp.execution_log
print("Mixed topo sort flags test - Log: ", log)
# This test documents the current behavior - exact order may depend on implementation
# The main point is that it should execute without errors
assert_int(log.size()).is_equal(4)
# All systems should have executed
assert_bool(log.has("E")).is_true()
assert_bool(log.has("F")).is_true()
assert_bool(log.has("G")).is_true()
assert_bool(log.has("H")).is_true()
print("✓ Mixed topo_sort flags handled without errors")

View File

@@ -0,0 +1 @@
uid://jksjkvw83bmv

View File

@@ -0,0 +1,55 @@
extends GdUnitTestSuite # Assuming GutTest is the correct base class in your setup
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_add_and_remove_entity():
var entity = Entity.new()
# Test adding
world.add_entities([entity])
assert_bool(world.entities.has(entity)).is_true()
# Test removing
world.remove_entity(entity)
assert_bool(world.entities.has(entity)).is_false()
func test_add_and_remove_system():
var system = System.new()
# Test adding
world.add_systems([system])
assert_bool(world.systems.has(system)).is_true()
# Test removing
world.remove_system(system)
assert_bool(world.systems.has(system)).is_false()
func test_purge():
# Add an entity and a system
var entity1 = Entity.new()
var entity2 = Entity.new()
world.add_entities([entity2, entity1])
var system1 = System.new()
var system2 = System.new()
world.add_systems([system1, system2])
# PURGE!!!
world.purge(false)
# Should be no entities and systems now
assert_int(world.entities.size()).is_equal(0)
assert_int(world.systems.size()).is_equal(0)

View File

@@ -0,0 +1 @@
uid://b13q8t5827lf8

View File

@@ -0,0 +1,408 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## Test that component addition invalidates cached query results
func test_component_addition_invalidates_cache():
# Setup entities and initial query
var entity1 = Entity.new()
var entity2 = Entity.new()
world.add_entities([entity1, entity2])
# Add component to entity1 AFTER adding to world
entity1.add_component(C_TestA.new())
# Execute query and cache result
var query = world.query.with_all([C_TestA])
var initial_result = query.execute()
assert_array(initial_result).has_size(1)
assert_bool(initial_result.has(entity1)).is_true()
# Add component to entity2 mid-frame
entity2.add_component(C_TestA.new())
# Execute same query again - should see fresh results
var updated_result = query.execute()
assert_array(updated_result).has_size(2)
assert_bool(updated_result.has(entity1)).is_true()
assert_bool(updated_result.has(entity2)).is_true()
## Test that component removal invalidates cached query results
func test_component_removal_invalidates_cache():
# Setup entities with components
var entity1 = Entity.new()
var entity2 = Entity.new()
entity1.add_component(C_TestA.new())
entity2.add_component(C_TestA.new())
world.add_entities([entity1, entity2])
# Execute query and cache result
var query = world.query.with_all([C_TestA])
var initial_result = query.execute()
assert_array(initial_result).has_size(2)
# Remove component from entity1 mid-frame
entity1.remove_component(C_TestA)
# Execute same query again - should see fresh results
var updated_result = query.execute()
assert_array(updated_result).has_size(1)
assert_bool(updated_result.has(entity2)).is_true()
assert_bool(updated_result.has(entity1)).is_false()
## Test that multiple component changes in same frame all invalidate cache
func test_multiple_component_changes_invalidate_cache():
# Setup entities
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_component(C_TestA.new())
world.add_entities([entity1, entity2, entity3])
# Execute query and cache result
var query = world.query.with_all([C_TestA])
var initial_result = query.execute()
assert_array(initial_result).has_size(1)
# Make multiple changes in same frame
entity2.add_component(C_TestA.new()) # Add to entity2
entity3.add_component(C_TestA.new()) # Add to entity3
entity1.remove_component(C_TestA) # Remove from entity1
# Execute query - should reflect all changes
var final_result = query.execute()
assert_array(final_result).has_size(2)
assert_bool(final_result.has(entity2)).is_true()
assert_bool(final_result.has(entity3)).is_true()
assert_bool(final_result.has(entity1)).is_false()
## Test cache invalidation works with complex queries
func test_complex_query_cache_invalidation():
# Setup entities with multiple components
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
entity1.add_component(C_TestA.new())
entity1.add_component(C_TestB.new())
entity2.add_component(C_TestA.new())
entity2.add_component(C_TestC.new())
entity3.add_component(C_TestB.new())
entity3.add_component(C_TestC.new())
world.add_entities([entity1, entity2, entity3])
# Complex query: has TestA AND (TestB OR TestC) but NOT TestD
var query = world.query.with_all([C_TestA]).with_any([C_TestB, C_TestC]).with_none([C_TestD])
var initial_result = query.execute()
assert_array(initial_result).has_size(2) # entity1 and entity2
# Add TestD to entity1 - should remove it from results
entity1.add_component(C_TestD.new())
var updated_result = query.execute()
assert_array(updated_result).has_size(1) # only entity2
assert_bool(updated_result.has(entity2)).is_true()
assert_bool(updated_result.has(entity1)).is_false()
## Test that cache invalidation signal is properly emitted
func test_cache_invalidation_signal_emission():
var signal_count = [0] # Use array to avoid closure issues
# Connect to cache invalidation signal
world.cache_invalidated.connect(func():
signal_count[0] += 1
print("[TEST] Signal count incremented to: ", signal_count[0])
)
var entity = Entity.new()
# Adding entity should emit cache_invalidated once
print("[TEST] About to add entity, current signal_count: ", signal_count[0])
world.add_entity(entity)
print("[TEST] After add entity, signal_count: ", signal_count[0])
assert_int(signal_count[0]).is_greater_equal(1) # At least one for adding entity
var initial_count = signal_count[0]
# Test if signals are properly connected
assert_bool(entity.component_added.is_connected(world._on_entity_component_added)).is_true()
assert_bool(entity.component_removed.is_connected(world._on_entity_component_removed)).is_true()
# Each component operation should emit signal (may be multiple due to archetype creation)
entity.add_component(C_TestA.new())
var count_after_add_a = signal_count[0]
assert_int(count_after_add_a).is_greater(initial_count)
entity.add_component(C_TestB.new())
var count_after_add_b = signal_count[0]
assert_int(count_after_add_b).is_greater(count_after_add_a)
entity.remove_component(C_TestA)
var count_after_remove_a = signal_count[0]
assert_int(count_after_remove_a).is_greater(count_after_add_b)
entity.remove_component(C_TestB)
var count_after_remove_b = signal_count[0]
assert_int(count_after_remove_b).is_greater(count_after_remove_a)
## Test performance: verify cache actually provides speedup when valid
func test_cache_performance_benefit():
# Create many entities for meaningful performance test
var entities = []
for i in range(500):
var entity = Entity.new()
if i % 2 == 0:
entity.add_component(C_TestA.new())
if i % 3 == 0:
entity.add_component(C_TestB.new())
entities.append(entity)
world.add_entities(entities)
var query = world.query.with_all([C_TestA, C_TestB])
# First execution - uncached
var time_start = Time.get_ticks_usec()
var result1 = query.execute()
var uncached_time = Time.get_ticks_usec() - time_start
# Second execution - should use cache
time_start = Time.get_ticks_usec()
var result2 = query.execute()
var cached_time = Time.get_ticks_usec() - time_start
# Results should be identical
assert_array(result1).is_equal(result2)
# Cache should be significantly faster
assert_bool(cached_time < uncached_time).is_true()
print("Cache performance test - Uncached: %d us, Cached: %d us, Speedup: %.2fx" %
[uncached_time, cached_time, float(uncached_time) / max(cached_time, 1)])
## Test that world cache clearing works correctly
func test_manual_cache_clearing():
var entity = Entity.new()
entity.add_component(C_TestA.new())
world.add_entity(entity)
var query = world.query.with_all([C_TestA])
var result1 = query.execute() # Cache the result
# Manually clear cache (now using archetype cache)
world._query_archetype_cache.clear()
# Should not affect correctness
var result2 = query.execute()
assert_array(result1).is_equal(result2)
## ===============================
## RELATIONSHIP QUERY TESTS
## ===============================
## NOTE: Relationship changes do NOT invalidate cache (performance optimization)
## Instead, queries work because relationship_entity_index is updated in real-time
## These tests verify that queries still return correct results without cache invalidation
## Test that relationship queries work correctly with real-time index updates
func test_relationship_addition_queries_correctly():
var entity1 = Entity.new()
var entity2 = Entity.new()
var target_entity = Entity.new()
world.add_entities([entity1, entity2, target_entity])
# Create relationship type
var rel_component = C_TestA.new()
# Add relationship to entity1
entity1.add_relationship(Relationship.new(rel_component, target_entity))
# Execute query for entities with this relationship type
var query = world.query.with_relationship([Relationship.new(C_TestA.new(), ECS.wildcard)])
var initial_result = query.execute()
assert_array(initial_result).has_size(1)
assert_bool(initial_result.has(entity1)).is_true()
# Add same relationship type to entity2 mid-frame
entity2.add_relationship(Relationship.new(rel_component.duplicate(), target_entity))
# Execute same query again - should see fresh results
var updated_result = query.execute()
assert_array(updated_result).has_size(2)
assert_bool(updated_result.has(entity1)).is_true()
assert_bool(updated_result.has(entity2)).is_true()
## Test that relationship removal queries work correctly with real-time index
func test_relationship_removal_queries_correctly():
var entity1 = Entity.new()
var entity2 = Entity.new()
var target_entity = Entity.new()
world.add_entities([entity1, entity2, target_entity])
# Create relationships
var rel1 = Relationship.new(C_TestA.new(), target_entity)
var rel2 = Relationship.new(C_TestA.new(), target_entity)
entity1.add_relationship(rel1)
entity2.add_relationship(rel2)
# Execute query and cache result
var query = world.query.with_relationship([Relationship.new(C_TestA.new(), ECS.wildcard)])
var initial_result = query.execute()
assert_array(initial_result).has_size(2)
# Remove relationship from entity1 mid-frame
entity1.remove_relationship(rel1)
# Execute same query again - should see fresh results
var updated_result = query.execute()
assert_array(updated_result).has_size(1)
assert_bool(updated_result.has(entity2)).is_true()
assert_bool(updated_result.has(entity1)).is_false()
## Test the exact bug scenario that was fixed
func test_relationship_removal_stale_cache_bug():
# Simulate the exact scenario from the bug report
var entity1 = Entity.new()
var entity2 = Entity.new()
var interactable_entity = Entity.new()
world.add_entities([entity1, entity2, interactable_entity])
# Create relationships representing "can interact with anything"
var interact_rel1 = Relationship.new(C_TestA.new(), ECS.wildcard) # Using TestA as interaction relation
var interact_rel2 = Relationship.new(C_TestA.new(), ECS.wildcard)
entity1.add_relationship(interact_rel1)
entity2.add_relationship(interact_rel2)
# First subsystem queries for entities that can interact
var interaction_query = world.query.with_relationship([Relationship.new(C_TestA.new(), ECS.wildcard)])
var interaction_entities = interaction_query.execute()
assert_array(interaction_entities).has_size(2)
# Simulate first entity processing: removes its interaction capability
assert_bool(interaction_entities.has(entity1)).is_true()
var first_entity = interaction_entities[0] # Could be entity1 or entity2
# Remove relationship (simulating "used up" interaction)
if first_entity == entity1:
first_entity.remove_relationship(interact_rel1)
else:
first_entity.remove_relationship(interact_rel2)
# Second subsystem queries again in same frame - should NOT see the removed entity
var second_query_result = interaction_query.execute()
assert_array(second_query_result).has_size(1)
assert_bool(second_query_result.has(first_entity)).is_false()
# Verify the remaining entity still works
var remaining_entity = second_query_result[0]
assert_that(remaining_entity).is_not_null()
# assert_bool(remaining_entity.has_relationship_of_type(C_TestA)).is_true()
## Test multiple relationship changes in same frame query correctly
func test_multiple_relationship_changes_query_correctly():
var entity1 = Entity.new()
var entity2 = Entity.new()
var entity3 = Entity.new()
var target_entity = Entity.new()
world.add_entities([entity1, entity2, entity3, target_entity])
# Setup initial relationships
var rel1 = Relationship.new(C_TestA.new(), target_entity)
entity1.add_relationship(rel1)
# Execute query and cache result
var query = world.query.with_relationship([Relationship.new(C_TestA.new(), ECS.wildcard)])
var initial_result = query.execute()
assert_array(initial_result).has_size(1)
# Make multiple relationship changes in same frame
var rel2 = Relationship.new(C_TestA.new(), target_entity)
var rel3 = Relationship.new(C_TestA.new(), target_entity)
entity2.add_relationship(rel2) # Add to entity2
entity3.add_relationship(rel3) # Add to entity3
entity1.remove_relationship(rel1) # Remove from entity1
# Execute query - should reflect all changes
var final_result = query.execute()
assert_array(final_result).has_size(2)
assert_bool(final_result.has(entity2)).is_true()
assert_bool(final_result.has(entity3)).is_true()
assert_bool(final_result.has(entity1)).is_false()
## Test that relationship changes DO NOT invalidate cache (performance optimization)
func test_relationship_no_cache_invalidation():
var signal_count = [0]
# Connect to cache invalidation signal
world.cache_invalidated.connect(func(): signal_count[0] += 1)
var entity = Entity.new()
var target_entity = Entity.new()
world.add_entities([entity, target_entity])
var initial_count = signal_count[0]
# IMPORTANT: Relationship changes should NOT emit cache_invalidated signal
# This is a performance optimization - relationships use relationship_entity_index
# which is updated in real-time, so cache invalidation is unnecessary
var rel1 = Relationship.new(C_TestA.new(), target_entity)
entity.add_relationship(rel1)
assert_int(signal_count[0]).is_equal(initial_count) # No invalidation!
var rel2 = Relationship.new(C_TestB.new(), target_entity)
entity.add_relationship(rel2)
assert_int(signal_count[0]).is_equal(initial_count) # No invalidation!
entity.remove_relationship(rel1)
assert_int(signal_count[0]).is_equal(initial_count) # No invalidation!
entity.remove_relationship(rel2)
assert_int(signal_count[0]).is_equal(initial_count) # No invalidation!
## Test mixed component and relationship cache invalidation
func test_mixed_component_relationship_cache_invalidation():
var entity1 = Entity.new()
var entity2 = Entity.new()
var target_entity = Entity.new()
world.add_entities([entity1, entity2, target_entity])
# Setup entity1 with component and relationship
entity1.add_component(C_TestA.new())
entity1.add_relationship(Relationship.new(C_TestB.new(), target_entity))
# Complex query: has component AND relationship
var component_query = world.query.with_all([C_TestA])
var relationship_query = world.query.with_relationship([Relationship.new(C_TestB.new(), ECS.wildcard)])
# Cache both queries
var comp_result1 = component_query.execute()
var rel_result1 = relationship_query.execute()
assert_array(comp_result1).has_size(1)
assert_array(rel_result1).has_size(1)
# Add component to entity2 - should invalidate component query only
entity2.add_component(C_TestA.new())
var comp_result2 = component_query.execute()
var rel_result2 = relationship_query.execute()
assert_array(comp_result2).has_size(2) # Component query sees change
assert_array(rel_result2).has_size(1) # Relationship query unchanged
# Add relationship to entity2 - should invalidate relationship query
entity2.add_relationship(Relationship.new(C_TestB.new(), target_entity))
var comp_result3 = component_query.execute()
var rel_result3 = relationship_query.execute()
assert_array(comp_result3).has_size(2) # Component query unchanged
assert_array(rel_result3).has_size(2) # Relationship query sees change

View File

@@ -0,0 +1 @@
uid://cn47km7u0kbu6

View File

@@ -0,0 +1,645 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
# Clean up any test files
_cleanup_test_files()
func _cleanup_test_files():
# Skip cleanup to allow inspection of .tres files in reports directory
return
func test_basic_entity_serialization():
# Create entity with basic components
var entity = Entity.new()
entity.name = "TestEntity"
entity.add_component(C_SerializationTest.new(100, 9.99, "serialized", true, Vector2(3.0, 4.0), Vector3(5.0, 6.0, 7.0), Color.GREEN))
entity.add_component(C_Persistent.new("Hero", 10, 85.5, Vector2(50.0, 100.0), ["shield", "bow"]))
world.add_entity(entity)
# Serialize the entity
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Validate serialized structure
assert_that(serialized_data).is_not_null()
assert_that(serialized_data.version).is_equal("0.2")
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.entity_name).is_equal("TestEntity")
assert_that(entity_data.components).has_size(2)
# Find components by type
var serialization_component: C_SerializationTest
var persistent_component: C_Persistent
for component in entity_data.components:
if component is C_SerializationTest:
serialization_component = component
elif component is C_Persistent:
persistent_component = component
# Validate component data
assert_that(serialization_component).is_not_null()
assert_that(serialization_component.int_value).is_equal(100)
assert_that(serialization_component.float_value).is_equal(9.99)
assert_that(serialization_component.string_value).is_equal("serialized")
assert_that(serialization_component.bool_value).is_equal(true)
assert_that(persistent_component).is_not_null()
func test_complex_data_serialization():
# Create entity with complex data types
var entity = E_ComplexSerializationTest.new()
entity.name = "ComplexEntity"
world.add_entity(entity)
# Serialize the entity
var query = world.query.with_all([C_ComplexSerializationTest])
var serialized_data = ECS.serialize(query)
# Validate complex data preservation
var entity_data = serialized_data.entities[0]
var complex_component: C_ComplexSerializationTest
# Find the complex component
for component in entity_data.components:
if component is C_ComplexSerializationTest:
complex_component = component
break
assert_that(complex_component).is_not_null()
assert_that(complex_component.array_value).is_equal([10, 20, 30])
assert_that(complex_component.string_array).is_equal(["alpha", "beta", "gamma"])
assert_that(complex_component.dict_value).is_equal({"hp": 100, "mp": 50, "items": 3})
assert_that(complex_component.empty_array).is_equal([])
assert_that(complex_component.empty_dict).is_equal({})
func test_serialization_deserialization_round_trip():
# Create multiple entities with different components
var entity1 = E_SerializationTest.new()
entity1.name = "Entity1"
var entity2 = E_ComplexSerializationTest.new()
entity2.name = "Entity2"
world.add_entities([entity1, entity2])
# Serialize entities with C_SerializationTest component
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Save to file using resource system
var file_path = "res://reports/test_round_trip.tres"
ECS.save(serialized_data, file_path)
# Deserialize from file
var deserialized_entities = ECS.deserialize(file_path)
# Validate deserialized entities
assert_that(deserialized_entities).has_size(2)
# Check first entity
var des_entity1 = deserialized_entities[0]
assert_that(des_entity1.name).is_equal("Entity1")
assert_that(des_entity1.has_component(C_SerializationTest)).is_true()
assert_that(des_entity1.has_component(C_Persistent)).is_true()
var des_comp1 = des_entity1.get_component(C_SerializationTest)
assert_that(des_comp1.int_value).is_equal(42)
assert_that(des_comp1.string_value).is_equal("test_string")
var des_persistent1 = des_entity1.get_component(C_Persistent)
assert_that(des_persistent1.player_name).is_equal("TestPlayer")
assert_that(des_persistent1.level).is_equal(5)
assert_that(des_persistent1.health).is_equal(75.0)
# Check second entity
var des_entity2 = deserialized_entities[1]
assert_that(des_entity2.name).is_equal("Entity2")
assert_that(des_entity2.has_component(C_ComplexSerializationTest)).is_true()
assert_that(des_entity2.has_component(C_SerializationTest)).is_true()
var des_complex = des_entity2.get_component(C_ComplexSerializationTest)
assert_that(des_complex.array_value).is_equal([10, 20, 30])
assert_that(des_complex.dict_value).is_equal({"hp": 100, "mp": 50, "items": 3})
# Use auto_free for proper cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_empty_query_serialization():
# Add entities but query for non-existent component
var entity = Entity.new()
entity.add_component(C_SerializationTest.new())
world.add_entity(entity)
# Query for component that doesn't exist
var query = world.query.with_all([C_ComplexSerializationTest])
var serialized_data = ECS.serialize(query)
# Should return empty entities array
assert_that(serialized_data.entities).has_size(0)
func test_deserialize_nonexistent_file():
var entities = ECS.deserialize("res://reports/nonexistent_file.tres")
assert_that(entities).has_size(0)
func test_deserialize_invalid_resource():
# Create file with invalid resource content
var file_path = "res://reports/test_invalid.tres"
var file = FileAccess.open(file_path, FileAccess.WRITE)
file.store_string("invalid resource content")
file.close()
var entities = ECS.deserialize(file_path)
assert_that(entities).has_size(0)
func test_deserialize_empty_resource():
# Create a GecsData resource with empty entities array
var empty_data = GecsData.new([])
var file_path = "res://reports/test_empty_resource.tres"
ECS.save(empty_data, file_path)
var entities = ECS.deserialize(file_path)
assert_that(entities).has_size(0)
func test_multiple_entities_with_persistent_components():
# Create multiple entities with persistent components
var entities_to_create = []
for i in range(5):
var entity = Entity.new()
entity.name = "PersistentEntity_" + str(i)
entity.add_component(C_Persistent.new("Player" + str(i), i + 1, 100.0 - i * 5, Vector2(i * 10, i * 20), ["item" + str(i)]))
entities_to_create.append(entity)
world.add_entities(entities_to_create)
# Serialize all persistent entities
var query = world.query.with_all([C_Persistent])
var serialized_data = ECS.serialize(query)
# Save and reload
var file_path = "res://reports/test_multiple_persistent.tres"
ECS.save(serialized_data, file_path)
var deserialized_entities = ECS.deserialize(file_path)
assert_that(deserialized_entities).has_size(5)
# Validate each entity
for i in range(5):
var entity = deserialized_entities[i]
assert_that(entity.name).is_equal("PersistentEntity_" + str(i))
assert_that(entity.has_component(C_Persistent)).is_true()
var persistent_comp = entity.get_component(C_Persistent)
assert_that(persistent_comp.player_name).is_equal("Player" + str(i))
assert_that(persistent_comp.level).is_equal(i + 1)
assert_that(persistent_comp.health).is_equal(100.0 - i * 5)
assert_that(persistent_comp.position).is_equal(Vector2(i * 10, i * 20))
assert_that(persistent_comp.inventory).is_equal(["item" + str(i)])
# Use auto_free for cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_performance_serialization_large_dataset():
# Create many entities for performance testing
var start_time = Time.get_ticks_msec()
var entities_to_create = []
for i in range(100):
var entity = Entity.new()
entity.name = "PerfEntity_" + str(i)
entity.add_component(C_SerializationTest.new(i, i * 1.1, "entity_" + str(i), i % 2 == 0))
entity.add_component(C_Persistent.new("Player" + str(i), i, 100.0, Vector2(i, i)))
entities_to_create.append(entity)
world.add_entities(entities_to_create)
var creation_time = Time.get_ticks_msec() - start_time
# Serialize all entities
var serialize_start = Time.get_ticks_msec()
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
var serialize_time = Time.get_ticks_msec() - serialize_start
# Validate serialization completed
assert_that(serialized_data.entities).has_size(100)
# Save to file
var save_start = Time.get_ticks_msec()
var file_path = "res://reports/test_performance.tres"
ECS.save(serialized_data, file_path)
var save_time = Time.get_ticks_msec() - save_start
# Deserialize
var deserialize_start = Time.get_ticks_msec()
var deserialized_entities = ECS.deserialize(file_path)
var deserialize_time = Time.get_ticks_msec() - deserialize_start
# Validate deserialization
assert_that(deserialized_entities).has_size(100)
# Performance assertions (should complete in reasonable time)
print("Performance Test Results:")
print(" Entity Creation: ", creation_time, "ms")
print(" Serialization: ", serialize_time, "ms")
print(" File Save: ", save_time, "ms")
print(" Deserialization: ", deserialize_time, "ms")
# These are reasonable expectations for 100 entities
assert_that(serialize_time).is_less(1000) # < 1 second
assert_that(deserialize_time).is_less(1000) # < 1 second
# Use auto_free for cleanup
for entity in deserialized_entities:
auto_free(entity)
func test_binary_format_and_auto_detection():
# Create test entity with various component types
var entity = Entity.new()
entity.name = "BinaryTestEntity"
entity.add_component(C_SerializationTest.new(777, 3.14159, "binary_test", true, Vector2(10.0, 20.0), Vector3(1.0, 2.0, 3.0), Color.RED))
entity.add_component(C_Persistent.new("BinaryPlayer", 99, 88.8, Vector2(100.0, 200.0), ["sword", "shield", "potion"]))
world.add_entity(entity)
# Serialize the entity
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Save in both formats
var text_path = "res://reports/test_binary_format.tres"
var binary_test_path = "res://reports/test_binary_format.tres" # Same path for both
# Save as text format
ECS.save(serialized_data, text_path, false)
# Save as binary format (should create .res file)
ECS.save(serialized_data, binary_test_path, true)
# Verify both files exist
assert_that(ResourceLoader.exists("res://reports/test_binary_format.tres")).is_true()
assert_that(ResourceLoader.exists("res://reports/test_binary_format.res")).is_true()
# Test auto-detection: should load binary (.res) first
print("Deserializing from: ", binary_test_path)
print("Binary file exists: ", ResourceLoader.exists("res://reports/test_binary_format.res"))
print("Text file exists: ", ResourceLoader.exists("res://reports/test_binary_format.tres"))
var entities_auto = ECS.deserialize(binary_test_path)
print("Deserialized entities count: ", entities_auto.size())
assert_that(entities_auto).has_size(1)
# Verify loaded data is correct
var loaded_entity = entities_auto[0]
assert_that(loaded_entity.name).is_equal("BinaryTestEntity")
var loaded_serialization = loaded_entity.get_component(C_SerializationTest)
assert_that(loaded_serialization.int_value).is_equal(777)
assert_that(loaded_serialization.string_value).is_equal("binary_test")
var loaded_persistent = loaded_entity.get_component(C_Persistent)
assert_that(loaded_persistent.player_name).is_equal("BinaryPlayer")
assert_that(loaded_persistent.level).is_equal(99)
assert_that(loaded_persistent.inventory).is_equal(["sword", "shield", "potion"])
# Use auto_free for cleanup
for _entity in entities_auto:
auto_free(_entity)
print("Binary format test completed successfully!")
# Compare file sizes (for information)
var text_file = FileAccess.open("res://reports/test_binary_format.tres", FileAccess.READ)
var binary_file = FileAccess.open("res://reports/test_binary_format.res", FileAccess.READ)
if text_file and binary_file:
var text_size = text_file.get_length()
var binary_size = binary_file.get_length()
text_file.close()
binary_file.close()
print("File size comparison:")
print(" Text (.tres): ", text_size, " bytes")
print(" Binary (.res): ", binary_size, " bytes")
print(" Compression: ", "%.1f" % ((1.0 - float(binary_size) / float(text_size)) * 100), "% smaller")
func test_prefab_entity_serialization():
# Load a prefab entity from scene
var packed_scene = load("res://addons/gecs/tests/entities/e_prefab_test.tscn") as PackedScene
var prefab_entity = packed_scene.instantiate() as Entity
prefab_entity.name = "LoadedPrefab"
world.add_entity(prefab_entity)
# Add C_Test_C back in
prefab_entity.add_component(C_TestC.new(99))
# Get component values before serialization for comparison
var original_test_a = prefab_entity.get_component(C_TestA)
var original_test_b = prefab_entity.get_component(C_TestB)
var original_test_c = prefab_entity.get_component(C_TestC)
assert_that(original_test_a).is_not_null()
assert_that(original_test_b).is_not_null()
assert_that(original_test_c).is_not_null()
var original_a_value = original_test_a.value
var original_b_value = original_test_b.value
var original_c_value = original_test_c.value
# Serialize entities with test components
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
# Validate the prefab was serialized with scene path
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.entity_name).is_equal("LoadedPrefab")
assert_that(entity_data.scene_path).is_equal("res://addons/gecs/tests/entities/e_prefab_test.tscn")
assert_that(entity_data.components).has_size(3) # Should have C_TestA, C_TestB, C_TestC
# Save and reload
var file_path = "res://reports/test_prefab_serialization.tres"
ECS.save(serialized_data, file_path)
# Remove original entity from world
world.remove_entity(prefab_entity)
# Deserialize and validate prefab is properly reconstructed
var deserialized_entities = ECS.deserialize(file_path)
assert_that(deserialized_entities).has_size(1)
var des_entity = deserialized_entities[0]
assert_that(des_entity.name).is_equal("LoadedPrefab")
assert_that(des_entity.has_component(C_TestA)).is_true()
assert_that(des_entity.has_component(C_TestB)).is_true()
assert_that(des_entity.has_component(C_TestC)).is_true()
# Validate component values are preserved
var test_a = des_entity.get_component(C_TestA)
var test_b = des_entity.get_component(C_TestB)
var test_c = des_entity.get_component(C_TestC)
assert_that(test_a.value).is_equal(original_a_value)
assert_that(test_b.value).is_equal(original_b_value)
# NOW THE CRITICAL TEST: Add deserialized entity back to world
world.add_entity(des_entity)
# Verify components still work after being added to world
assert_that(des_entity.has_component(C_TestA)).is_true()
assert_that(des_entity.has_component(C_TestB)).is_true()
assert_that(des_entity.has_component(C_TestC)).is_true()
# Verify we can still get components after world operations
var world_test_a = des_entity.get_component(C_TestA)
var world_test_b = des_entity.get_component(C_TestB)
var world_test_c = des_entity.get_component(C_TestC)
assert_that(world_test_a).is_not_null()
assert_that(world_test_b).is_not_null()
assert_that(world_test_c).is_not_null()
# Verify values are still correct after world operations
assert_that(world_test_a.value).is_equal(original_a_value)
assert_that(world_test_b.value).is_equal(original_b_value)
# Test that queries still work with the deserialized entity
var world_query = world.query.with_all([C_TestA])
var found_entities = world_query.execute()
assert_that(found_entities).has_size(1)
assert_that(found_entities[0]).is_equal(des_entity)
print("Prefab entity serialization with world round-trip test completed successfully!")
func test_serialize_config_include_all_components():
# Create entity with multiple components
var entity = Entity.new()
entity.name = "ConfigTestEntity"
entity.add_component(C_SerializationTest.new(100, 5.5, "test", true))
entity.add_component(C_Persistent.new("Player", 10, 75.0, Vector2(10, 20), ["item1"]))
world.add_entity(entity)
# Test default config (include all)
var config = GECSSerializeConfig.new()
assert_that(config.include_all_components).is_true()
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query, config)
# Should include both components
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.components).has_size(2)
func test_serialize_config_specific_components_only():
# Create entity with multiple components
var entity = Entity.new()
entity.name = "SpecificConfigTestEntity"
entity.add_component(C_SerializationTest.new(200, 10.5, "specific", false))
entity.add_component(C_Persistent.new("SpecificPlayer", 20, 90.0, Vector2(30, 40), ["item2"]))
world.add_entity(entity)
# Configure to include only C_SerializationTest
var config = GECSSerializeConfig.new()
config.include_all_components = false
config.components = [C_SerializationTest]
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query, config)
# Should only include C_SerializationTest component
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.components).has_size(1)
# Verify it's the correct component type
var component = entity_data.components[0]
assert_that(component is C_SerializationTest).is_true()
assert_that(component.int_value).is_equal(200)
func test_serialize_config_exclude_relationships():
# Create entities with relationships
var parent = Entity.new()
parent.name = "ParentEntity"
parent.add_component(C_SerializationTest.new(300, 15.5, "parent", true))
var child = Entity.new()
child.name = "ChildEntity"
child.add_component(C_SerializationTest.new(400, 20.5, "child", false))
world.add_entities([parent, child])
# Add relationship
var relationship = Relationship.new(C_TestA.new(), parent)
child.add_relationship(relationship)
# Configure to exclude relationships
var config = GECSSerializeConfig.new()
config.include_relationships = false
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query, config)
# Should have entities but no relationships
assert_that(serialized_data.entities).has_size(2)
for entity_data in serialized_data.entities:
assert_that(entity_data.relationships).has_size(0)
func test_serialize_config_exclude_related_entities():
# Create entities with relationships
var parent = Entity.new()
parent.name = "ParentForExclusion"
parent.add_component(C_SerializationTest.new(500, 25.5, "parent_exclude", true))
var child = Entity.new()
child.name = "ChildForExclusion"
child.add_component(C_Persistent.new("ChildPlayer", 30, 80.0, Vector2(50, 60), ["child_item"]))
world.add_entities([parent, child])
# Add relationship from parent to child
var relationship = Relationship.new(C_TestA.new(), child)
parent.add_relationship(relationship)
# Configure to exclude related entities
var config = GECSSerializeConfig.new()
config.include_related_entities = false
# Query only for parent (which has C_SerializationTest)
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query, config)
# Should only include the parent, not the related child
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.entity_name).is_equal("ParentForExclusion")
func test_world_default_serialize_config():
# Test that world has a default config
assert_that(world.default_serialize_config).is_not_null()
assert_that(world.default_serialize_config.include_all_components).is_true()
assert_that(world.default_serialize_config.include_relationships).is_true()
assert_that(world.default_serialize_config.include_related_entities).is_true()
# Modify world default to exclude relationships and related entities
world.default_serialize_config.include_relationships = false
world.default_serialize_config.include_related_entities = false
# Create entity with relationship
var parent = Entity.new()
parent.name = "WorldConfigParent"
parent.add_component(C_SerializationTest.new(600, 30.5, "world_config", true))
var child = Entity.new()
child.name = "WorldConfigChild"
child.add_component(C_Persistent.new("WorldChild", 40, 70.0, Vector2(70, 80), ["world_item"]))
world.add_entities([parent, child])
var relationship = Relationship.new(C_TestA.new(), child)
parent.add_relationship(relationship)
# Serialize without explicit config (should use world default)
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Should exclude relationships and related entities due to world config
assert_that(serialized_data.entities).has_size(1) # Only parent, no related entities included
var entity_data = serialized_data.entities[0]
assert_that(entity_data.entity_name).is_equal("WorldConfigParent")
assert_that(entity_data.relationships).has_size(0) # No relationships included
func test_entity_level_serialize_config_override():
# Create entity with custom serialize config
var entity = Entity.new()
entity.name = "EntityConfigOverride"
entity.add_component(C_SerializationTest.new(700, 35.5, "entity_override", false))
entity.add_component(C_Persistent.new("EntityPlayer", 50, 60.0, Vector2(90, 100), ["entity_item"]))
# Set entity-specific config to include only C_Persistent
entity.serialize_config = GECSSerializeConfig.new()
entity.serialize_config.include_all_components = false
entity.serialize_config.components = [C_Persistent]
world.add_entity(entity)
# Serialize without explicit config (should use entity override)
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
# Should only include C_Persistent component due to entity config
assert_that(serialized_data.entities).has_size(1)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.components).has_size(1)
var component = entity_data.components[0]
assert_that(component is C_Persistent).is_true()
func test_config_hierarchy_priority():
# Set world default to exclude relationships
world.default_serialize_config.include_relationships = false
# Create entity with entity-level config that includes relationships
var entity = Entity.new()
entity.name = "HierarchyTestEntity"
entity.add_component(C_SerializationTest.new(800, 40.5, "hierarchy", true))
entity.serialize_config = GECSSerializeConfig.new()
entity.serialize_config.include_relationships = true
world.add_entity(entity)
# Add relationship
var other_entity = Entity.new()
other_entity.name = "OtherEntity"
other_entity.add_component(C_Persistent.new("Other", 60, 50.0, Vector2(110, 120), ["other_item"]))
world.add_entity(other_entity)
var relationship = Relationship.new(C_TestA.new(), other_entity)
entity.add_relationship(relationship)
# Test 1: No explicit config should use entity config (include relationships)
var query = world.query.with_all([C_SerializationTest])
var serialized_data = ECS.serialize(query)
var entity_data = serialized_data.entities[0]
assert_that(entity_data.relationships).has_size(1) # Entity config overrides world default
# Test 2: Explicit config should override everything
var explicit_config = GECSSerializeConfig.new()
explicit_config.include_relationships = false
explicit_config.include_related_entities = false
var serialized_data_explicit = ECS.serialize(query, explicit_config)
assert_that(serialized_data_explicit.entities).has_size(1) # No related entities
var entity_data_explicit = serialized_data_explicit.entities[0]
assert_that(entity_data_explicit.relationships).has_size(0) # Explicit config overrides entity config

View File

@@ -0,0 +1 @@
uid://duykas5yc8gn3

View File

@@ -0,0 +1,51 @@
extends GdUnitTestSuite
var testSystemA = TestASystem.new()
var testSystemB = TestBSystem.new()
var testSystemC = TestCSystem.new()
var testSystemD = TestDSystem.new()
func test_topological_sort():
# Create a dictionary of systems by group
var systems_by_group = {
"Group1":
[
testSystemD,
testSystemB,
testSystemC,
testSystemA,
],
"Group2":
[
testSystemB,
testSystemD,
testSystemA,
testSystemC,
]
}
var expected_sorted_systems = {
"Group1":
[
testSystemA,
testSystemB,
testSystemC,
testSystemD,
],
"Group2":
[
testSystemA,
testSystemB,
testSystemC,
testSystemD,
]
}
# Sorts the dict in place
ArrayExtensions.topological_sort(systems_by_group)
# Check if the systems are sorted correctly
for group in systems_by_group.keys():
assert_array(systems_by_group[group]).is_equal(expected_sorted_systems[group])

View File

@@ -0,0 +1 @@
uid://bydpef6khpc53