Files
libakgl/tests/ui.c
Andrew Kesterson 566b870a5c Feed the mouse to the UI and tell the host which events it consumed
akgl_ui_handle_event is the mouse half of the input story -- keyboard and
gamepad stay with the control maps, because keyboard focus is something an
application declares rather than something a pointer position implies. It
handles motion, left-button presses and releases, the wheel and window
resizes; everything else, and everything while the subsystem is inert,
reports consumed=false and succeeds, the same pass-everything contract
akgl_controller_handle_event already has. The documented call order is UI
first, control maps second, skipping on consumed.

A press, release or wheel is consumed when the pointer is over any UI
element. The hit test asks clay against the layout the previous frame
retained -- this frame's does not exist while events are polled, and one
frame of staleness is the standard model's accepted cost -- with clay's
internal full-screen Clay__RootContainer excluded, because being inside the
window is not being over the interface (unexcluded, every click anywhere was
consumed; the test that pins this caught it).

Pointer state is fed to clay per event, as clay documents; the press edge is
latched once per frame_begin for the widgets to come, rather than trusting
Clay_PointerData's per-SetPointerState edge, which advances per mouse event.
frame_begin also drives Clay_UpdateScrollContainers from the accumulated
wheel (32 pixels per notch) and a wall-clock dt.

Tests pin the whole contract: pass-through before init, no consumption
before any layout exists, press/release inside vs beside a panel, right
button ignored, motion never consumed, wheel consumed only over the UI,
resize reaching the layout engine, and key events left alone.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:10:35 -04:00

725 lines
26 KiB
C

/**
* @file ui.c
* @brief Unit tests for the UI subsystem: lifecycle, fonts, measurement and rendering.
*
* The lifecycle and arena tests run without a renderer -- akgl_ui_init takes
* its layout dimensions as parameters precisely so that a headless program
* can bring the subsystem up. The layout tests draw real clay layouts into a
* small software renderer under the dummy video driver and read the target
* back, the same arrangement tests/draw.c uses, so their assertions are about
* pixels rather than about clay having been called.
*/
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include "testutil.h"
/** @brief Width and height of the offscreen target the layout tests draw into. */
#define TEST_TARGET_SIZE 64
/** @brief The font every text test measures and draws with. */
#define TEST_FONT_PATH "assets/akgl_test_mono.ttf"
/** @brief Registry name the test font is loaded under. */
#define TEST_FONT_NAME "uifont"
/** @brief Point size the test font is loaded at. */
#define TEST_FONT_SIZE 16
/** @brief Opaque black, what each layout test clears the target to. */
static const SDL_Color testblack = { 0x00, 0x00, 0x00, 0xff };
/** @brief The color most layout tests fill with. */
static const SDL_Color testred = { 0xff, 0x00, 0x00, 0xff };
/** @brief A second color, for borders against fills. */
static const SDL_Color testgreen = { 0x00, 0xff, 0x00, 0xff };
/** @brief Clear the whole target to opaque black. */
static akerr_ErrorContext *clear_target(void)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x00, 0x00, 0x00, 0xff),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
FAIL_ZERO_RETURN(
errctx,
SDL_RenderClear(akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}
/** @brief Report whether one pixel of @p shot carries @p color. Alpha is not compared. */
static bool pixel_is(SDL_Surface *shot, int x, int y, SDL_Color color)
{
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 0;
if ( shot == NULL ) {
return false;
}
if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) {
return false;
}
return ((r == color.r) && (g == color.g) && (b == color.b));
}
/**
* @brief Init must refuse bad dimensions and an uninitialized resize.
*
* Runs before anything initializes the subsystem, because the "not
* initialized" refusal is half of what it asserts.
*/
akerr_ErrorContext *test_ui_validation(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(0, 240),
"akgl_ui_init accepted a zero width");
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(320, -1),
"akgl_ui_init accepted a negative height");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_resize(320, 240),
"akgl_ui_resize worked on an uninitialized subsystem");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(),
"akgl_ui_frame_begin worked on an uninitialized subsystem");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Init, double-init refusal, idempotent shutdown, re-init.
*
* The double init has to be refused rather than absorbed: a second
* Clay_Initialize over a live context would silently discard every element id
* and scroll position the first one held, which is the kind of reset a game
* only notices as "the menu forgot where it was".
*/
akerr_ErrorContext *test_ui_lifecycle(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "first akgl_ui_init failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240),
"a second akgl_ui_init was not refused");
TEST_EXPECT_OK(e, akgl_ui_resize(640, 480), "resize on a live subsystem failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "akgl_ui_shutdown failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "a second akgl_ui_shutdown was not a no-op");
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "re-init after shutdown failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after re-init failed");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief A too-small arena must refuse at init, loudly, and recover.
*
* The narrowed limit stands in for a consumer who raised AKGL_UI_MAX_ELEMENTS
* without raising AKGL_UI_ARENA_BYTES: the refusal at startup, with both
* numbers in the message, is the whole safety story -- past init, clay trusts
* the arena completely. Same shape as the collision arena's exhaustion test,
* for the same reason: rebuilding with a smaller ceiling is not something CI
* can do.
*/
akerr_ErrorContext *test_ui_arena_exhaustion(void)
{
PREPARE_ERROR(e);
ATTEMPT {
akgl_ui_arena_limit(64);
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240),
"akgl_ui_init accepted an arena clay cannot fit in");
akgl_ui_arena_limit(0);
TEST_EXPECT_OK(e, akgl_ui_init(320, 240),
"akgl_ui_init failed after the arena limit was restored");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after the exhaustion test failed");
} CLEANUP {
akgl_ui_arena_limit(0);
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The fontId table: issue, dedupe, refuse the unknown and the overfull.
*
* The table stores names and resolves them per use, so the interesting cases
* are all at registration: an id for a loaded font, the same id again for the
* same name, a refusal for a name nothing loaded, and a refusal -- naming the
* ceiling -- when the table is full.
*/
akerr_ErrorContext *test_ui_font_register(void)
{
PREPARE_ERROR(e);
uint16_t fontid = 99;
uint16_t again = 99;
uint16_t extra = 99;
char slotname[8];
bool loaded = true;
bool registered = true;
int count = 0;
int i = 0;
ATTEMPT {
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"font registration worked on an uninitialized subsystem");
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the font table test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering a loaded font failed");
TEST_ASSERT(e, fontid == 0, "the first fontId issued was %u, not 0", fontid);
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &again),
"re-registering the same name failed");
TEST_ASSERT(e, again == fontid,
"re-registering the same name issued a second id (%u after %u)", again, fontid);
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("nothing-loaded-this", &extra),
"a name absent from the font registry was registered");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(NULL, &extra),
"a NULL name was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(TEST_FONT_NAME, NULL),
"a NULL id destination was accepted");
// Fill the table: the same face under fresh names costs a slot each,
// because the size-baked handle is what a slot maps to.
for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) {
IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i));
TEST_ASSERT_FLAG(loaded, akgl_text_loadfont(slotname, TEST_FONT_PATH, TEST_FONT_SIZE) == NULL);
TEST_ASSERT_FLAG(registered, akgl_ui_font_register(slotname, &extra) == NULL);
}
TEST_ASSERT(e, loaded, "loading the fill fonts failed");
TEST_ASSERT(e, registered, "registering the fill fonts failed");
TEST_EXPECT_OK(e, akgl_text_loadfont("overflow", TEST_FONT_PATH, TEST_FONT_SIZE),
"loading the overflow font failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("overflow", &extra),
"a full fontId table issued another id");
} CLEANUP {
IGNORE(akgl_text_unloadfont("overflow"));
for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) {
IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i));
IGNORE(akgl_text_unloadfont(slotname));
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The measure bridge must agree with akgl_text_measure, slice or not.
*
* clay hands the callback slices carved out of larger strings, so the case
* that matters is a slice whose stated length stops short of the bytes behind
* it -- five bytes of "HelloWorld" must measure exactly as "Hello" does. A
* fontId nothing issued reports zero-by-zero, the only failure channel the
* callback signature allows.
*/
akerr_ErrorContext *test_ui_measure_text(void)
{
PREPARE_ERROR(e);
Clay_TextElementConfig config;
Clay_StringSlice slice;
Clay_Dimensions sliced;
Clay_Dimensions whole;
TTF_Font *font = NULL;
uint16_t fontid = 0;
int w = 0;
int h = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the measure test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the measure test font failed");
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, TEST_FONT_NAME, NULL);
FAIL_ZERO_BREAK(e, font, AKGL_ERR_BEHAVIOR, "the test font left the registry");
SDL_memset(&config, 0x00, sizeof(config));
config.fontId = fontid;
slice.chars = "HelloWorld";
slice.baseChars = slice.chars;
slice.length = 5;
sliced = akgl_ui_measure_text(slice, &config, NULL);
TEST_EXPECT_OK(e, akgl_text_measure(font, "Hello", &w, &h),
"measuring the whole string through the text subsystem failed");
TEST_ASSERT_FEQ(e, sliced.width, (float)w,
"a 5-byte slice of HelloWorld measured %f wide; Hello measures %d",
(double)sliced.width, w);
TEST_ASSERT_FEQ(e, sliced.height, (float)h,
"a 5-byte slice of HelloWorld measured %f high; Hello measures %d",
(double)sliced.height, h);
slice.length = 10;
whole = akgl_ui_measure_text(slice, &config, NULL);
TEST_ASSERT(e, whole.width > sliced.width,
"HelloWorld did not measure wider than its Hello prefix");
config.fontId = (uint16_t)(AKGL_UI_MAX_FONTS + 1);
sliced = akgl_ui_measure_text(slice, &config, NULL);
TEST_ASSERT_FEQ(e, sliced.width, 0.0f,
"an unregistered fontId measured %f wide instead of 0",
(double)sliced.width);
TEST_ASSERT_FEQ(e, sliced.height, 0.0f,
"an unregistered fontId measured %f high instead of 0",
(double)sliced.height);
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The frame bracket refuses to nest, to close unopened, and recovers from failure.
*/
akerr_ErrorContext *test_ui_frame_bracket(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the frame bracket test failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"frame_end closed a frame nothing opened");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening a frame failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(),
"a second frame_begin was not refused");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer),
"closing an empty frame failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"frame_end closed the same frame twice");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_frame_end(NULL),
"frame_end accepted a NULL backend");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief One real layout, executed, read back: fill, border and clip land where clay put them.
*
* An outer container with 8 pixels of padding and a fixed 20x20 child pins
* the child's box to (8,8)-(28,28) without depending on any layout behaviour
* subtler than "padding is padding".
*/
akerr_ErrorContext *test_ui_layout_pixels(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the layout test failed");
// A filled rectangle.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the fill frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the fill frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 10, 10, testred),
"the fill did not land inside the child's box");
TEST_ASSERT(e, pixel_is(shot, 27, 27, testred),
"the fill stopped short of the child's far corner");
TEST_ASSERT(e, pixel_is(shot, 7, 7, testblack),
"the fill leaked outside the child's box");
TEST_ASSERT(e, pixel_is(shot, 40, 40, testblack),
"the fill reached pixels no element occupies");
SDL_DestroySurface(shot);
shot = NULL;
// A border: green edges two pixels wide, nothing in the middle.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the border frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.border = {
.color = { 0, 255, 0, 255 },
.width = { 2, 2, 2, 2, 0 }
}
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the border frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 18, 9, testgreen), "the top border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 18, 26, testgreen), "the bottom border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 9, 18, testgreen), "the left border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 26, 18, testgreen), "the right border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 18, 18, testblack), "the border filled its middle");
SDL_DestroySurface(shot);
shot = NULL;
// A clipped child: a 30x30 fill inside a 10x10 clipping container
// must stop at the container's edge.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the clip frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(10),
.height = CLAY_SIZING_FIXED(10)
}
},
.clip = { .horizontal = true, .vertical = true }
}) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(30),
.height = CLAY_SIZING_FIXED(30)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the clip frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 10, 10, testred),
"the clipped child did not draw inside the clip");
TEST_ASSERT(e, pixel_is(shot, 25, 25, testblack),
"the child escaped its clipping container");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Text laid out by clay lands on the target, and in its colour.
*
* Which pixels a rasterized glyph occupies is SDL_ttf's business, so the
* assertion is that *some* pixel in the text's box carries the text colour --
* enough to prove the measure bridge, the scratch copy and the draw all met.
*/
akerr_ErrorContext *test_ui_layout_text(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
uint16_t fontid = 0;
bool found = false;
int x = 0;
int y = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the text layout test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the layout test font failed");
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the text frame failed");
CLAY({ .layout = { .padding = { 4, 0, 4, 0 } } }) {
CLAY_TEXT(CLAY_STRING("Hi"), CLAY_TEXT_CONFIG({
.fontId = fontid,
.textColor = { 255, 0, 0, 255 }
}));
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the text frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) {
for ( x = 0; x < (TEST_TARGET_SIZE / 2); x++ ) {
if ( pixel_is(shot, x, y, testred) ) {
found = true;
}
}
}
TEST_ASSERT(e, found, "no pixel of the laid-out text carries the text colour");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Build a left-button mouse event at a position, down or up. */
static void make_button_event(SDL_Event *event, uint32_t type, float x, float y)
{
SDL_memset(event, 0x00, sizeof(SDL_Event));
event->type = type;
event->button.button = SDL_BUTTON_LEFT;
event->button.x = x;
event->button.y = y;
}
/**
* @brief The consumed contract: clicks on the UI are the UI's, everything else passes.
*
* The hit test runs against the layout the previous frame retained, so the
* test lays one panel out, then throws events at it: a click inside is
* consumed, a click outside is not, motion never is, and -- the case most
* likely to surprise -- a click before any layout exists is not consumed,
* because there is nothing to be over.
*/
akerr_ErrorContext *test_ui_handle_event(void)
{
PREPARE_ERROR(e);
SDL_Event event;
bool consumed = true;
int token = 1;
ATTEMPT {
// Uninitialized: everything passes through untouched.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"an event before init was refused");
TEST_ASSERT(e, consumed == false, "an event before init was consumed");
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the event test failed");
// Initialized but nothing laid out yet: a click lands on no tree.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a click before any layout was refused");
TEST_ASSERT(e, consumed == false, "a click was consumed before any layout existed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release before any layout was refused");
// Lay out one 20x20 panel at (8,8) for the hit tests that follow.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the hit-test frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the hit-test frame failed");
// A press on the panel is the UI's; its release is too.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press on the panel was refused");
TEST_ASSERT(e, consumed == true, "a press on a UI panel was not consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release on the panel was refused");
TEST_ASSERT(e, consumed == true, "a release on a UI panel was not consumed");
// A press beside the panel belongs to the game.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press beside the panel was refused");
TEST_ASSERT(e, consumed == false, "a press beside the panel was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release beside the panel was refused");
// A right-button press is not the UI's business at all.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
event.button.button = SDL_BUTTON_RIGHT;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a right-button press was refused");
TEST_ASSERT(e, consumed == false, "a right-button press was consumed");
// Motion is never consumed, wherever it lands.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 10.0f;
event.motion.y = 10.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion over the panel was refused");
TEST_ASSERT(e, consumed == false, "motion over a panel was consumed");
// The wheel goes to the UI only while the pointer is over it. The
// motion event above left the pointer on the panel.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel over the panel was refused");
TEST_ASSERT(e, consumed == true, "the wheel over a panel was not consumed");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 50.0f;
event.motion.y = 50.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion off the panel was refused");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel beside the panel was refused");
TEST_ASSERT(e, consumed == false, "the wheel beside the panel was consumed");
// A resize reaches the layout engine and still passes through.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_WINDOW_RESIZED;
event.window.data1 = TEST_TARGET_SIZE;
event.window.data2 = TEST_TARGET_SIZE;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a resize event was refused");
TEST_ASSERT(e, consumed == false, "a resize event was consumed");
// A key event is not the UI's; keyboard focus is the application's.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_RETURN;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a key event was refused");
TEST_ASSERT(e, consumed == false, "a key event was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(NULL, &event, &consumed),
"a NULL appstate was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, NULL, &consumed),
"a NULL event was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, &event, NULL),
"a NULL consumed destination was accepted");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief A layout error inside clay must surface from frame_end, not vanish.
*
* The measure callback and clay's own error handler both report into a stash
* with no other way out. Text declared against a fontId nothing issued is the
* easiest such error to provoke, and the frame after the failed one must be
* clean -- one bad frame, not a wedged subsystem.
*/
akerr_ErrorContext *test_ui_layout_error_surfaces(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the layout error test failed");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the doomed frame failed");
CLAY_TEXT(CLAY_STRING("doomed"), CLAY_TEXT_CONFIG({
.fontId = 7,
.textColor = { 255, 255, 255, 255 }
}));
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"a layout with an unresolvable font closed cleanly");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "the frame after a failed one was refused");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer),
"closing the recovery frame failed");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} 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");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
ATTEMPT {
CATCH(errctx, akgl_error_init());
// The headless tests run first, before SDL is up, because running
// without a renderer is part of what they assert.
CATCH(errctx, test_ui_validation());
CATCH(errctx, test_ui_lifecycle());
CATCH(errctx, test_ui_arena_exhaustion());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO),
AKGL_ERR_SDL,
"Couldn't initialize SDL: %s",
SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
TTF_Init(),
AKGL_ERR_SDL,
"Couldn't initialize the font engine: %s",
SDL_GetError());
CATCH(errctx, akgl_registry_init_font());
CATCH(errctx, akgl_text_loadfont(TEST_FONT_NAME, TEST_FONT_PATH, TEST_FONT_SIZE));
FAIL_ZERO_BREAK(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/test_ui",
TEST_TARGET_SIZE,
TEST_TARGET_SIZE,
0,
&akgl_window,
&akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s",
SDL_GetError());
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
CATCH(errctx, test_ui_font_register());
CATCH(errctx, test_ui_measure_text());
CATCH(errctx, test_ui_frame_bracket());
CATCH(errctx, test_ui_layout_pixels());
CATCH(errctx, test_ui_layout_text());
CATCH(errctx, test_ui_handle_event());
CATCH(errctx, test_ui_layout_error_surfaces());
} CLEANUP {
IGNORE(akgl_text_unloadallfonts());
TTF_Quit();
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}