/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "dusktest.h" #include "console/console.h" #include "script/scriptmanager.h" #include "ui/ui.h" #include "display/text/font.h" #include "util/memory.h" // Label's text rebuild (uiLabelSetText -> textBuildSpriteCache) only // ever reads font->tileset (pure geometry math), never font->texture -- // so a fake tileset with no real GPU texture behind it is enough to // exercise it here, without needing the real FONT_DEFAULT (which this // headless test binary can't initialize -- fontInitDefault() uploads a // real GL texture, and there's no GL context in a plain cmocka binary). static tileset_t TEST_FAKE_TILESET = { .tileWidth = 6, .tileHeight = 10, .tileCount = 100, .columns = 10, .rows = 10, .uv = { 0.1f, 0.1f } }; static font_t TEST_FAKE_FONT = { .texture = NULL, .tileset = &TEST_FAKE_TILESET }; static int uielement_setup(void **state) { consoleInit(); memoryZero(UI_ELEMENTS, sizeof(UI_ELEMENTS)); memoryZero(&UI, sizeof(UI)); for(uint8_t i = 0; i < UI_ROOT_MAX; i++) UI.root[i] = UI_ELEMENT_ID_INVALID; errorret_t ret = scriptManagerInit(); if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } return 0; } static int uielement_teardown(void **state) { errorret_t ret = scriptManagerDispose(); if(errorIsNotOk(ret)) errorCatch(ret); consoleDispose(); return 0; } static void exec(const char_t *script) { jerry_value_t result; errorret_t ret = scriptManagerExec(script, &result); if(errorIsNotOk(ret)) { errorCatch(errorPrint(ret)); fail_msg("Script execution failed (see printed error above)"); } assert_true(errorIsOk(ret)); jerry_value_free(result); } static void execExpectError(const char_t *script) { errorret_t ret = scriptManagerExec(script, NULL); assert_true(errorIsNotOk(ret)); errorCatch(ret); } static void test_uielement_x_y_readback(void **state) { exec( "var el = new UIElement();" "el.x = 10;" "el.y = 20;" "if(el.x !== 10 || el.y !== 20) throw new Error('x/y mismatch');" ); } static void test_uielement_construct_calls_init(void **state) { exec( "class Foo extends UIElement {" " init() { this.x = 42; }" "}" "var foo = new Foo();" "if(foo.x !== 42) throw new Error('init() did not run');" ); } static void test_uielement_render_no_override_is_noop_when_childless( void **state ) { // No override, no children -- render() should just do nothing, safely. exec("new UIElement().render();"); } static void test_uielement_render_dispatches_to_js_override(void **state) { exec( "class Foo extends UIElement {" " render() { this.rendered = true; }" "}" "var foo = new Foo();" "foo.render();" "if(foo.rendered !== true) throw new Error('render() override not called');" ); } static void test_uielement_add_sets_parent_and_world_position(void **state) { // No render() anywhere -- worldX/worldY/parent are set by add() itself // and always current, not just "as of last render()". exec( "class Child extends UIElement {" " init() { this.x = 32; this.y = 32; }" "}" "class Parent extends UIElement {" " init() {" " this.child = new Child();" " this.add(this.child);" " this.x = 64;" " this.y = 64;" " }" "}" "var parent = new Parent();" "if(parent.child.worldX !== 96) throw new Error('worldX wrong: ' + parent.child.worldX);" "if(parent.child.worldY !== 96) throw new Error('worldY wrong: ' + parent.child.worldY);" "if(parent.child.parent !== parent) throw new Error('parent identity wrong');" "if(parent.child.parent.x !== 64) throw new Error('parent.x wrong');" "if(parent.childCount !== 1) throw new Error('childCount wrong: ' + parent.childCount);" ); } static void test_uielement_no_override_auto_renders_children(void **state) { exec( "class Child extends UIElement {" " render() { this.rendered = true; }" "}" "var parent = new UIElement();" "var child = new Child();" "parent.add(child);" "parent.render();" "if(child.rendered !== true) throw new Error('child was not auto-rendered');" ); } static void test_uielement_override_does_not_auto_render_children( void **state ) { exec( "class Child extends UIElement {" " render() { this.rendered = true; }" "}" "class Parent extends UIElement {" " render() { this.overrideCalled = true; }" "}" "var parent = new Parent();" "var child = new Child();" "parent.add(child);" "parent.render();" "if(parent.overrideCalled !== true) throw new Error('override not called');" "if(child.rendered === true) {" " throw new Error('child should not auto-render under an override');" "}" ); } static void test_uielement_render_children_explicit_opt_in(void **state) { exec( "class Child extends UIElement {" " render() { this.rendered = true; }" "}" "class Parent extends UIElement {" " render() { this.renderChildren(); }" "}" "var parent = new Parent();" "var child = new Child();" "parent.add(child);" "parent.render();" "if(child.rendered !== true) throw new Error('renderChildren() did not render child');" ); } static void test_uielement_update_no_override_is_noop_when_childless( void **state ) { // No override, no children -- update() should just do nothing, safely. exec("new UIElement().update();"); } static void test_uielement_update_dispatches_to_js_override(void **state) { exec( "class Foo extends UIElement {" " update() { this.updated = true; }" "}" "var foo = new Foo();" "foo.update();" "if(foo.updated !== true) throw new Error('update() override not called');" ); } static void test_uielement_no_override_auto_updates_children(void **state) { exec( "class Child extends UIElement {" " update() { this.updated = true; }" "}" "var parent = new UIElement();" "var child = new Child();" "parent.add(child);" "parent.update();" "if(child.updated !== true) throw new Error('child was not auto-updated');" ); } static void test_uielement_override_does_not_auto_update_children( void **state ) { exec( "class Child extends UIElement {" " update() { this.updated = true; }" "}" "class Parent extends UIElement {" " update() { this.overrideCalled = true; }" "}" "var parent = new Parent();" "var child = new Child();" "parent.add(child);" "parent.update();" "if(parent.overrideCalled !== true) throw new Error('override not called');" "if(child.updated === true) {" " throw new Error('child should not auto-update under an override');" "}" ); } static void test_uielement_update_children_explicit_opt_in(void **state) { exec( "class Child extends UIElement {" " update() { this.updated = true; }" "}" "class Parent extends UIElement {" " update() { this.updateChildren(); }" "}" "var parent = new Parent();" "var child = new Child();" "parent.add(child);" "parent.update();" "if(child.updated !== true) throw new Error('updateChildren() did not update child');" ); } static void test_ui_update_reaches_root_and_children(void **state) { exec( "globalThis.parent = new UIElement();" "globalThis.child = new UIElement();" "class Grandchild extends UIElement {" " update() { this.updated = true; }" "}" "globalThis.grandchild = new Grandchild();" "parent.add(child);" "child.add(grandchild);" "UI.add(parent);" ); errorCatch(errorPrint(uiUpdate())); exec( "if(grandchild.updated !== true) {" " throw new Error('uiUpdate() did not reach a rooted grandchild');" "}" ); } static void test_uielement_add_self_throws(void **state) { execExpectError("var el = new UIElement(); el.add(el);"); } static void test_uielement_add_cycle_throws(void **state) { execExpectError( "var a = new UIElement();" "var b = new UIElement();" "a.add(b);" "b.add(a);" ); } static void test_uielement_add_reparents(void **state) { exec( "var parentA = new UIElement();" "var parentB = new UIElement();" "var child = new UIElement();" "parentA.add(child);" "if(parentA.childCount !== 1) throw new Error('parentA should have 1 child');" "parentB.add(child);" "if(parentA.childCount !== 0) {" " throw new Error('parentA should have 0 children after reparent');" "}" "if(parentB.childCount !== 1) throw new Error('parentB should have 1 child');" "if(child.parent !== parentB) throw new Error('child.parent should be parentB');" ); } static void test_uielement_add_capacity_exceeded_throws(void **state) { exec( "globalThis.parent = new UIElement();" "for(var i = 0; i < 8; i++) parent.add(new UIElement());" "if(parent.childCount !== 8) throw new Error('expected 8 children');" ); execExpectError("parent.add(new UIElement());"); } static void test_uielement_dispose_cascades(void **state) { // Freshly-reset pool (see uielement_setup) -- parent is id 0, child is // id 1, in construction order. exec( "globalThis.parent = new UIElement();" "globalThis.child = new UIElement();" "parent.add(child);" "UI.add(parent);" ); assert_true(UI.root[0] == 0); assert_true(UI_ELEMENTS[1].type != UI_ELEMENT_TYPE_NULL); exec("parent.dispose();"); assert_true(UI.root[0] == UI_ELEMENT_ID_INVALID); assert_true(UI_ELEMENTS[0].type == UI_ELEMENT_TYPE_NULL); assert_true(UI_ELEMENTS[1].type == UI_ELEMENT_TYPE_NULL); } static void test_uielement_dispose_is_idempotent(void **state) { exec( "globalThis.el = new UIElement();" "el.dispose();" "el.dispose();" ); } static void test_label_text_default_and_set(void **state) { // Freshly-reset pool (see uielement_setup) -- the first element // constructed in this test is always id 0. exec("globalThis.label = new Label();"); UI_ELEMENTS[0].label.font = &TEST_FAKE_FONT; exec( "if(label.text !== '') throw new Error('default text not empty');" "label.text = 'Hello World!';" "if(label.text !== 'Hello World!') throw new Error('text readback wrong');" ); } static void test_label_color_readback(void **state) { exec( "var label = new Label();" "label.color = { r: 10, g: 20, b: 30, a: 40 };" "if(label.color.r !== 10 || label.color.g !== 20 ||" " label.color.b !== 30 || label.color.a !== 40) {" " throw new Error('color readback wrong');" "}" ); } static void test_rectangle_size_and_color(void **state) { exec( "var rect = new Rectangle();" "rect.width = 100;" "rect.height = 50;" "rect.color = { r: 255, g: 0, b: 0, a: 255 };" "if(rect.width !== 100 || rect.height !== 50) {" " throw new Error('size readback wrong');" "}" "if(rect.color.r !== 255 || rect.color.g !== 0 || rect.color.b !== 0) {" " throw new Error('color readback wrong');" "}" ); } static void test_ui_add_and_remove(void **state) { assert_true(UI.root[0] == UI_ELEMENT_ID_INVALID); exec( "var el = new UIElement();" "el.x = 7;" "UI.add(el);" ); assert_true(UI.root[0] != UI_ELEMENT_ID_INVALID); assert_true(UI_ELEMENTS[UI.root[0]].type == UI_ELEMENT_TYPE_SCRIPTED); assert_float_equal(UI_ELEMENTS[UI.root[0]].x, 7.0f, 0.001f); exec("UI.remove(el);"); assert_true(UI.root[0] == UI_ELEMENT_ID_INVALID); } static void test_ui_add_detaches_from_parent(void **state) { exec( "globalThis.parent = new UIElement();" "globalThis.child = new UIElement();" "parent.add(child);" "if(parent.childCount !== 1) throw new Error('expected 1 child');" "UI.add(child);" "if(parent.childCount !== 0) {" " throw new Error('expected 0 children after UI.add');" "}" "if(child.parent !== undefined) throw new Error('child.parent should be undefined');" ); } int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown( test_uielement_x_y_readback, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_construct_calls_init, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_render_no_override_is_noop_when_childless, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_render_dispatches_to_js_override, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_add_sets_parent_and_world_position, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_no_override_auto_renders_children, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_override_does_not_auto_render_children, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_render_children_explicit_opt_in, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_update_no_override_is_noop_when_childless, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_update_dispatches_to_js_override, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_no_override_auto_updates_children, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_override_does_not_auto_update_children, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_update_children_explicit_opt_in, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_ui_update_reaches_root_and_children, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_add_self_throws, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_add_cycle_throws, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_add_reparents, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_add_capacity_exceeded_throws, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_dispose_cascades, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_uielement_dispose_is_idempotent, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_label_text_default_and_set, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_label_color_readback, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_rectangle_size_and_color, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_ui_add_and_remove, uielement_setup, uielement_teardown ), cmocka_unit_test_setup_teardown( test_ui_add_detaches_from_parent, uielement_setup, uielement_teardown ), }; return cmocka_run_group_tests(tests, NULL, NULL); }