diff --git a/.github/workflows/run-ubuntu-checks.yml b/.github/workflows/run-ubuntu-checks.yml index 14878830b5..e0a134219a 100644 --- a/.github/workflows/run-ubuntu-checks.yml +++ b/.github/workflows/run-ubuntu-checks.yml @@ -2,6 +2,7 @@ # Also generates coverage information from unit tests. Note that for intrinsics, # it only runs what gets compiled and would naturally run. It also is limited to # what can run in a CI environment. + # Update this workflow when our min/max python minor versions update # This workflow is necessary to ensure that we can build and run with # a debug python build without too much worrying about SIGABRT being thrown @@ -28,6 +29,7 @@ on: # re-include current file to not be excluded - '!.github/workflows/run-ubuntu-checks.yml' + pull_request: branches: main paths-ignore: @@ -102,8 +104,7 @@ jobs: id: build-pygame-ce run: | pyenv global ${{ matrix.python }}-debug - python dev.py build --lax --coverage --sanitize undefined - + python dev.py build --lax --coverage --ctest --sanitize undefined - name: Run tests env: SDL_VIDEODRIVER: "dummy" diff --git a/.gitignore b/.gitignore index ee57d22510..f29b774e44 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ # Ruff .ruff_cache +# Meson subprojects +subprojects/* +!subprojects/*.wrap + # Other envdev* .virtualenv* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9476d3460..cd840221a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,7 @@ repos: | ^.*\.svg$ | ^.*\.sfd$ | docs/LGPL.txt + | subprojects/.* )$ - id: trailing-whitespace exclude: | @@ -23,6 +24,7 @@ repos: | ^.*\.svg$ | ^.*\.sfd$ | docs/LGPL.txt + | subprojects/.* )$ - repo: https://github.com/astral-sh/ruff-pre-commit @@ -47,4 +49,5 @@ repos: | src_c/include/sse2neon.h | src_c/include/pythoncapi_compat.h | src_c/pypm.c + | subprojects/.* )$ diff --git a/ctest/base_ctest.c b/ctest/base_ctest.c new file mode 100644 index 0000000000..c001552ad6 --- /dev/null +++ b/ctest/base_ctest.c @@ -0,0 +1,121 @@ +#include + +#include "base.h" +#include "test_common.h" + +static PyObject *base_module; + +/* setUp and tearDown must be nonstatic void(void) */ +void setUp(void) {} + +void tearDown(void) {} + +/** + * @brief Tests _pg_is_int_tuple when passed a tuple of ints + */ +PG_CTEST(test__pg_is_int_tuple_nominal)(PyObject *self, PyObject *_null) { + PyObject *arg1 = Py_BuildValue("(iii)", 1, 2, 3); + PyObject *arg2 = Py_BuildValue("(iii)", -1, -2, -3); + PyObject *arg3 = Py_BuildValue("(iii)", 1, -2, -3); + + TEST_ASSERT_EQUAL(1, _pg_is_int_tuple(arg1)); + TEST_ASSERT_EQUAL(1, _pg_is_int_tuple(arg2)); + TEST_ASSERT_EQUAL(1, _pg_is_int_tuple(arg3)); + + Py_RETURN_NONE; +} + +/** + * @brief Tests _pg_is_int_tuple when passed a tuple of non-numeric values + */ +PG_CTEST(test__pg_is_int_tuple_failureModes)(PyObject *self, PyObject *_null) { + PyObject *arg1 = + Py_BuildValue("(sss)", (char *)"Larry", (char *)"Moe", (char *)"Curly"); + PyObject *arg2 = Py_BuildValue("(sss)", (char *)NULL, (char *)NULL, + (char *)NULL); // tuple of None's + PyObject *arg3 = Py_BuildValue("(OOO)", arg1, arg2, arg1); + + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg1)); + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg2)); + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg3)); + + Py_RETURN_NONE; +} + +/** + * @brief Tests _pg_is_int_tuple when passed a tuple of floats + */ +PG_CTEST(test__pg_is_int_tuple_floats)(PyObject *self, PyObject *_null) { + PyObject *arg1 = Py_BuildValue("(ddd)", 1.0, 2.0, 3.0); + PyObject *arg2 = Py_BuildValue("(ddd)", -1.1, -2.2, -3.3); + PyObject *arg3 = Py_BuildValue("(ddd)", 1.0, -2.0, -3.1); + + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg1)); + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg2)); + TEST_ASSERT_EQUAL(0, _pg_is_int_tuple(arg3)); + + Py_RETURN_NONE; +} + +/*=======Test Reset Option=====*/ +/* This must be void(void) */ +void resetTest(void) { + tearDown(); + setUp(); +} + +/*=======Exposed Test Reset Option=====*/ +static PyObject *reset_test(PyObject *self, PyObject *_null) { + resetTest(); + + Py_RETURN_NONE; +} + +/*=======Run The Tests=======*/ +static PyObject *run_tests(PyObject *self, PyObject *_null) { + UnityBegin("base_ctest.c"); + RUN_TEST_PG_INTERNAL(test__pg_is_int_tuple_nominal); + RUN_TEST_PG_INTERNAL(test__pg_is_int_tuple_failureModes); + RUN_TEST_PG_INTERNAL(test__pg_is_int_tuple_floats); + + return PyLong_FromLong(UnityEnd()); +} + +static PyMethodDef base_test_methods[] = { + {"test__pg_is_int_tuple_nominal", + (PyCFunction)test__pg_is_int_tuple_nominal, METH_NOARGS, + "Tests _pg_is_int_tuple when passed a tuple of ints"}, + {"test__pg_is_int_tuple_failureModes", + (PyCFunction)test__pg_is_int_tuple_failureModes, METH_NOARGS, + "Tests _pg_is_int_tuple when passed a tuple of non-numeric values"}, + {"test__pg_is_int_tuple_floats", (PyCFunction)test__pg_is_int_tuple_floats, + METH_NOARGS, "Tests _pg_is_int_tuple when passed a tuple of floats"}, + {"reset_test", (PyCFunction)reset_test, METH_NOARGS, + "Resets the test suite between tests, run_tests automatically calls this " + "after each test case it calls"}, + {"run_tests", (PyCFunction)run_tests, METH_NOARGS, + "Runs all the tests in this test wuite"}, + {NULL, NULL, 0, NULL}}; + +MODINIT_DEFINE(base_ctest) { + PyObject *module; + + static struct PyModuleDef _module = { + PyModuleDef_HEAD_INIT, + "base_ctest", + "C unit tests for the pygame.base internal implementation", + -1, + base_test_methods, + NULL, + NULL, + NULL, + NULL}; + + /* create the module */ + module = PyModule_Create(&_module); + if (!module) { + return NULL; + } + + return module; +} diff --git a/ctest/meson.build b/ctest/meson.build new file mode 100644 index 0000000000..aa961e79c6 --- /dev/null +++ b/ctest/meson.build @@ -0,0 +1,13 @@ +unity_subproject = subproject('unity') +unity_dependency = unity_subproject.get_variable('unity_dep') + +base_ctest = py.extension_module( + 'base_ctest', + 'base_ctest.c', + c_args: warnings_error, + dependencies: [pg_base_deps, unity_dependency], + sources: ['../src_c/base.c'], + install: true, + subdir: pg, + include_directories: ['../src_c'] +) diff --git a/ctest/test_common.h b/ctest/test_common.h new file mode 100644 index 0000000000..4442f6dce2 --- /dev/null +++ b/ctest/test_common.h @@ -0,0 +1,48 @@ +#include + +#include "unity.h" + +#ifndef TEST_COMMON_H +#define TEST_COMMON_H + +struct TestCase { + char *test_name; + int line_num; +}; + +/* + This will take some explanation... the PG_CTEST macro defines two things + for an individual test case. The test case itself, and a struct instance + called meta_TEST_CASE_NAME. The struct has two pieces of important + information that unity needs: the name in string format and the line + number of the test. This would be an absolute nighmare to maintain by + hand, so I defined a macro to do it automagically for us. + + The RUN_TEST_PG_INTERNAL macro then references that struct for each test + case that we tell it about and automatically populates the unity fields + with the requisite data. + + Note that the arguments to the test function must be *exactly* + (PyObject * self, PyObject * _null), but due to gcc throwing a fit, I + cannot just use token pasting to have the macro generate that part for me +*/ +#define PG_CTEST(TestFunc) \ + static struct TestCase meta_##TestFunc = {#TestFunc, __LINE__}; \ + static PyObject *TestFunc + +#define RUN_TEST_PG_INTERNAL(TestFunc) \ + { \ + Unity.CurrentTestName = meta_##TestFunc.test_name; \ + Unity.CurrentTestLineNumber = meta_##TestFunc.line_num; \ + Unity.NumberOfTests++; \ + if (TEST_PROTECT()) { \ + setUp(); \ + TestFunc(self, _null); \ + } \ + if (TEST_PROTECT()) { \ + tearDown(); \ + } \ + UnityConcludeTest(); \ + } + +#endif // #ifndef TEST_COMMON_H diff --git a/dev.py b/dev.py index 7ed720a134..50d4551eaf 100644 --- a/dev.py +++ b/dev.py @@ -32,6 +32,8 @@ ] COVERAGE_ARGS = ["-Csetup-args=-Dcoverage=true"] +CTEST_ARGS = ["-Csetup-args=-Dctest=true"] + # We assume this script works with any pip version above this. PIP_MIN_VERSION = "23.1" @@ -213,6 +215,7 @@ def cmd_build(self): stripped = self.args.get("stripped", False) sanitize = self.args.get("sanitize") coverage = self.args.get("coverage", False) + ctest = self.args.get("ctest", False) if wheel_dir and coverage: pprint("Cannot pass --wheel and --coverage together", Colors.RED) sys.exit(1) @@ -226,6 +229,8 @@ def cmd_build(self): build_suffix += "-sdl3" if coverage: build_suffix += "-cov" + if ctest: + build_suffix += "-ctest" install_args = [ "--no-build-isolation", f"-Cbuild-dir=.mesonpy-build{build_suffix}", @@ -255,12 +260,14 @@ def cmd_build(self): if coverage: install_args.extend(COVERAGE_ARGS) + if ctest: + install_args.extend(CTEST_ARGS) + if sanitize: install_args.append(f"-Csetup-args=-Db_sanitize={sanitize}") - info_str = ( - f"with {debug=}, {lax=}, {sdl3=}, {stripped=}, {coverage=} and {sanitize=}" - ) + info_str = f"with {debug=}, {lax=}, {sdl3=}, {stripped=}, {coverage=}, {ctest=}, and {sanitize=}" + if wheel_dir: pprint(f"Building wheel at '{wheel_dir}' ({info_str})") cmd_run( @@ -416,6 +423,9 @@ def parse_args(self): "supported if the underlying compiler supports the --coverage argument" ), ) + build_parser.add_argument( + "--ctest", action="store_true", help="Build the C-direct unit tests" + ) # Docs command docs_parser = subparsers.add_parser("docs", help="Generate docs") diff --git a/meson.build b/meson.build index 011ed5f06a..335abbac3f 100644 --- a/meson.build +++ b/meson.build @@ -452,4 +452,9 @@ if not get_option('stripped') subdir('buildconfig/stubs') install_subdir('examples', install_dir: pg_dir, install_tag: 'pg-tag') # TODO: install headers? not really important though + + if get_option('ctest') + subproject('unity') + subdir('ctest') + endif endif diff --git a/meson_options.txt b/meson_options.txt index e433b07f52..aec5f9ce4d 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -24,19 +24,23 @@ option('midi', type: 'feature', value: 'enabled') # Controls whether to make a "stripped" pygame install. Enabling this disables # the bundling of docs/examples/tests/stubs in the wheels. # The default behaviour is to bundle all of these. -option('stripped', type: 'boolean', value: 'false') +option('stripped', type: 'boolean', value: false) # Controls whether to compile with -Werror (or its msvc equivalent). The default # behaviour is to not do this by default -option('error_on_warns', type: 'boolean', value: 'false') +option('error_on_warns', type: 'boolean', value: false) # Controls whether to error on build if generated docs are missing. Defaults to # false. -option('error_docs_missing', type: 'boolean', value: 'false') +option('error_docs_missing', type: 'boolean', value: false) # Controls whether to do a coverage build. # This argument must be used together with the editable install. option('coverage', type: 'boolean', value: false) +# Controls whether to do to a C unit test build. Defaults to false. +# If "stripped" is true, this is ignored. +option('ctest', type: 'boolean', value: false) + # Controls whether to use SDL3 instead of SDL2. The default is to use SDL2 option('sdl_api', type: 'integer', min: 2, max: 3, value: 2) diff --git a/src_c/base.c b/src_c/base.c index c4f7597a4f..48b4daee7a 100644 --- a/src_c/base.c +++ b/src_c/base.c @@ -45,7 +45,7 @@ static int pg_is_init = 0; static bool pg_sdl_was_init = 0; SDL_Window *pg_default_window = NULL; pgSurfaceObject *pg_default_screen = NULL; -static int pg_env_blend_alpha_SDL2 = 0; +int pg_env_blend_alpha_SDL2 = 0; /* compare compiled to linked, raise python error on incompatibility */ int diff --git a/subprojects/unity.wrap b/subprojects/unity.wrap new file mode 100644 index 0000000000..440c66c6bc --- /dev/null +++ b/subprojects/unity.wrap @@ -0,0 +1,3 @@ +[wrap-git] +url = https://github.com/ThrowTheSwitch/Unity.git +revision = v2.6.1 \ No newline at end of file diff --git a/test/ctest_test.py b/test/ctest_test.py new file mode 100644 index 0000000000..73f30fdbfb --- /dev/null +++ b/test/ctest_test.py @@ -0,0 +1,17 @@ +import unittest + +base_ctest = None +try: + import pygame.base_ctest as base_ctest +except ModuleNotFoundError: + pass + + +class Ctest(unittest.TestCase): + @unittest.skipIf(base_ctest is None, "base_ctest not built") + def test_run_base_ctests(self): + self.assertEqual(base_ctest.run_tests(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/meson.build b/test/meson.build index 1e2cadfa7d..5ee558cc3e 100644 --- a/test/meson.build +++ b/test/meson.build @@ -9,6 +9,7 @@ test_files = files( 'color_test.py', 'constants_test.py', 'controller_test.py', + 'ctest_test.py', 'cursors_test.py', 'debug_test.py', 'display_test.py',