Add physics, heap, json_helpers, game, and actor test suites
Raise line coverage from 39.6 to 61.8 percent with four new suites and an
extension to the actor suite, and register every suite through a single
CMake list so a new test file cannot be left out of the coverage fixture.
Give the test targets a build-tree RPATH and prepend the build tree to
LD_LIBRARY_PATH for CTest, so a developer with a previously installed
libakgl.so exercises the library that was just compiled.
Fix six defects the new tests exposed:
- akgl_physics_simulate read self->gravity_time before its NULL check, so a
NULL backend crashed instead of reporting AKERR_NULLPOINTER.
- akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose
inside the PROCESS switch. An ordinary save never flushed or closed its
stream and produced an empty file.
- akgl_game_save_actors wrote each name table terminator from the address of
a single char, emitting stack contents into the save file and a sentinel
the loader could not recognize.
- akgl_game_load_objectnamemap used CATCH directly inside while(1), where the
break leaves the loop rather than propagating, so a truncated name table
loaded as a successful game.
- akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no
NULL check, unlike their left and right counterparts.
- akgl_actor_logic_movement checked actor twice instead of checking
actor->basechar before dereferencing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
|
|
|
/**
|
|
|
|
|
* @file physics.c
|
|
|
|
|
* @brief Unit tests for the physics backends and the simulation loop.
|
|
|
|
|
*
|
|
|
|
|
* None of these tests need a renderer or a window. The simulation loop walks
|
|
|
|
|
* HEAP_ACTOR directly, so each test rebuilds the actor heap rather than relying
|
|
|
|
|
* on state left behind by an earlier one.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
|
#include <string.h>
|
|
|
|
|
#include <akerror.h>
|
|
|
|
|
|
|
|
|
|
#include <akgl/error.h>
|
|
|
|
|
#include <akgl/physics.h>
|
|
|
|
|
#include <akgl/actor.h>
|
|
|
|
|
#include <akgl/character.h>
|
|
|
|
|
#include <akgl/game.h>
|
|
|
|
|
#include <akgl/heap.h>
|
|
|
|
|
#include <akgl/registry.h>
|
|
|
|
|
#include <akgl/staticstring.h>
|
|
|
|
|
|
|
|
|
|
#include "testutil.h"
|
|
|
|
|
|
|
|
|
|
/** @brief Records whether the stub movement logic ran, and with what dt. */
|
|
|
|
|
static int stub_movement_calls = 0;
|
|
|
|
|
static float32_t stub_movement_last_dt = 0;
|
|
|
|
|
|
|
|
|
|
/** @brief Movement logic stub that records its invocation and does nothing else. */
|
|
|
|
|
static akerr_ErrorContext *stub_movement_noop(akgl_Actor *actor, float32_t dt)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
stub_movement_calls += 1;
|
|
|
|
|
stub_movement_last_dt = dt;
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief Movement logic stub that raises the interrupt the simulator must swallow. */
|
|
|
|
|
static akerr_ErrorContext *stub_movement_interrupt(akgl_Actor *actor, float32_t dt)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
stub_movement_calls += 1;
|
|
|
|
|
FAIL_RETURN(e, AKGL_ERR_LOGICINTERRUPT, "deliberate interrupt from test stub");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief Movement logic stub that raises an error the simulator must propagate. */
|
|
|
|
|
static akerr_ErrorContext *stub_movement_error(akgl_Actor *actor, float32_t dt)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
stub_movement_calls += 1;
|
|
|
|
|
FAIL_RETURN(e, AKERR_VALUE, "deliberate error from test stub");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Build a character and an actor bound to it, ready for simulation.
|
|
|
|
|
*
|
|
|
|
|
* The actor is placed on the heap with a nonzero refcount and a movement logic
|
|
|
|
|
* stub, which is the minimum the simulation loop requires to process it.
|
|
|
|
|
*/
|
|
|
|
|
static akerr_ErrorContext *make_sim_actor(akgl_Actor **actor, akgl_Character **basechar, char *name)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, akgl_heap_next_character(basechar));
|
|
|
|
|
CATCH(e, akgl_character_initialize(*basechar, name));
|
|
|
|
|
CATCH(e, akgl_heap_next_actor(actor));
|
|
|
|
|
CATCH(e, akgl_actor_initialize(*actor, name));
|
|
|
|
|
(*actor)->basechar = *basechar;
|
|
|
|
|
(*actor)->movementlogicfunc = &stub_movement_noop;
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief Reset the actor heap so each simulation test starts from a clean slate. */
|
|
|
|
|
static akerr_ErrorContext *reset_sim_heap(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, akgl_registry_init_actor());
|
|
|
|
|
CATCH(e, akgl_registry_init_character());
|
|
|
|
|
CATCH(e, akgl_heap_init());
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
stub_movement_last_dt = 0;
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_init_null(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT(e, backend.gravity == &akgl_physics_null_gravity,
|
|
|
|
|
"init_null did not install the null gravity function");
|
|
|
|
|
TEST_ASSERT(e, backend.collide == &akgl_physics_null_collide,
|
|
|
|
|
"init_null did not install the null collide function");
|
|
|
|
|
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
|
|
|
|
|
"init_null did not install the null move function");
|
|
|
|
|
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
|
|
|
|
|
"init_null did not install the shared simulate function");
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_null(NULL),
|
|
|
|
|
"akgl_physics_init_null(NULL)");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_null_backend_is_inert(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor actor;
|
|
|
|
|
akgl_Actor reference;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
|
|
|
|
|
|
|
|
|
|
actor.x = 3.0f; actor.y = 5.0f; actor.z = 7.0f;
|
|
|
|
|
actor.vx = 11.0f; actor.vy = 13.0f; actor.vz = 17.0f;
|
|
|
|
|
actor.ex = 19.0f; actor.ey = 23.0f; actor.ez = 29.0f;
|
|
|
|
|
memcpy(&reference, &actor, sizeof(akgl_Actor));
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity");
|
|
|
|
|
TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move");
|
|
|
|
|
TEST_EXPECT_OK(e, backend.collide(&backend, &actor, &reference), "null collide");
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
|
|
|
|
|
"the null backend modified the actor");
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(NULL, &actor, 1.0f),
|
|
|
|
|
"null gravity with NULL self");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f),
|
|
|
|
|
"null move with NULL self");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, &reference),
|
|
|
|
|
"null collide with NULL self");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_arcade_gravity(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor actor;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
|
|
|
|
|
// X gravity subtracts, because the X origin is screen-left.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
backend.gravity_x = 10.0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity x");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ex, -5.0f, "gravity_x 10 over dt 0.5 gave ex %f, expected -5", actor.ex);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "gravity_x leaked into ey (%f)", actor.ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "gravity_x leaked into ez (%f)", actor.ez);
|
|
|
|
|
|
|
|
|
|
// Y gravity adds, because the Y origin is down-screen.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
backend.gravity_x = 0;
|
|
|
|
|
backend.gravity_y = 10.0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity y");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "gravity_y 10 over dt 0.5 gave ey %f, expected 5", actor.ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "gravity_y leaked into ex (%f)", actor.ex);
|
|
|
|
|
|
|
|
|
|
// Z gravity subtracts, because the Z origin is behind the camera.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
backend.gravity_y = 0;
|
|
|
|
|
backend.gravity_z = 4.0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.25f), "arcade gravity z");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ez, -1.0f, "gravity_z 4 over dt 0.25 gave ez %f, expected -1", actor.ez);
|
|
|
|
|
|
|
|
|
|
// Negative gravity reverses each axis.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
backend.gravity_x = -10.0;
|
|
|
|
|
backend.gravity_y = -10.0;
|
|
|
|
|
backend.gravity_z = -10.0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity negative");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ex, 10.0f, "negative gravity_x gave ex %f, expected 10", actor.ex);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ey, -10.0f, "negative gravity_y gave ey %f, expected -10", actor.ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ez, 10.0f, "negative gravity_z gave ez %f, expected 10", actor.ez);
|
|
|
|
|
|
|
|
|
|
// A zero dt applies no force even when gravity is set.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
backend.gravity_x = 10.0;
|
|
|
|
|
backend.gravity_y = 10.0;
|
|
|
|
|
backend.gravity_z = 10.0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.0f), "arcade gravity dt 0");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "dt 0 still moved ex (%f)", actor.ex);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "dt 0 still moved ey (%f)", actor.ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "dt 0 still moved ez (%f)", actor.ez);
|
|
|
|
|
|
|
|
|
|
// All-zero gravity leaves every axis alone.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
actor.ex = 1.0f; actor.ey = 2.0f; actor.ez = 3.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity all zero");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ex, 1.0f, "zero gravity_x changed ex (%f)", actor.ex);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ey, 2.0f, "zero gravity_y changed ey (%f)", actor.ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.ez, 3.0f, "zero gravity_z changed ez (%f)", actor.ez);
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(NULL, &actor, 1.0f),
|
|
|
|
|
"arcade gravity with NULL self");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(&backend, NULL, 1.0f),
|
|
|
|
|
"arcade gravity with NULL actor");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_arcade_move(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor actor;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
|
|
|
|
|
actor.x = 100.0f; actor.y = 200.0f; actor.z = 300.0f;
|
|
|
|
|
actor.vx = 10.0f; actor.vy = -20.0f; actor.vz = 30.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.5f), "arcade move");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "x moved to %f, expected 105", actor.x);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "y moved to %f, expected 190", actor.y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "z moved to %f, expected 315", actor.z);
|
|
|
|
|
|
|
|
|
|
// dt of zero is a no-op regardless of velocity.
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.0f), "arcade move dt 0");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "dt 0 moved x to %f", actor.x);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "dt 0 moved y to %f", actor.y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "dt 0 moved z to %f", actor.z);
|
|
|
|
|
|
|
|
|
|
// Zero velocity is a no-op regardless of dt.
|
|
|
|
|
actor.vx = 0; actor.vy = 0; actor.vz = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 10.0f), "arcade move zero velocity");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "zero velocity moved x to %f", actor.x);
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(NULL, &actor, 1.0f),
|
|
|
|
|
"arcade move with NULL self");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(&backend, NULL, 1.0f),
|
|
|
|
|
"arcade move with NULL actor");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor a1;
|
|
|
|
|
akgl_Actor a2;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
memset(&a1, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
memset(&a2, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
|
|
|
|
|
// Pins the stub so that implementing collision has to update this test.
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2),
|
|
|
|
|
"arcade collide is still a stub");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2),
|
|
|
|
|
"arcade collide with NULL self");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_factory(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_String typestr;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
|
|
|
|
|
CATCH(e, akgl_string_initialize(&typestr, "null"));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"null\")");
|
|
|
|
|
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
|
|
|
|
|
"factory(\"null\") did not install the null backend");
|
|
|
|
|
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_string_initialize(&typestr, "arcade"));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"arcade\")");
|
|
|
|
|
TEST_ASSERT(e, backend.move == &akgl_physics_arcade_move,
|
|
|
|
|
"factory(\"arcade\") did not install the arcade backend");
|
|
|
|
|
|
|
|
|
|
CATCH(e, akgl_string_initialize(&typestr, "newtonian"));
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
|
|
|
|
|
"factory with an unknown backend name");
|
|
|
|
|
|
|
|
|
|
// An empty name must not match either prefix.
|
|
|
|
|
CATCH(e, akgl_string_initialize(&typestr, ""));
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
|
|
|
|
|
"factory with an empty backend name");
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(NULL, &typestr),
|
|
|
|
|
"factory with NULL self");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(&backend, NULL),
|
|
|
|
|
"factory with NULL type");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_init_arcade_properties(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, akgl_registry_init_properties());
|
|
|
|
|
|
|
|
|
|
// With nothing configured, every environmental constant defaults to zero.
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with no properties set");
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.gravity_y, 0.0f,
|
|
|
|
|
"unset physics.gravity.y defaulted to %f, expected 0", backend.gravity_y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.drag_x, 0.0f,
|
|
|
|
|
"unset physics.drag.x defaulted to %f, expected 0", backend.drag_x);
|
|
|
|
|
TEST_ASSERT(e, backend.gravity == &akgl_physics_arcade_gravity,
|
|
|
|
|
"init_arcade did not install the arcade gravity function");
|
|
|
|
|
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
|
|
|
|
|
"init_arcade did not install the shared simulate function");
|
|
|
|
|
|
|
|
|
|
// Configured values are read out of the property registry.
|
|
|
|
|
CATCH(e, akgl_set_property("physics.gravity.x", "1.5"));
|
|
|
|
|
CATCH(e, akgl_set_property("physics.gravity.y", "9.8"));
|
|
|
|
|
CATCH(e, akgl_set_property("physics.gravity.z", "2.25"));
|
|
|
|
|
CATCH(e, akgl_set_property("physics.drag.x", "0.5"));
|
|
|
|
|
CATCH(e, akgl_set_property("physics.drag.y", "0.25"));
|
|
|
|
|
CATCH(e, akgl_set_property("physics.drag.z", "0.125"));
|
|
|
|
|
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with properties set");
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.gravity_x, 1.5f, "physics.gravity.x read as %f", backend.gravity_x);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.gravity_y, 9.8f, "physics.gravity.y read as %f", backend.gravity_y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.gravity_z, 2.25f, "physics.gravity.z read as %f", backend.gravity_z);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.drag_x, 0.5f, "physics.drag.x read as %f", backend.drag_x);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.drag_y, 0.25f, "physics.drag.y read as %f", backend.drag_y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, backend.drag_z, 0.125f, "physics.drag.z read as %f", backend.drag_z);
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_arcade(NULL),
|
|
|
|
|
"init_arcade with NULL self");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_skips_inactive(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *actor = NULL;
|
|
|
|
|
akgl_Character *basechar = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
|
|
|
|
|
// A released actor (refcount 0) is skipped entirely.
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "inactive"));
|
|
|
|
|
actor->refcount = 0;
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a released actor");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 0,
|
|
|
|
|
"simulate ran movement logic for an actor with refcount 0 (%d calls)",
|
|
|
|
|
stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// An actor with no base character is skipped.
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "nochar"));
|
|
|
|
|
actor->basechar = NULL;
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a character-less actor");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 0,
|
|
|
|
|
"simulate ran movement logic for an actor with no base character (%d calls)",
|
|
|
|
|
stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// A fully wired actor is processed.
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "active"));
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with an active actor");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 1,
|
|
|
|
|
"simulate ran movement logic %d times for one active actor, expected 1",
|
|
|
|
|
stub_movement_calls);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_children_follow_parents(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *parent = NULL;
|
|
|
|
|
akgl_Actor *child = NULL;
|
|
|
|
|
akgl_Character *basechar = NULL;
|
|
|
|
|
akgl_Character *childchar = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
|
|
|
|
|
CATCH(e, make_sim_actor(&parent, &basechar, "parent"));
|
|
|
|
|
CATCH(e, make_sim_actor(&child, &childchar, "child"));
|
|
|
|
|
|
|
|
|
|
parent->x = 100.0f; parent->y = 200.0f; parent->z = 300.0f;
|
|
|
|
|
// For a child, vx/vy/vz are read as a fixed offset from the parent.
|
|
|
|
|
child->vx = 5.0f; child->vy = -5.0f; child->vz = 2.0f;
|
|
|
|
|
child->x = -999.0f; child->y = -999.0f; child->z = -999.0f;
|
|
|
|
|
CATCH(e, akgl_actor_add_child(parent, child));
|
|
|
|
|
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a parented child");
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT_FEQ(e, child->x, 105.0f, "child x resolved to %f, expected parent 100 + offset 5", child->x);
|
|
|
|
|
TEST_ASSERT_FEQ(e, child->y, 195.0f, "child y resolved to %f, expected parent 200 - offset 5", child->y);
|
|
|
|
|
TEST_ASSERT_FEQ(e, child->z, 302.0f, "child z resolved to %f, expected parent 300 + offset 2", child->z);
|
|
|
|
|
|
|
|
|
|
// Only the parent goes through the movement logic; the child is positional only.
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 1,
|
|
|
|
|
"simulate ran movement logic %d times, expected 1 (parent only)",
|
|
|
|
|
stub_movement_calls);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_layer_mask(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *layer0 = NULL;
|
|
|
|
|
akgl_Actor *layer3 = NULL;
|
|
|
|
|
akgl_Character *char0 = NULL;
|
|
|
|
|
akgl_Character *char3 = NULL;
|
|
|
|
|
akgl_Iterator opflags;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
|
|
|
|
|
CATCH(e, make_sim_actor(&layer0, &char0, "onlayer0"));
|
|
|
|
|
CATCH(e, make_sim_actor(&layer3, &char3, "onlayer3"));
|
|
|
|
|
layer0->layer = 0;
|
|
|
|
|
layer3->layer = 3;
|
|
|
|
|
|
|
|
|
|
// Masking to layer 3 processes only the layer 3 actor.
|
|
|
|
|
opflags.flags = AKGL_ITERATOR_OP_LAYERMASK;
|
|
|
|
|
opflags.layerid = 3;
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to layer 3");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 1,
|
|
|
|
|
"layer mask 3 processed %d actors, expected 1", stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// Masking to a layer with no actors processes nothing.
|
|
|
|
|
opflags.layerid = 7;
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to an empty layer");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 0,
|
|
|
|
|
"empty layer mask processed %d actors, expected 0", stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// Without the mask flag, layerid is ignored and both actors are processed.
|
|
|
|
|
opflags.flags = 0;
|
|
|
|
|
opflags.layerid = 7;
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate with the layer mask off");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 2,
|
|
|
|
|
"unmasked simulate processed %d actors, expected 2", stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// A NULL iterator selects the documented defaults, which mask nothing.
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with NULL opflags");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 2,
|
|
|
|
|
"NULL opflags processed %d actors, expected 2", stub_movement_calls);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *actor = NULL;
|
|
|
|
|
akgl_Character *basechar = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "thruster"));
|
|
|
|
|
|
|
|
|
|
// Thrust beyond the actor's max speed clamps to +sx, preserving sign.
|
|
|
|
|
actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
|
|
|
|
|
actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with positive over-thrust");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, 40.0f, "ty clamped to %f, expected 40", actor->ty);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz);
|
|
|
|
|
|
|
|
|
|
// Negative over-thrust clamps to -sx.
|
|
|
|
|
actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tz, -30.0f, "tz clamped to %f, expected -30", actor->tz);
|
|
|
|
|
|
|
|
|
|
// Thrust inside the limit is left alone.
|
|
|
|
|
actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz);
|
|
|
|
|
|
|
|
|
|
// Velocity is the sum of environmental force and thrust.
|
|
|
|
|
actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f;
|
|
|
|
|
actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_drag(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *actor = NULL;
|
|
|
|
|
akgl_Character *basechar = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "dragged"));
|
|
|
|
|
|
|
|
|
|
// Zero drag leaves environmental velocity untouched.
|
|
|
|
|
actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f;
|
|
|
|
|
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez);
|
|
|
|
|
|
|
|
|
|
// Nonzero drag bleeds off environmental velocity in proportion to dt.
|
|
|
|
|
// The simulator derives dt from the wall clock, so assert the direction
|
|
|
|
|
// and bounds of the change rather than an exact figure.
|
|
|
|
|
backend.drag_x = 0.5;
|
|
|
|
|
backend.drag_y = 0.5;
|
|
|
|
|
backend.drag_z = 0.5;
|
|
|
|
|
backend.gravity_time = SDL_GetTicksNS();
|
|
|
|
|
SDL_Delay(20);
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied");
|
|
|
|
|
TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f,
|
|
|
|
|
"drag left ex at %f, expected a value between 0 and 100", actor->ex);
|
|
|
|
|
TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f,
|
|
|
|
|
"drag left ey at %f, expected a value between 0 and 100", actor->ey);
|
|
|
|
|
TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f,
|
|
|
|
|
"drag left ez at %f, expected a value between 0 and 100", actor->ez);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_movement_states(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *actor = NULL;
|
|
|
|
|
akgl_Character *basechar = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
CATCH(e, make_sim_actor(&actor, &basechar, "mover"));
|
|
|
|
|
|
|
|
|
|
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
|
|
|
|
|
actor->ax = 10.0f; actor->ay = 20.0f;
|
|
|
|
|
|
|
|
|
|
// An idle actor accumulates no thrust on either axis.
|
|
|
|
|
actor->state = 0;
|
|
|
|
|
actor->tx = 0; actor->ty = 0;
|
|
|
|
|
backend.gravity_time = SDL_GetTicksNS();
|
|
|
|
|
SDL_Delay(10);
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor");
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty);
|
|
|
|
|
|
|
|
|
|
// Moving horizontally accumulates thrust on X only.
|
|
|
|
|
actor->state = AKGL_ACTOR_STATE_MOVING_LEFT;
|
|
|
|
|
actor->tx = 0; actor->ty = 0;
|
|
|
|
|
backend.gravity_time = SDL_GetTicksNS();
|
|
|
|
|
SDL_Delay(10);
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor");
|
|
|
|
|
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty);
|
|
|
|
|
|
|
|
|
|
// Moving vertically accumulates thrust on Y only.
|
|
|
|
|
actor->state = AKGL_ACTOR_STATE_MOVING_DOWN;
|
|
|
|
|
actor->tx = 0; actor->ty = 0;
|
|
|
|
|
backend.gravity_time = SDL_GetTicksNS();
|
|
|
|
|
SDL_Delay(10);
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor");
|
|
|
|
|
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty);
|
|
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx);
|
|
|
|
|
|
|
|
|
|
// The right and up states drive the same accumulators.
|
|
|
|
|
actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP);
|
|
|
|
|
actor->tx = 0; actor->ty = 0;
|
|
|
|
|
backend.gravity_time = SDL_GetTicksNS();
|
|
|
|
|
SDL_Delay(10);
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor");
|
|
|
|
|
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx);
|
|
|
|
|
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_logic_interrupt(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
akgl_Actor *first = NULL;
|
|
|
|
|
akgl_Actor *second = NULL;
|
|
|
|
|
akgl_Character *char1 = NULL;
|
|
|
|
|
akgl_Character *char2 = NULL;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
CATCH(e, akgl_physics_init_null(&backend));
|
|
|
|
|
|
|
|
|
|
CATCH(e, make_sim_actor(&first, &char1, "interrupter"));
|
|
|
|
|
CATCH(e, make_sim_actor(&second, &char2, "follower"));
|
|
|
|
|
first->movementlogicfunc = &stub_movement_interrupt;
|
|
|
|
|
|
|
|
|
|
// AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame",
|
|
|
|
|
// so the second actor must still be reached.
|
|
|
|
|
stub_movement_calls = 0;
|
|
|
|
|
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL),
|
|
|
|
|
"simulate must swallow AKGL_ERR_LOGICINTERRUPT");
|
|
|
|
|
TEST_ASSERT(e, stub_movement_calls == 2,
|
|
|
|
|
"an interrupting actor stopped the loop after %d of 2 actors",
|
|
|
|
|
stub_movement_calls);
|
|
|
|
|
|
|
|
|
|
// An unrelated error is not swallowed.
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
CATCH(e, make_sim_actor(&first, &char1, "failer"));
|
|
|
|
|
first->movementlogicfunc = &stub_movement_error;
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL),
|
|
|
|
|
"simulate must propagate a non-interrupt movement error");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_physics_simulate_nullpointers(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(e);
|
|
|
|
|
akgl_PhysicsBackend backend;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(e, reset_sim_heap());
|
|
|
|
|
|
|
|
|
|
// A backend with no move function cannot simulate.
|
|
|
|
|
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL),
|
|
|
|
|
"simulate with no move function installed");
|
|
|
|
|
|
|
|
|
|
// A NULL backend must be reported, not dereferenced.
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL),
|
|
|
|
|
"simulate with NULL self");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(e) {
|
|
|
|
|
} FINISH(e, true);
|
|
|
|
|
SUCCEED_RETURN(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int main(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
|
|
|
|
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
Migrate to the libakerror 1.0.0 status registry
libakerror 1.0.0 replaced the consumer-sized status-name array with a
private registry and made status-code ownership explicit and enforced.
AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, and the registry
entry points raise akerr_ErrorContext * instead of returning int. See
deps/libakerror/UPGRADING.md.
The break was not only source-level. libakgl's codes sat at
AKERR_LAST_ERRNO_VALUE + 18 through + 22, and 1.0.0 claimed exactly
those five offsets for its own AKERR_STATUS_* registry codes, so every
AKGL_ERR_* was aliasing a libakerror status. HANDLE(e,
AKGL_ERR_LOGICINTERRUPT) at physics.c:222 would have swallowed a
foreign-name refusal.
- Move the band to AKERR_FIRST_CONSUMER_STATUS (256) as fixed offsets,
so a libc that grows an errno cannot move the codes, and add
AKGL_ERR_OWNER, AKGL_ERR_LIMIT and AKGL_ERR_COUNT to describe it.
- Reserve the range and register the names through the owned entry
points, PASS-ing each: these are AKERR_NOIGNORE, and the old
akerr_name_for_status calls discarded failure silently.
- Drop the AKERR_MAX_ERR_VALUE=256 compile definition.
- Guard on AKERR_FIRST_CONSUMER_STATUS in include/akgl/error.h, which
now includes <akerror.h> so the guard is reliable. The embedded build
is fine, but the find_package path can pick up a stale installed
header, and 1.0.0 has an soname, so that pairing is an ABI mismatch
rather than a compile problem. Same guard libakstdlib already carries.
Registration also moves out of akgl_heap_init into a new akgl_error_init
in src/error.c. It was in the heap pool's initializer only because that
was the first thing akgl_game_init called, and the upgrade turned five
fire-and-forget name calls into a library-wide ownership claim that can
fail. That placement was hiding a defect: game.c raises AKGL_ERR_SDL
when SDL_CreateMutex fails, five lines before akgl_heap_init ran, so the
earliest error path in the library was guaranteed to print "Unknown
Error". akgl_error_init is now the first statement in akgl_game_init.
Callers that drive subsystems directly must call akgl_error_init first;
it is idempotent, so ordering it precisely is not required. The eleven
test suites that relied on akgl_heap_init to name their statuses now
call it explicitly, or their failure messages would have degraded to
"Unknown Error".
Add tests/error.c: assert every code reads back its registered name,
that the name table and AKGL_ERR_COUNT agree, that a foreign owner is
refused with AKERR_STATUS_NAME_FOREIGN and AKERR_STATUS_RANGE_OVERLAP,
and that repeating the init is a no-op. That last one is a live
constraint, not a triviality -- libakerror treats only an identical
reservation as a repeat, so a subset or superset raises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:28 -04:00
|
|
|
CATCH(errctx, akgl_error_init());
|
Add physics, heap, json_helpers, game, and actor test suites
Raise line coverage from 39.6 to 61.8 percent with four new suites and an
extension to the actor suite, and register every suite through a single
CMake list so a new test file cannot be left out of the coverage fixture.
Give the test targets a build-tree RPATH and prepend the build tree to
LD_LIBRARY_PATH for CTest, so a developer with a previously installed
libakgl.so exercises the library that was just compiled.
Fix six defects the new tests exposed:
- akgl_physics_simulate read self->gravity_time before its NULL check, so a
NULL backend crashed instead of reporting AKERR_NULLPOINTER.
- akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose
inside the PROCESS switch. An ordinary save never flushed or closed its
stream and produced an empty file.
- akgl_game_save_actors wrote each name table terminator from the address of
a single char, emitting stack contents into the save file and a sentinel
the loader could not recognize.
- akgl_game_load_objectnamemap used CATCH directly inside while(1), where the
break leaves the loop rather than propagating, so a truncated name table
loaded as a successful game.
- akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no
NULL check, unlike their left and right counterparts.
- akgl_actor_logic_movement checked actor twice instead of checking
actor->basechar before dereferencing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
|
|
|
CATCH(errctx, akgl_heap_init());
|
|
|
|
|
CATCH(errctx, akgl_registry_init());
|
|
|
|
|
CATCH(errctx, akgl_registry_init_properties());
|
|
|
|
|
|
|
|
|
|
CATCH(errctx, test_physics_init_null());
|
|
|
|
|
CATCH(errctx, test_physics_null_backend_is_inert());
|
|
|
|
|
CATCH(errctx, test_physics_arcade_gravity());
|
|
|
|
|
CATCH(errctx, test_physics_arcade_move());
|
|
|
|
|
CATCH(errctx, test_physics_arcade_collide_unimplemented());
|
|
|
|
|
CATCH(errctx, test_physics_factory());
|
|
|
|
|
CATCH(errctx, test_physics_init_arcade_properties());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_skips_inactive());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_children_follow_parents());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_layer_mask());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_thrust_and_clamp());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_drag());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_movement_states());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_logic_interrupt());
|
|
|
|
|
CATCH(errctx, test_physics_simulate_nullpointers());
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
|
}
|