From 1de7cdde97576079430200f6c7bc9da68658f4e2 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:34:08 +0000 Subject: [PATCH 1/3] test: fail instead of skipping ui tests when they are required --- test/tkinter_floor_test.py | 6 +-- test/tkinter_generator_test.py | 19 +--------- test/tkinter_teleport_room_test.py | 6 +-- test/ui_test_support.py | 60 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 test/ui_test_support.py diff --git a/test/tkinter_floor_test.py b/test/tkinter_floor_test.py index 5904b5e..d5c8dfb 100644 --- a/test/tkinter_floor_test.py +++ b/test/tkinter_floor_test.py @@ -1,6 +1,5 @@ """Unit tests for the TkinterFloor class.""" -import importlib.util import unittest import numpy @@ -8,11 +7,10 @@ from floors.floor import Floor from generators.generator import Generator from rooms.teleport_room import TeleportRoom +from ui_test_support import requires_tkinter -_HAS_TKINTER = importlib.util.find_spec("tkinter") is not None - -@unittest.skipUnless(_HAS_TKINTER, "tkinter is not available") +@requires_tkinter() class TkinterFloorTest(unittest.TestCase): def setUp(self) -> None: # pylint: disable=import-outside-toplevel diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index a584e0a..8478b1b 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -1,30 +1,15 @@ """Unit tests for the TkinterGenerator class.""" -import importlib.util import tempfile import time import unittest from unittest import mock +from ui_test_support import requires_display from utils import globals as my_globals -_HAS_TKINTER = importlib.util.find_spec("tkinter") is not None - -def _has_display() -> bool: - if not _HAS_TKINTER: - return False - # pylint: disable=import-outside-toplevel - import tkinter - - try: - tkinter.Tk().destroy() - except tkinter.TclError: - return False - return True - - -@unittest.skipUnless(_has_display(), "tkinter or a display is not available") +@requires_display() class TkinterGeneratorTest(unittest.TestCase): def setUp(self) -> None: # pylint: disable=import-outside-toplevel diff --git a/test/tkinter_teleport_room_test.py b/test/tkinter_teleport_room_test.py index aaff632..c753596 100644 --- a/test/tkinter_teleport_room_test.py +++ b/test/tkinter_teleport_room_test.py @@ -1,14 +1,12 @@ """Unit tests for the TkinterTeleportRoom class.""" -import importlib.util import unittest +from ui_test_support import requires_tkinter from utils.room_type import RoomType -_HAS_TKINTER = importlib.util.find_spec("tkinter") is not None - -@unittest.skipUnless(_HAS_TKINTER, "tkinter is not available") +@requires_tkinter() class TkinterTeleportRoomTest(unittest.TestCase): def setUp(self) -> None: # pylint: disable=import-outside-toplevel diff --git a/test/ui_test_support.py b/test/ui_test_support.py new file mode 100644 index 0000000..80b1dd4 --- /dev/null +++ b/test/ui_test_support.py @@ -0,0 +1,60 @@ +""" +Helpers to skip the UI tests when tkinter or a display is not available. + +Set the environment variable REQUIRE_UI_TESTS=1 to make these tests fail +instead of being skipped, e.g. in CI where they must always run. +""" + +import importlib.util +import os +import unittest +from typing import Callable, TypeVar + +T = TypeVar("T") + +_REQUIRED = os.environ.get("REQUIRE_UI_TESTS") == "1" + + +def has_tkinter() -> bool: + """ + Returns if tkinter can be imported. + """ + return importlib.util.find_spec("tkinter") is not None + + +def has_display() -> bool: + """ + Returns if tkinter can open a window. + """ + if not has_tkinter(): + return False + # pylint: disable=import-outside-toplevel + import tkinter + + try: + tkinter.Tk().destroy() + except tkinter.TclError: + return False + return True + + +def _require(available: bool, reason: str) -> Callable[[T], T]: + if available: + return lambda test: test + if _REQUIRED: + raise RuntimeError(f"UI tests are required but {reason}") + return unittest.skip(reason) + + +def requires_tkinter() -> Callable[[T], T]: + """ + Skips the decorated tests if tkinter is not available. + """ + return _require(has_tkinter(), "tkinter is not available") + + +def requires_display() -> Callable[[T], T]: + """ + Skips the decorated tests if tkinter cannot open a window. + """ + return _require(has_display(), "tkinter or a display is not available") From fd2170f33c11052fcda83dfc92c2f98d1d03dd08 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:34:15 +0000 Subject: [PATCH 2/3] chore(ci): run the ui tests with xvfb --- .github/workflows/ci.yaml | 7 ++++++- readme.md | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea2ed6e..496d18b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,8 +20,13 @@ jobs: - name: Install dependencies run: pip install -r requirements-dev.txt + - name: Install Xvfb for the UI tests + run: sudo apt-get update && sudo apt-get install -y xvfb + - name: Run tests - run: pytest + run: xvfb-run -a pytest + env: + REQUIRE_UI_TESTS: "1" lint: runs-on: ubuntu-latest diff --git a/readme.md b/readme.md index b7c1503..524c7ca 100644 --- a/readme.md +++ b/readme.md @@ -168,5 +168,6 @@ pip install -r requirements-dev.txt | Type checks | `mypy` | The UI tests are skipped when no display is available. On Linux they can be run with `xvfb-run -a pytest`. +CI sets `REQUIRE_UI_TESTS=1`, which makes them fail instead of being skipped. Pull requests target the `dev` branch. From 00c695ef7bfbf5b69d0d2a9dde522baab2e1ceb0 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:34:20 +0000 Subject: [PATCH 3/3] chore(renovate): replace the deprecated base preset --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index 1bc58cd..b6eb8bc 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:recommended" ], "rebaseWhen": "conflicted", "labels": [