package coffee.liz.ecs; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import coffee.liz.ecs.model.Component; import coffee.liz.ecs.model.Entity; import coffee.liz.ecs.model.System; import coffee.liz.ecs.model.World; import coffee.liz.ecs.model.Query; import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; import java.util.stream.Stream; public class DAGWorldTest { @ParameterizedTest @MethodSource("queryScenarios") public void queryResolvesExpectedEntities(final Query query, final Set expectedEntityKeys) { final World world = new DAGWorld(); final Entity both = world.createEntity(); both.add(new PositionComponent()); both.add(new VelocityComponent()); final Entity positionOnly = world.createEntity(); positionOnly.add(new PositionComponent()); final Entity velocityOnly = world.createEntity(); velocityOnly.add(new VelocityComponent()); final Entity neither = world.createEntity(); world.update(0); final Map entities = Map.of("both", both, "positionOnly", positionOnly, "velocityOnly", velocityOnly, "neither", neither); final Set expectedEntities = expectedEntityKeys.stream().map(entities::get).collect(Collectors.toSet()); assertEquals(expectedEntities, world.resolve(query)); } private static Stream queryScenarios() { return Stream.of(Arguments.of(Query.allOf(PositionComponent.class, VelocityComponent.class), Set.of("both")), Arguments.of(Query.allOf(), Set.of("both", "positionOnly", "velocityOnly", "neither")), Arguments.of(Query.anyOf(PositionComponent.class, VelocityComponent.class), Set.of("both", "positionOnly", "velocityOnly")), Arguments.of(Query.noneOf(PositionComponent.class, VelocityComponent.class), Set.of("neither"))); } @Test public void updateExecutesSystemsInTopologicalOrder() { final CopyOnWriteArrayList executionLog = new CopyOnWriteArrayList<>(); final DAGWorld world = new DAGWorld(new SystemC(executionLog), new SystemA(executionLog), new SystemB(executionLog)); world.update(0); assertEquals(List.of("A", "B", "C"), executionLog); } @Test public void cacheTracksComponentMutationsViaEntityEvents() { final DAGWorld world = new DAGWorld(); final Entity subject = world.createEntity(); assertTrue(world.resolve(Query.allOf(PositionComponent.class)).isEmpty()); subject.add(new PositionComponent()); assertEquals(1, world.resolve(Query.allOf(PositionComponent.class)).size()); subject.remove(PositionComponent.class); assertTrue(world.resolve(Query.allOf(PositionComponent.class)).isEmpty()); } @Test public void removedEntityNoLongerMutatesWorldCache() { final DAGWorld world = new DAGWorld(); final Entity subject = world.createEntity(); subject.add(new PositionComponent()); assertEquals(Set.of(subject), world.resolve(Query.allOf(PositionComponent.class))); world.removeEntity(subject); subject.remove(PositionComponent.class); subject.add(new PositionComponent()); assertTrue(world.resolve(Query.allOf(PositionComponent.class)).isEmpty()); } @Test public void queryFindsComponentsAddedByEarlierSystemInSameTick() { final List> queryResults = new CopyOnWriteArrayList<>(); final DAGWorld world = new DAGWorld(new ComponentAdderSystem(), new ComponentReaderSystem(queryResults)); final Entity entity = world.createEntity(); entity.add(new PositionComponent()); world.update(0); assertEquals(1, queryResults.size()); assertEquals(Set.of(entity), queryResults.get(0)); } @Test public void circularDependencyDetectionThrowsIllegalStateException() { assertThrows(IllegalStateException.class, () -> new DAGWorld(new SystemCycleA(), new SystemCycleB())); } private static final class PositionComponent implements Component { } private static final class VelocityComponent implements Component { } @RequiredArgsConstructor private abstract static class RecordingSystem implements System { private final List log; private final String label; @Override public final void update(final World world, final float deltaSeconds) { log.add(label); } } private static final class SystemA extends RecordingSystem { private SystemA(final List log) { super(log, "A"); } @Override public Collection> getDependencies() { return List.of(); } } private static final class SystemB extends RecordingSystem { private SystemB(final List log) { super(log, "B"); } @Override public Collection> getDependencies() { return Set.of(SystemA.class); } } private static final class SystemC extends RecordingSystem { private SystemC(final List log) { super(log, "C"); } @Override public Collection> getDependencies() { return Set.of(SystemB.class); } } private static final class SystemCycleA implements System { @Override public Collection> getDependencies() { return Set.of(SystemCycleB.class); } @Override public void update(final World world, final float deltaSeconds) { } } private static final class SystemCycleB implements System { @Override public Collection> getDependencies() { return Set.of(SystemCycleA.class); } @Override public void update(final World world, final float deltaSeconds) { } } private static final class ComponentAdderSystem implements System { @Override public Collection> getDependencies() { return List.of(); } @Override public void update(final World world, final float deltaSeconds) { world.resolve(Query.allOf(PositionComponent.class)).forEach(e -> e.add(new VelocityComponent())); } } @RequiredArgsConstructor private static final class ComponentReaderSystem implements System { private final List> queryResults; @Override public Collection> getDependencies() { return Set.of(ComponentAdderSystem.class); } @Override public void update(final World world, final float deltaSeconds) { queryResults.add(world.resolve(Query.allOf(VelocityComponent.class, PositionComponent.class))); } } }