Clamp keyframe interpolation to last value, add main menu scene/UI, battle HUD, and expanded test coverage

- keyframeGetValue now returns the last keyframe's value for times at or
  beyond it, fixes a missing util/math.h include, and asserts keyframes are
  sorted by time; adds test/animation/test_keyframe.c
- Adds mainmenu scene/UI and a battle HUD UI frame
- Adds save autosave-related fields and battle scene tweaks
- Adds headless test coverage for cutscenes, entities, and map areas
This commit is contained in:
2026-08-06 12:58:07 -05:00
parent fb48285143
commit 1bd73d69fe
42 changed files with 2318 additions and 9 deletions
+1
View File
@@ -3,6 +3,7 @@
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
add_subdirectory(animation)
add_subdirectory(assert)
add_subdirectory(asset)
add_subdirectory(error)
+9
View File
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(dusktest)
# Tests
dusktest(test_keyframe.c)
+180
View File
@@ -0,0 +1,180 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "animation/keyframe.h"
static void test_keyframeGetValueSingleSegmentLinear(void **state) {
keyframe_t keyframes[2] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 2, 0.0f), 0.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 0.25f), 2.5f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 5.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 0.75f), 7.5f, 0.0001f);
}
static void test_keyframeGetValueMultiSegment(void **state) {
keyframe_t keyframes[3] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
{ .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR },
};
// Within the first segment.
assert_float_equal(keyframeGetValue(keyframes, 3, 0.5f), 5.0f, 0.0001f);
// Exactly on the interior keyframe, resolved as the end of segment one.
assert_float_equal(keyframeGetValue(keyframes, 3, 1.0f), 10.0f, 0.0001f);
// Within the second segment.
assert_float_equal(keyframeGetValue(keyframes, 3, 1.5f), 15.0f, 0.0001f);
}
static void test_keyframeGetValueDescendingValues(void **state) {
keyframe_t keyframes[2] = {
{ .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 0.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 2, 0.0f), 100.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 50.0f, 0.0001f);
}
static void test_keyframeGetValueAppliesEasing(void **state) {
keyframe_t keyframes[2] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_IN_QUAD },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
};
// EASING_IN_QUAD is t * t, so halfway through time should be a quarter of
// the way through the value range, not half.
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 2.5f, 0.0001f);
}
static void test_keyframeGetValueNonZeroStartTime(void **state) {
keyframe_t keyframes[2] = {
{ .time = 5.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 10.0f, .value = 100.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 2, 5.0f), 0.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 7.5f), 50.0f, 0.0001f);
}
// NOTE: keyframeGetValue does not clamp to the first keyframe's value when
// queried before the first keyframe's time - the start and end pointers
// collapse onto the same keyframe, so the interpolation divides by a zero
// time delta. This test documents that current behavior rather than
// asserting it is desirable; flag to the maintainer if this should instead
// clamp like the last-keyframe case does.
static void test_keyframeGetValueBeforeFirstKeyframeIsNaN(void **state) {
keyframe_t keyframes[2] = {
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
{ .time = 3.0f, .value = 30.0f, .easing = EASING_LINEAR },
};
assert_true(isnan(keyframeGetValue(keyframes, 2, 0.0f)));
}
static void test_keyframeGetValueAtOrAfterLastKeyframeClampsToLastValue(void **state) {
keyframe_t keyframes[2] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 2, 1.0f), 10.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 2, 2.0f), 10.0f, 0.0001f);
}
static void test_keyframeGetValueSingleKeyframeAtOrAfterClampsToValue(void **state) {
keyframe_t keyframes[1] = {
{ .time = 5.0f, .value = 42.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 1, 5.0f), 42.0f, 0.0001f);
assert_float_equal(keyframeGetValue(keyframes, 1, 10.0f), 42.0f, 0.0001f);
// Before the single keyframe's time is still the documented NaN case.
assert_true(isnan(keyframeGetValue(keyframes, 1, 0.0f)));
}
static void test_keyframeGetValueNullKeyframesAsserts(void **state) {
expect_assert_failure(keyframeGetValue(NULL, 1, 0.0f));
}
static void test_keyframeGetValueZeroCountAsserts(void **state) {
keyframe_t keyframes[1] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
};
expect_assert_failure(keyframeGetValue(keyframes, 0, 0.0f));
}
static void test_keyframeGetValueNegativeTimeAsserts(void **state) {
keyframe_t keyframes[2] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
};
expect_assert_failure(keyframeGetValue(keyframes, 2, -1.0f));
}
static void test_keyframeGetValueUnsortedKeyframesAsserts(void **state) {
keyframe_t keyframes[2] = {
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
};
expect_assert_failure(keyframeGetValue(keyframes, 2, 0.0f));
}
static void test_keyframeGetValueLaterKeyframeOutOfOrderAsserts(void **state) {
// The first pair is sorted, but the third keyframe is out of order - the
// check must walk the whole array, not just the first pair.
keyframe_t keyframes[3] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
{ .time = 0.5f, .value = 5.0f, .easing = EASING_LINEAR },
};
expect_assert_failure(keyframeGetValue(keyframes, 3, 0.0f));
}
static void test_keyframeGetValueEqualConsecutiveTimesDoesNotAssert(void **state) {
// Equal (non-decreasing) times are allowed - only strictly decreasing
// times should trigger the sorted check.
keyframe_t keyframes[3] = {
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
{ .time = 0.0f, .value = 5.0f, .easing = EASING_LINEAR },
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
};
assert_float_equal(keyframeGetValue(keyframes, 3, 0.0f), 5.0f, 0.0001f);
}
int main(int argc, char **argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_keyframeGetValueSingleSegmentLinear),
cmocka_unit_test(test_keyframeGetValueMultiSegment),
cmocka_unit_test(test_keyframeGetValueDescendingValues),
cmocka_unit_test(test_keyframeGetValueAppliesEasing),
cmocka_unit_test(test_keyframeGetValueNonZeroStartTime),
cmocka_unit_test(test_keyframeGetValueBeforeFirstKeyframeIsNaN),
cmocka_unit_test(test_keyframeGetValueAtOrAfterLastKeyframeClampsToLastValue),
cmocka_unit_test(test_keyframeGetValueSingleKeyframeAtOrAfterClampsToValue),
cmocka_unit_test(test_keyframeGetValueNullKeyframesAsserts),
cmocka_unit_test(test_keyframeGetValueZeroCountAsserts),
cmocka_unit_test(test_keyframeGetValueNegativeTimeAsserts),
cmocka_unit_test(test_keyframeGetValueUnsortedKeyframesAsserts),
cmocka_unit_test(test_keyframeGetValueLaterKeyframeOutOfOrderAsserts),
cmocka_unit_test(test_keyframeGetValueEqualConsecutiveTimesDoesNotAssert),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+3 -1
View File
@@ -10,4 +10,6 @@ dusktest(test_rpg.c)
# Subdirs
add_subdirectory(overworld)
add_subdirectory(battle)
add_subdirectory(battle)
add_subdirectory(entity)
add_subdirectory(cutscene)
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(dusktest)
# Tests
dusktest(test_cutscenesystem.c)
dusktest(test_cutscenecontrol.c)
dusktest(test_cutscenemaparea.c)
+142
View File
@@ -0,0 +1,142 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "time/time.h"
#include "util/memory.h"
static void test_cutsceneWaitCompletesAfterItsDuration(void **state) {
cutsceneitem_t item = CUTSCENE_WAIT(1.0f);
cutsceneitemdata_t data;
memoryZero(&data, sizeof(data));
cutsceneWaitStart(&item, &data);
TIME.delta = 0.5f;
assert_false(cutsceneWaitUpdate(&item, &data));
TIME.delta = 0.6f;
assert_true(cutsceneWaitUpdate(&item, &data));
}
static void *lastUserData;
static uint8_t callbackCallCount;
static void recordCallback(void *userData) {
lastUserData = userData;
callbackCallCount++;
}
static void test_cutsceneCallbackFiresOnStartWithUserData(void **state) {
callbackCallCount = 0;
lastUserData = NULL;
cutsceneitem_t item = CUTSCENE_CALLBACK(recordCallback);
cutsceneitemdata_t data;
cutsceneCallbackStart(&item, &data);
assert_int_equal(callbackCallCount, 1);
assert_ptr_equal(lastUserData, CUTSCENE_SYSTEM.userData);
// Callback items always complete immediately -- the effect already
// happened in Start, not Update.
assert_true(cutsceneCallbackUpdate(&item, &data));
assert_int_equal(callbackCallCount, 1);// Update doesn't fire it again
}
static void test_cutsceneCallbackNullIsNoop(void **state) {
callbackCallCount = 0;
cutsceneitem_t item = { .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = NULL };
cutsceneitemdata_t data;
cutsceneCallbackStart(&item, &data);// should not crash
assert_int_equal(callbackCallCount, 0);
}
static void test_cutsceneSetPauseAppliesImmediately(void **state) {
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
cutsceneitem_t item =
CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_BATTLE);
cutsceneitemdata_t data;
cutsceneSetPauseStart(&item, &data);
assert_int_equal(
CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_BATTLE
);
assert_true(cutsceneSetPauseUpdate(&item, &data));
}
static void test_cutsceneConcurrentCompletesOnceAllChildrenDo(void **state) {
cutsceneitem_t item = CUTSCENE_CONCURRENT(
CUTSCENE_WAIT(0.2f),
CUTSCENE_WAIT(0.5f)
);
cutsceneitemdata_t data;
memoryZero(&data, sizeof(data));
cutsceneConcurrentStart(&item, &data);
TIME.delta = 0.3f;
// Child 0 (0.2s) elapses; child 1 (0.5s) still has 0.2s left.
assert_false(cutsceneConcurrentUpdate(&item, &data));
TIME.delta = 0.3f;
// Child 1 now elapses too.
assert_true(cutsceneConcurrentUpdate(&item, &data));
}
static void test_cutsceneConcurrentDoesNotReUpdateFinishedChildren(
void **state
) {
cutsceneitem_t item = CUTSCENE_CONCURRENT(
CUTSCENE_WAIT(0.1f),
CUTSCENE_WAIT(10.0f)
);
cutsceneitemdata_t data;
memoryZero(&data, sizeof(data));
cutsceneConcurrentStart(&item, &data);
TIME.delta = 0.2f;
cutsceneConcurrentUpdate(&item, &data);// child 0 finishes
// If child 0 were re-updated, its stored wait value would keep dropping
// further below zero -- not observable directly, but the overall result
// must not falsely report done just because child 0 keeps completing.
TIME.delta = 0.2f;
assert_false(cutsceneConcurrentUpdate(&item, &data));
}
static void test_cutsceneConcurrentCannotNest(void **state) {
cutsceneitem_t outer =
CUTSCENE_CONCURRENT(CUTSCENE_CONCURRENT(CUTSCENE_WAIT(1.0f)));
cutsceneitemdata_t data;
expect_assert_failure(cutsceneConcurrentStart(&outer, &data));
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_cutsceneWaitCompletesAfterItsDuration),
cmocka_unit_test(test_cutsceneCallbackFiresOnStartWithUserData),
cmocka_unit_test(test_cutsceneCallbackNullIsNoop),
cmocka_unit_test(test_cutsceneSetPauseAppliesImmediately),
cmocka_unit_test(test_cutsceneConcurrentCompletesOnceAllChildrenDo),
cmocka_unit_test(test_cutsceneConcurrentDoesNotReUpdateFinishedChildren),
cmocka_unit_test(test_cutsceneConcurrentCannotNest),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/overworld/maparea.h"
#include "util/memory.h"
static void resetMapAreas(void) {
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
}
static void test_cutsceneMapAreaAddCreatesAreaAndStoresLastCreated(
void **state
) {
resetMapAreas();
cutsceneitem_t item = CUTSCENE_MAP_AREA_ADD(
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitemdata_t data;
cutsceneMapAreaAddStart(&item, &data);
assert_true(cutsceneMapAreaAddUpdate(&item, &data));
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
assert_ptr_equal(MAP_AREAS[id].callback, mapAreaNoopCallback);
assert_int_equal(MAP_AREAS[id].max.x, 5);
}
static void test_cutsceneMapAreaRemoveClearsSlot(void **state) {
resetMapAreas();
uint8_t id = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitem_t item = CUTSCENE_MAP_AREA_REMOVE(id);
cutsceneitemdata_t data;
cutsceneMapAreaRemoveStart(&item, &data);
assert_true(cutsceneMapAreaRemoveUpdate(&item, &data));
assert_null(MAP_AREAS[id].callback);
}
static void test_cutsceneMapAreaRemoveResolvesLastCreatedSentinel(
void **state
) {
resetMapAreas();
cutsceneitem_t addItem = CUTSCENE_MAP_AREA_ADD(
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitemdata_t data;
cutsceneMapAreaAddStart(&addItem, &data);
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
cutsceneitem_t removeItem = CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED);
cutsceneMapAreaRemoveStart(&removeItem, &data);
assert_null(MAP_AREAS[id].callback);
}
static void test_cutsceneMapAreaWaitCompletesWhenTriggerCountChanges(
void **state
) {
resetMapAreas();
uint8_t id = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitem_t item = CUTSCENE_MAP_AREA_WAIT(id);
cutsceneitemdata_t data;
cutsceneMapAreaWaitStart(&item, &data);
assert_false(cutsceneMapAreaWaitUpdate(&item, &data));
MAP_AREAS[id].triggerCount++;// simulates the area's callback firing
assert_true(cutsceneMapAreaWaitUpdate(&item, &data));
}
static void test_cutsceneMapAreaWaitCompletesWhenAnyWatchedAreaChanges(
void **state
) {
resetMapAreas();
uint8_t idA = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
uint8_t idB = mapAreaAdd(
(worldpos_t){ 10, 10, 0 }, (worldpos_t){ 15, 15, 0 },
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitem_t item = CUTSCENE_MAP_AREA_WAIT(idA, idB);
cutsceneitemdata_t data;
cutsceneMapAreaWaitStart(&item, &data);
assert_false(cutsceneMapAreaWaitUpdate(&item, &data));
MAP_AREAS[idB].triggerCount++;// only the second watched area fires
assert_true(cutsceneMapAreaWaitUpdate(&item, &data));
}
static void test_cutsceneMapAreaWaitResolvesLastCreatedSentinel(
void **state
) {
resetMapAreas();
cutsceneitem_t addItem = CUTSCENE_MAP_AREA_ADD(
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
cutsceneitemdata_t data;
cutsceneMapAreaAddStart(&addItem, &data);
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
cutsceneitem_t waitItem = CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED);
cutsceneMapAreaWaitStart(&waitItem, &data);
assert_false(cutsceneMapAreaWaitUpdate(&waitItem, &data));
MAP_AREAS[id].triggerCount++;
assert_true(cutsceneMapAreaWaitUpdate(&waitItem, &data));
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_cutsceneMapAreaAddCreatesAreaAndStoresLastCreated),
cmocka_unit_test(test_cutsceneMapAreaRemoveClearsSlot),
cmocka_unit_test(test_cutsceneMapAreaRemoveResolvesLastCreatedSentinel),
cmocka_unit_test(test_cutsceneMapAreaWaitCompletesWhenTriggerCountChanges),
cmocka_unit_test(test_cutsceneMapAreaWaitCompletesWhenAnyWatchedAreaChanges),
cmocka_unit_test(test_cutsceneMapAreaWaitResolvesLastCreatedSentinel),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/entity/entity.h"
#include "time/time.h"
static uint8_t callbackFired;
static void recordCallback(void *userData) {
callbackFired++;
}
// INNER is jumped into by OUTER's second item, never returning -- this is
// the "nested cutscene is a one-way jump" behavior: cutsceneCutsceneStart
// replaces CUTSCENE_SYSTEM.scene outright, and the callback item's effect
// fires in Start, not Update, so it fires the same frame the jump happens.
CUTSCENE(TEST_INNER, 0, NONE,
CUTSCENE_CALLBACK(recordCallback)
);
CUTSCENE(TEST_OUTER, 0, DEFAULT,
CUTSCENE_WAIT(0.5f),
CUTSCENE_CUTSCENE(TEST_INNER)
);
static void test_cutsceneSystemStartSetsUpInitialItem(void **state) {
cutsceneSystemInit();
cutsceneSystemStartCutscene(&CUTSCENE_TEST_OUTER);
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_OUTER);
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0);
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_DEFAULT);
}
static void test_cutsceneSystemNestedCutsceneIsOneWayJump(void **state) {
cutsceneSystemInit();
callbackFired = 0;
cutsceneSystemStartCutscene(&CUTSCENE_TEST_OUTER);
TIME.delta = 0.1f;
cutsceneSystemUpdate();// wait not elapsed yet
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_OUTER);
assert_int_equal(callbackFired, 0);
// The wait elapses, advancing to the nested-cutscene item, which jumps
// straight into INNER and starts its first item (the callback) -- all
// within this single update call.
TIME.delta = 1.0f;
cutsceneSystemUpdate();
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INNER);
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0);
assert_int_equal(callbackFired, 1);
// INNER's only item (the callback) always reports complete -- one more
// update ends the whole cutscene.
cutsceneSystemUpdate();
assert_null(CUTSCENE_SYSTEM.scene);
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0xFF);
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NONE);
}
static void test_cutsceneSystemUpdateIsNoopWithNoActiveCutscene(void **state) {
cutsceneSystemInit();
cutsceneSystemUpdate();// should not crash
assert_null(CUTSCENE_SYSTEM.scene);
}
CUTSCENE(TEST_SINGLE_WAIT, 0, NONE,
CUTSCENE_WAIT(1.0f)
);
static void test_cutsceneSystemStartWithSetsInteractEntities(void **state) {
cutsceneSystemInit();
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
cutsceneSystemStartCutsceneWith(
&CUTSCENE_TEST_SINGLE_WAIT, &ENTITIES[0], &ENTITIES[1]
);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, &ENTITIES[0]);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, &ENTITIES[1]);
assert_ptr_equal(
cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACT), &ENTITIES[0]
);
assert_ptr_equal(
cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACTED), &ENTITIES[1]
);
TIME.delta = 2.0f;
cutsceneSystemUpdate();// ends the cutscene
assert_null(CUTSCENE_SYSTEM.entityInteract);// reset on end
assert_null(CUTSCENE_SYSTEM.entityInteracted);
}
static void test_cutsceneSystemGetEntitySentinelsRequireBeingSet(
void **state
) {
cutsceneSystemInit();
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACT));
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACTED));
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_CREATED));
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_REF));
}
static void test_cutsceneSystemGetEntityDirectIndexUpdatesLastRef(
void **state
) {
cutsceneSystemInit();
entityInit(&ENTITIES[2], ENTITY_TYPE_NPC);
entity_t *resolved = cutsceneSystemGetEntity(2);
assert_ptr_equal(resolved, &ENTITIES[2]);
// Resolving by direct index also updates LAST_REF.
assert_ptr_equal(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_REF), &ENTITIES[2]);
}
static void test_cutsceneSystemGetAreaId(void **state) {
cutsceneSystemInit();
// cutsceneSystemInit() zero-inits the field -- only actually starting a
// cutscene sets it to the "nothing created yet" sentinel.
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
expect_assert_failure(cutsceneSystemGetAreaId(CUTSCENE_AREA_LAST_CREATED));
assert_int_equal(cutsceneSystemGetAreaId(5), 5);// direct IDs pass through
CUTSCENE_SYSTEM.areaLastCreated = 3;
assert_int_equal(cutsceneSystemGetAreaId(CUTSCENE_AREA_LAST_CREATED), 3);
}
static void test_cutsceneSystemGetTextMiniId(void **state) {
cutsceneSystemInit();
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
expect_assert_failure(
cutsceneSystemGetTextMiniId(CUTSCENE_TEXT_MINI_LAST_CREATED)
);
assert_int_equal(cutsceneSystemGetTextMiniId(4), 4);
CUTSCENE_SYSTEM.textMiniLastCreated = 1;
assert_int_equal(
cutsceneSystemGetTextMiniId(CUTSCENE_TEXT_MINI_LAST_CREATED), 1
);
}
static void test_cutsceneSystemDisposeResetsState(void **state) {
cutsceneSystemInit();
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
cutsceneSystemStartCutsceneWith(
&CUTSCENE_TEST_SINGLE_WAIT, &ENTITIES[0], &ENTITIES[0]
);
cutsceneSystemDispose();
assert_null(CUTSCENE_SYSTEM.scene);
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0xFF);
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NONE);
assert_null(CUTSCENE_SYSTEM.entityInteract);
assert_null(CUTSCENE_SYSTEM.entityInteracted);
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_cutsceneSystemStartSetsUpInitialItem),
cmocka_unit_test(test_cutsceneSystemNestedCutsceneIsOneWayJump),
cmocka_unit_test(test_cutsceneSystemUpdateIsNoopWithNoActiveCutscene),
cmocka_unit_test(test_cutsceneSystemStartWithSetsInteractEntities),
cmocka_unit_test(test_cutsceneSystemGetEntitySentinelsRequireBeingSet),
cmocka_unit_test(test_cutsceneSystemGetEntityDirectIndexUpdatesLastRef),
cmocka_unit_test(test_cutsceneSystemGetAreaId),
cmocka_unit_test(test_cutsceneSystemGetTextMiniId),
cmocka_unit_test(test_cutsceneSystemDisposeResetsState),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+19
View File
@@ -0,0 +1,19 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(dusktest)
# Tests
dusktest(test_entity.c)
target_sources(test_entity PRIVATE entitytestfixture.c)
dusktest(test_npc.c)
target_sources(test_npc PRIVATE entitytestfixture.c)
dusktest(test_entityinteract.c)
target_sources(test_entityinteract PRIVATE entitytestfixture.c)
dusktest(test_entityitem.c)
target_sources(test_entityitem PRIVATE entitytestfixture.c)
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitytestfixture.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/tile.h"
#include "util/memory.h"
void entityTestFixtureReset(void) {
memoryZero(ENTITIES, sizeof(ENTITIES));
memoryZero(&MAP, sizeof(map_t));
MAP.loaded = true;
MAP.chunkPosition = (chunkpos_t){ 0, 0, 0 };
chunk_t *chunk = &MAP.chunks[0];
chunk->position = (chunkpos_t){ 0, 0, 0 };
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND, .z = 0 };
}
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
chunk->entities[i] = 0xFF;
}
MAP.chunkOrder[0] = chunk;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/entity/entity.h"
/**
* Resets ENTITIES[] to all-empty and builds a single minimal, synchronously
* "loaded" chunk at chunk position (0, 0, 0) on the MAP global, entirely
* TILE_SHAPE_GROUND at local Z 0. Sufficient for entityWalk/Turn/Run, chunk
* bookkeeping, and NPC movement tests -- does not exercise the real async
* chunk-loading pipeline (mapChunkLoad).
*
* World positions with x/y in [0, CHUNK_WIDTH-1]/[0, CHUNK_HEIGHT-1] and
* z == 0 fall within the fixture chunk. Individual tests may override
* specific columns afterward (e.g. to place a ramp).
*/
void entityTestFixtureReset(void);
+298
View File
@@ -0,0 +1,298 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "entitytestfixture.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/worldpos.h"
static void setTile(const worldpos_t pos, const tileshape_t shape, const uint8_t z) {
chunktileindex_t index = worldPosToChunkTileIndex(&pos);
MAP.chunks[0].tiles[index] = (tile_t){ .shape = shape, .z = z };
}
static void test_entityInit(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[3], ENTITY_TYPE_ITEM);
assert_int_equal(ENTITIES[3].id, 3);
assert_int_equal(ENTITIES[3].type, ENTITY_TYPE_ITEM);
assert_int_equal(ENTITIES[3].chunkIndex, 0xFF);
assert_int_equal(ENTITIES[3].globalId, ENTITY_GLOBAL_ID_NULL);
// entityItemInit wires the interact callback -- confirms init dispatch ran.
assert_int_equal(ENTITIES[3].interact.type, ENTITY_INTERACT_CALLBACK);
expect_assert_failure(entityInit(NULL, ENTITY_TYPE_ITEM));
expect_assert_failure(entityInit(&ENTITIES[0], ENTITY_TYPE_NULL));
expect_assert_failure(entityInit(&ENTITIES[0], ENTITY_TYPE_COUNT));
entity_t offBounds;
expect_assert_failure(entityInit(&offBounds, ENTITY_TYPE_ITEM));
}
static void test_entityGetAvailable(void **state) {
entityTestFixtureReset();
assert_int_equal(entityGetAvailable(), 0);
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
assert_int_equal(entityGetAvailable(), 1);
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
assert_int_equal(entityGetAvailable(), 2);
}
static void test_entityGetAt(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 3, 4, 0 };
assert_ptr_equal(entityGetAt((worldpos_t){ 3, 4, 0 }), &ENTITIES[0]);
assert_null(entityGetAt((worldpos_t){ 3, 4, 1 }));
assert_null(entityGetAt((worldpos_t){ 0, 0, 0 }));
}
static void test_entityGetByGlobalId(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[2], ENTITY_TYPE_NPC);
ENTITIES[2].globalId = 42;
assert_ptr_equal(entityGetByGlobalId(42), &ENTITIES[2]);
assert_null(entityGetByGlobalId(43));
assert_null(entityGetByGlobalId(ENTITY_GLOBAL_ID_NULL));
}
static void test_entityCanUnload(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].globalId = ENTITY_GLOBAL_ID_START - 1;
assert_true(entityCanUnload(&ENTITIES[0]));
ENTITIES[0].globalId = ENTITY_GLOBAL_ID_START;
assert_false(entityCanUnload(&ENTITIES[0]));
}
static void test_entityCanTurnWalkRun(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
assert_true(entityCanTurn(&ENTITIES[0]));
assert_true(entityCanWalk(&ENTITIES[0]));
assert_true(entityCanRun(&ENTITIES[0]));
ENTITIES[0].animation = ENTITY_ANIM_WALK;
assert_false(entityCanTurn(&ENTITIES[0]));
assert_false(entityCanWalk(&ENTITIES[0]));
assert_false(entityCanRun(&ENTITIES[0]));
ENTITIES[0].animation = ENTITY_ANIM_IDLE;
ENTITIES[0].walkEndCooldown = 0.1f;
assert_false(entityCanTurn(&ENTITIES[0]));// cooldown blocks turning only
assert_true(entityCanWalk(&ENTITIES[0]));
}
static void test_entityTurn(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
entityTurn(&ENTITIES[0], ENTITY_DIR_EAST);
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_EAST);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_TURN);
// Can't turn again mid-turn.
ENTITIES[0].direction = ENTITY_DIR_NORTH;
entityTurn(&ENTITIES[0], ENTITY_DIR_WEST);
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_NORTH);
}
static void test_entityWalkOnOpenGround(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
assert_int_equal(ENTITIES[0].position.x, 5);
assert_int_equal(ENTITIES[0].position.y, 6);
assert_int_equal(ENTITIES[0].position.z, 0);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_WALK);
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_NORTH);
// entityUpdateChunk should have assigned it to the fixture chunk.
assert_int_equal(ENTITIES[0].chunkIndex, 0);
assert_int_equal(MAP.chunks[0].entities[0], 0);
}
static void test_entityWalkBlockedAtChunkEdge(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 0, 0, 0 };
// West/south of the loaded chunk is unloaded (TILE_NULL) -- blocked.
entityWalk(&ENTITIES[0], ENTITY_DIR_WEST);
assert_int_equal(ENTITIES[0].position.x, 0);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
}
static void test_entityWalkBlockedByOtherEntity(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
ENTITIES[1].position = (worldpos_t){ 5, 6, 0 };// directly north
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
assert_int_equal(ENTITIES[0].position.x, 5);
assert_int_equal(ENTITIES[0].position.y, 5);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
}
static void test_entityWalkCannotWhileNotIdle(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
ENTITIES[0].animation = ENTITY_ANIM_WALK;
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
assert_int_equal(ENTITIES[0].position.y, 5);// unchanged
}
// Ramp at (5,5) facing north, raised GROUND at (5,6)/z1 -- walking north
// off the ramp raises the entity by one Z layer, and walking back south
// from the raised tile falls back down onto the ramp.
static void setUpRamp(void) {
setTile((worldpos_t){ 5, 5, 0 }, TILE_SHAPE_RAMP_NORTH, 0);
setTile((worldpos_t){ 5, 6, 0 }, TILE_SHAPE_GROUND, 1);
}
static void test_entityWalkUpRamp(void **state) {
entityTestFixtureReset();
setUpRamp();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
assert_int_equal(ENTITIES[0].position.x, 5);
assert_int_equal(ENTITIES[0].position.y, 6);
assert_int_equal(ENTITIES[0].position.z, 1);
}
static void test_entityWalkFallDownRamp(void **state) {
entityTestFixtureReset();
setUpRamp();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 6, 1 };
entityWalk(&ENTITIES[0], ENTITY_DIR_SOUTH);
assert_int_equal(ENTITIES[0].position.x, 5);
assert_int_equal(ENTITIES[0].position.y, 5);
assert_int_equal(ENTITIES[0].position.z, 0);
}
static void test_entityWalkCannotClimbRampFromTheSide(void **state) {
entityTestFixtureReset();
setUpRamp();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
// Only NORTH climbs this ramp -- EAST off the ramp tile is just blocked
// ground movement, not a climb.
entityWalk(&ENTITIES[0], ENTITY_DIR_EAST);
assert_int_equal(ENTITIES[0].position.x, 6);
assert_int_equal(ENTITIES[0].position.z, 0);
}
static void test_entityRun(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
entityRun(&ENTITIES[0], ENTITY_DIR_NORTH);
assert_int_equal(ENTITIES[0].position.y, 6);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_RUN);
}
static void test_entitySetChunkAndUpdateChunk(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
entitySetChunk(&ENTITIES[0], 0);
assert_int_equal(ENTITIES[0].chunkIndex, 0);
assert_int_equal(MAP.chunks[0].entities[0], 0);
entitySetChunk(&ENTITIES[0], 0xFF);
assert_int_equal(ENTITIES[0].chunkIndex, 0xFF);
assert_int_equal(MAP.chunks[0].entities[0], 0xFF);
ENTITIES[0].position = (worldpos_t){ 3, 3, 0 };
entityUpdateChunk(&ENTITIES[0]);
assert_int_equal(ENTITIES[0].chunkIndex, 0);
}
static void test_entityPositionSet(void **state) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].animation = ENTITY_ANIM_WALK;
ENTITIES[0].walkEndCooldown = 5;
entityPositionSet(&ENTITIES[0], (worldpos_t){ 7, 8, 0 });
assert_int_equal(ENTITIES[0].position.x, 7);
assert_int_equal(ENTITIES[0].position.y, 8);
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
assert_int_equal(ENTITIES[0].walkEndCooldown, 0);
assert_int_equal(ENTITIES[0].chunkIndex, 0);// updated as a side effect
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_entityInit),
cmocka_unit_test(test_entityGetAvailable),
cmocka_unit_test(test_entityGetAt),
cmocka_unit_test(test_entityGetByGlobalId),
cmocka_unit_test(test_entityCanUnload),
cmocka_unit_test(test_entityCanTurnWalkRun),
cmocka_unit_test(test_entityTurn),
cmocka_unit_test(test_entityWalkOnOpenGround),
cmocka_unit_test(test_entityWalkBlockedAtChunkEdge),
cmocka_unit_test(test_entityWalkBlockedByOtherEntity),
cmocka_unit_test(test_entityWalkCannotWhileNotIdle),
cmocka_unit_test(test_entityWalkUpRamp),
cmocka_unit_test(test_entityWalkFallDownRamp),
cmocka_unit_test(test_entityWalkCannotClimbRampFromTheSide),
cmocka_unit_test(test_entityRun),
cmocka_unit_test(test_entitySetChunkAndUpdateChunk),
cmocka_unit_test(test_entityPositionSet),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "entitytestfixture.h"
#include "ui/rpg/textbox/uitextboxmain.h"
#include "ui/focus/uifocus.h"
#include "rpg/cutscene/cutscenesystem.h"
static const cutsceneitem_t CUTSCENE_TEST_INTERACT_ITEMS[] = {
CUTSCENE_WAIT(1.0f)
};
static const cutscene_t CUTSCENE_TEST_INTERACT = {
.items = CUTSCENE_TEST_INTERACT_ITEMS,
.itemCount = 1,
.pause = CUTSCENE_PAUSE_NONE,
.dataSize = 0
};
static entity_t *player;
static entity_t *target;
static void resetInteractFixture(void) {
entityTestFixtureReset();
uiFocusInit();
uiTextboxMainInit();
uiTextboxMainFocusClosed(NULL);// force-clear stale focus from a prior test
cutsceneSystemInit();
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
player = &ENTITIES[0];
target = &ENTITIES[1];
player->direction = ENTITY_DIR_NORTH;
}
static void test_entityInteractWithNull(void **state) {
resetInteractFixture();
target->interact.type = ENTITY_INTERACT_NULL;
entityInteractWith(player, target);// should not crash or change anything
assert_int_equal(target->interact.type, ENTITY_INTERACT_NULL);
}
static void test_entityInteractWithPrintTurnsNpcToFacePlayer(void **state) {
resetInteractFixture();
target->interact.type = ENTITY_INTERACT_PRINT;
target->interact.data.message = "Hello!";
entityInteractWith(player, target);
assert_true(uiTextboxMainIsActive());
assert_int_equal(target->data.npc.interactState, NPC_INTERACT_STATE_CONVERSING);
// NPC turns to face the opposite of the player's facing direction.
assert_int_equal(target->direction, entityDirGetOpposite(player->direction));
}
static entity_t *callbackPlayerArg;
static entity_t *callbackTargetArg;
static uint8_t callbackCount;
static void recordInteractCallback(entity_t *p, entity_t *t) {
callbackPlayerArg = p;
callbackTargetArg = t;
callbackCount++;
}
static void test_entityInteractWithCallback(void **state) {
resetInteractFixture();
callbackCount = 0;
target->interact.type = ENTITY_INTERACT_CALLBACK;
target->interact.data.callback = recordInteractCallback;
entityInteractWith(player, target);
assert_int_equal(callbackCount, 1);
assert_ptr_equal(callbackPlayerArg, player);
assert_ptr_equal(callbackTargetArg, target);
}
static void test_entityInteractWithCallbackRequiresNonNull(void **state) {
resetInteractFixture();
target->interact.type = ENTITY_INTERACT_CALLBACK;
target->interact.data.callback = NULL;
expect_assert_failure(entityInteractWith(player, target));
}
static void test_entityInteractWithCutsceneStartsIt(void **state) {
resetInteractFixture();
target->interact.type = ENTITY_INTERACT_CUTSCENE;
target->interact.data.cutscene = &CUTSCENE_TEST_INTERACT;
entityInteractWith(player, target);
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INTERACT);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, player);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, target);
}
static void test_entityInteractWithRequiresNonNullEntities(void **state) {
resetInteractFixture();
expect_assert_failure(entityInteractWith(NULL, target));
expect_assert_failure(entityInteractWith(player, NULL));
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_entityInteractWithNull),
cmocka_unit_test(test_entityInteractWithPrintTurnsNpcToFacePlayer),
cmocka_unit_test(test_entityInteractWithCallback),
cmocka_unit_test(test_entityInteractWithCallbackRequiresNonNull),
cmocka_unit_test(test_entityInteractWithCutsceneStartsIt),
cmocka_unit_test(test_entityInteractWithRequiresNonNullEntities),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "entitytestfixture.h"
#include "ui/rpg/textbox/uitextboxmain.h"
#include "ui/focus/uifocus.h"
static entity_t *setUpItemEntity(void) {
entityTestFixtureReset();
uiFocusInit();
uiTextboxMainInit();
uiTextboxMainFocusClosed(NULL);// force-clear stale focus from a prior test
entityInit(&ENTITIES[0], ENTITY_TYPE_ITEM);
return &ENTITIES[0];
}
static void test_entityItemInit(void **state) {
entity_t *item = setUpItemEntity();
assert_int_equal(item->interact.type, ENTITY_INTERACT_CALLBACK);
assert_ptr_equal(item->interact.data.callback, entityItemInteract);
}
static void test_entityItemSet(void **state) {
entity_t *item = setUpItemEntity();
entityItemSet(item, ITEM_ID_POTION, 3);
assert_int_equal(item->data.item.item, ITEM_ID_POTION);
assert_int_equal(item->data.item.quantity, 3);
assert_false(item->data.item.collected);
}
// entityItemInteract itself is NOT covered here: it unconditionally calls
// itemGive(), which calls itemGetName(), which dereferences
// LOCALE.entry->data.locale -- a real locale asset populated only by the
// async asset-loading pipeline (see localemanager.h/assetlocaleloader.h).
// That's exactly the asset-loading dependency this test pass scoped out;
// faking it cheaply isn't possible without hand-building the loader's
// internal hash format, which is more coupling than it's worth. Backpack
// bookkeeping itself is already covered by test/item/test_inventory.c-style
// tests at the inventory/backpack layer.
static void test_entityItemMovementWaitsForCollectionAndTextbox(void **state) {
entity_t *item = setUpItemEntity();
// Not collected yet -- stays.
entityItemMovement(item);
assert_int_equal(item->type, ENTITY_TYPE_ITEM);
// Collected (set directly -- see note above on why entityItemInteract
// itself isn't driven here), but a message is still showing -- stays.
item->data.item.collected = true;
uiTextboxMainSetText("Picked up!");
entityItemMovement(item);
assert_int_equal(item->type, ENTITY_TYPE_ITEM);
// Once the textbox is dismissed, the entity despawns.
uiFocusPop();
assert_false(uiTextboxMainIsActive());
entityItemMovement(item);
assert_int_equal(item->type, ENTITY_TYPE_NULL);
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_entityItemInit),
cmocka_unit_test(test_entityItemSet),
cmocka_unit_test(test_entityItemMovementWaitsForCollectionAndTextbox),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "entitytestfixture.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "time/time.h"
static entity_t *setUpNpc(const worldpos_t position) {
entityTestFixtureReset();
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
ENTITIES[0].position = position;
return &ENTITIES[0];
}
static void test_npcSetMoveType(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN);
assert_int_equal(npc->data.npc.moveType, NPC_MOVE_TYPE_RANDOM_TURN);
// Init ran: timer was seeded within the default frequency range.
assert_true(npc->data.npc.moveData.randomTurn.timer > 0.0f);
}
static void test_npcRandomTurnMovementFiresOnceTimerElapses(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN);
npc->data.npc.moveData.randomTurn.timer = 0.05f;
TIME.delta = 0.01f;
npcRandomTurnMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// timer not elapsed yet
TIME.delta = 1.0f;
npcRandomTurnMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_TURN);
// Timer is reseeded, not left at/below zero.
assert_true(npc->data.npc.moveData.randomTurn.timer > 0.0f);
}
static void test_npcRandomWalkMovementFiresOnceTimerElapses(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
npc->data.npc.moveData.randomWalk.timer = 0.05f;
TIME.delta = 0.01f;
npcRandomWalkMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);
TIME.delta = 1.0f;
npcRandomWalkMovement(npc);
// Open ground on all sides -- whichever direction is chosen, it moves.
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
}
static void test_npcRandomTurnAndWalkMovementRunsBothIndependently(
void **state
) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN_AND_WALK);
npcrandomturnandwalk_t *tw = &npc->data.npc.moveData.randomTurnAndWalk;
tw->turn.timer = 999.0f;// don't fire this tick
tw->walk.timer = 0.05f;// fires this tick
TIME.delta = 1.0f;
npcRandomTurnAndWalkMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
assert_true(tw->turn.timer > 900.0f);// unaffected by the walk firing
}
static void test_npcMovementGatedByCutscenePause(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
npc->data.npc.moveData.randomWalk.timer = 0.0f;
TIME.delta = 1.0f;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NPC;
npcMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// blocked by pause
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
npcMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
}
static void test_npcMovementGatedByInteractState(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
npc->data.npc.moveData.randomWalk.timer = 0.0f;
TIME.delta = 1.0f;
npc->data.npc.interactState = NPC_INTERACT_STATE_CONVERSING;
npcMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// blocked while conversing
npc->data.npc.interactState = NPC_INTERACT_STATE_NONE;
npcMovement(npc);
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
}
static void test_npcPathMovementFollowsAndLoopsWaypoints(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 5, 5, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_PATH);
npcpath_t *path = &npc->data.npc.moveData.path;
npcPathAddNode(&npc->data.npc, (worldpos_t){ 5, 6, 0 });
npcPathAddNode(&npc->data.npc, (worldpos_t){ 5, 5, 0 });
npcPathMovement(npc);// steps toward waypoint 0
assert_int_equal(npc->position.x, 5);
assert_int_equal(npc->position.y, 6);
assert_int_equal(path->index, 0);// not yet considered "arrived"
npc->animation = ENTITY_ANIM_IDLE;// simulate the walk animation finishing
npcPathMovement(npc);// arrives at waypoint 0, advances, steps toward wp 1
assert_int_equal(path->index, 1);
assert_int_equal(npc->position.x, 5);
assert_int_equal(npc->position.y, 5);
}
static void test_npcPathMovementNoopWithEmptyPath(void **state) {
entity_t *npc = setUpNpc((worldpos_t){ 5, 5, 0 });
npcSetMoveType(npc, NPC_MOVE_TYPE_PATH);
npcPathMovement(npc);
assert_int_equal(npc->position.x, 5);
assert_int_equal(npc->position.y, 5);
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_npcSetMoveType),
cmocka_unit_test(test_npcRandomTurnMovementFiresOnceTimerElapses),
cmocka_unit_test(test_npcRandomWalkMovementFiresOnceTimerElapses),
cmocka_unit_test(test_npcRandomTurnAndWalkMovementRunsBothIndependently),
cmocka_unit_test(test_npcMovementGatedByCutscenePause),
cmocka_unit_test(test_npcMovementGatedByInteractState),
cmocka_unit_test(test_npcPathMovementFollowsAndLoopsWaypoints),
cmocka_unit_test(test_npcPathMovementNoopWithEmptyPath),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+1
View File
@@ -6,5 +6,6 @@
include(dusktest)
# Tests
dusktest(test_maparea.c)
# Subdirs
+213
View File
@@ -0,0 +1,213 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "rpg/overworld/maparea.h"
#include "rpg/overworld/map.h"
#include "rpg/entity/entity.h"
#include "util/memory.h"
static uint8_t callbackCount;
static uint8_t lastTrigger;
static void recordCallback(entity_t *entity, const uint8_t trigger) {
callbackCount++;
lastTrigger = trigger;
}
static void resetMapAreas(void) {
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
callbackCount = 0;
lastTrigger = 0;
}
static void test_mapAreaInit(void **state) {
maparea_t area;
const worldpos_t min = { 5, 5, 0 };
const worldpos_t max = { 0, 0, 0 };
// min/max should be normalized regardless of argument order.
mapAreaInit(&area, min, max, recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL);
assert_int_equal(area.min.x, 0);
assert_int_equal(area.min.y, 0);
assert_int_equal(area.max.x, 5);
assert_int_equal(area.max.y, 5);
assert_int_equal(area.triggerCount, 0);
expect_assert_failure(
mapAreaInit(&area, min, max, NULL, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL)
);
}
static void test_mapAreaIsInside(void **state) {
maparea_t area;
mapAreaInit(
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 10, 10, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
assert_true(mapAreaIsInside(&area, (worldpos_t){ 5, 5, 0 }));
assert_true(mapAreaIsInside(&area, (worldpos_t){ 0, 0, 0 }));// inclusive min
assert_true(mapAreaIsInside(&area, (worldpos_t){ 10, 10, 0 }));// inclusive max
assert_false(mapAreaIsInside(&area, (worldpos_t){ 11, 5, 0 }));
assert_false(mapAreaIsInside(&area, (worldpos_t){ 5, 5, 1 }));
}
static void test_mapAreaShouldNotify(void **state) {
maparea_t area;
mapAreaInit(
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 10, 10, 0 },
recordCallback, MAP_AREA_NOTIFY_PLAYER, MAP_TRIGGER_ALL
);
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
entityInit(&ENTITIES[2], ENTITY_TYPE_ITEM);
assert_true(mapAreaShouldNotify(&area, &ENTITIES[0]));
assert_false(mapAreaShouldNotify(&area, &ENTITIES[1]));// not notified
assert_false(mapAreaShouldNotify(&area, &ENTITIES[2]));// item never notifies
area.notify = MAP_AREA_NOTIFY_ALL;
assert_true(mapAreaShouldNotify(&area, &ENTITIES[1]));
}
static void test_mapAreaAddAndRemove(void **state) {
resetMapAreas();
uint8_t id = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
assert_int_equal(id, 0);
assert_ptr_equal(MAP_AREAS[0].callback, recordCallback);
// Adding again should reuse the next free slot, not the same one.
uint8_t id2 = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
assert_int_equal(id2, 1);
mapAreaRemove(id);
assert_null(MAP_AREAS[0].callback);// slot freed
// Removing frees the slot for reuse.
uint8_t id3 = mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
assert_int_equal(id3, 0);
}
static void test_mapAreaCheckEntityTriggersEnterStepExit(void **state) {
resetMapAreas();
mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };// outside
// Outside -> outside: no callback.
mapAreaCheckEntity(&ENTITIES[0]);
assert_int_equal(callbackCount, 0);
assert_int_equal(MAP_AREAS[0].triggerCount, 0);
// Outside -> inside: ENTER.
ENTITIES[0].position = (worldpos_t){ 2, 2, 0 };
mapAreaCheckEntity(&ENTITIES[0]);
assert_int_equal(callbackCount, 1);
assert_int_equal(lastTrigger, MAP_TRIGGER_ENTER);
assert_int_equal(MAP_AREAS[0].triggerCount, 1);
// Inside -> inside: STEP, every subsequent call.
mapAreaCheckEntity(&ENTITIES[0]);
assert_int_equal(callbackCount, 2);
assert_int_equal(lastTrigger, MAP_TRIGGER_STEP);
mapAreaCheckEntity(&ENTITIES[0]);
assert_int_equal(callbackCount, 3);
assert_int_equal(lastTrigger, MAP_TRIGGER_STEP);
// Inside -> outside: EXIT.
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };
mapAreaCheckEntity(&ENTITIES[0]);
assert_int_equal(callbackCount, 4);
assert_int_equal(lastTrigger, MAP_TRIGGER_EXIT);
}
static void test_mapAreaCheckEntityRespectsTriggerMask(void **state) {
resetMapAreas();
// Only interested in ENTER, not STEP or EXIT.
mapAreaAdd(
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ENTER
);
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
ENTITIES[0].position = (worldpos_t){ 2, 2, 0 };
mapAreaCheckEntity(&ENTITIES[0]);// ENTER: fires
assert_int_equal(callbackCount, 1);
mapAreaCheckEntity(&ENTITIES[0]);// STEP: masked out
assert_int_equal(callbackCount, 1);
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };
mapAreaCheckEntity(&ENTITIES[0]);// EXIT: masked out
assert_int_equal(callbackCount, 1);
}
static void test_mapAreaCanUnload(void **state) {
resetMapAreas();
memoryZero(&MAP, sizeof(map_t));
maparea_t area;
mapAreaInit(
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
);
// No chunk overlaps the area's bounds (all chunks default to position 0,
// 0, 0 after memoryZero -- move them far away first).
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
MAP.chunks[i].position = (chunkpos_t){ 100, 100, 100 };
}
assert_true(mapAreaCanUnload(&area));
// One chunk overlapping the area's bounds blocks unload.
MAP.chunks[0].position = (chunkpos_t){ 0, 0, 0 };
assert_false(mapAreaCanUnload(&area));
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_mapAreaInit),
cmocka_unit_test(test_mapAreaIsInside),
cmocka_unit_test(test_mapAreaShouldNotify),
cmocka_unit_test(test_mapAreaAddAndRemove),
cmocka_unit_test(test_mapAreaCheckEntityTriggersEnterStepExit),
cmocka_unit_test(test_mapAreaCheckEntityRespectsTriggerMask),
cmocka_unit_test(test_mapAreaCanUnload),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}