Skip to content

Commit bdd1a0c

Browse files
eduralphclaude
andcommitted
test: add shared addon test harness, plugin & dependency tests, make.py runner
Adds a repo-root test suite for addons, runnable via `make.py <series> test`: - tests/gramps_test_env.py — GrampsTestCase / GrampsDbTestCase base classes that boot the real Gramps plugin manager and registry once per process. - tests/test_plugin_registration.py — registers every addon through Gramps' PluginRegister, validates id/name/version and the target Gramps series, loads each addon in a crash-isolated subprocess, and smoke-tests import/export entry points. - tests/test_plugin_load_gate.py — regression test that the load check gates (self.fail) on a genuine load failure rather than only logging it. - tests/test_addon_dependency_loading.py — exercises Gramps' depends_on mechanism over the tree: every declared dependency resolves to a registered plugin, the dependency graph is acyclic, and each declared dependency is load-bearing — proven by an isolated subprocess import so sys.modules caching can't mask a missing/undeclared dependency. Verified against Gramps 6.0 and 6.1. - make.py — new `test` command that runs the suite via unittest discovery, gated on GRAMPSPATH like the other commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d3eeea1 commit bdd1a0c

5 files changed

Lines changed: 987 additions & 0 deletions

File tree

make.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
python3 make.py gramps60 extract-po
5050
Extracts strings from the aggregated `po/{lang}.po` files into the
5151
`{lang}-local.po` files for each addon.
52+
53+
GRAMPSPATH=path python3 make.py gramps60 test
54+
Runs the repo-root `tests/` suite (unittest discovery for
55+
`test_*.py`). GRAMPSPATH must point at a Gramps checkout so the
56+
addon plugins are importable. Exits non-zero on any failure.
5257
"""
5358
import shutil
5459
import glob
@@ -1073,5 +1078,16 @@ def register(ptype, **kwargs):
10731078
print(addon)
10741079
extract_po(addon)
10751080

1081+
elif command == "test":
1082+
check_gramps_path(command)
1083+
import unittest
1084+
1085+
suite = unittest.TestLoader().discover(
1086+
"tests", pattern="test_*.py", top_level_dir="."
1087+
)
1088+
result = unittest.TextTestRunner(verbosity=2).run(suite)
1089+
if not result.wasSuccessful():
1090+
exit(1)
1091+
10761092
else:
10771093
raise AttributeError("unknown command")

tests/gramps_test_env.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Eduard Ralph
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program; if not, write to the Free Software
18+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19+
#
20+
21+
"""
22+
Shared test infrastructure for Gramps addon integration tests.
23+
24+
Module-level initialisation puts the addons-source root on :data:`sys.path`
25+
and sets :envvar:`GRAMPS_RESOURCES` so ``gramps`` is importable. Helpers
26+
boot the Gramps plugin system once per process (registering plugins is
27+
expensive) and provide fresh in-memory databases on demand.
28+
29+
Usage — in any :class:`unittest.TestCase`::
30+
31+
from tests.gramps_test_env import GrampsTestCase
32+
33+
class MyPluginTest(GrampsTestCase):
34+
def test_registered(self) -> None:
35+
pdata = self.plugin_registry.get_plugin("im_sqz")
36+
self.assertIsNotNone(pdata)
37+
"""
38+
39+
# ------------------------
40+
# Python modules
41+
# ------------------------
42+
import os
43+
import shutil
44+
import sys
45+
import tempfile
46+
import unittest
47+
from typing import Any, ClassVar
48+
49+
# ------------------------
50+
# Path + environment bootstrap (runs at import)
51+
# ------------------------
52+
ADDONS_ROOT: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
53+
if ADDONS_ROOT not in sys.path:
54+
sys.path.insert(0, ADDONS_ROOT)
55+
56+
if "GRAMPS_RESOURCES" not in os.environ:
57+
try:
58+
import gramps # noqa: F401
59+
60+
os.environ["GRAMPS_RESOURCES"] = os.path.dirname(
61+
os.path.dirname(gramps.__file__)
62+
)
63+
except ImportError:
64+
pass
65+
66+
67+
# ------------------------
68+
# GTK availability
69+
# ------------------------
70+
def _has_gtk() -> bool:
71+
"""Return whether GTK 3.0 is importable on this host.
72+
73+
:returns: ``True`` if ``gi.repository.Gtk`` can be loaded, else ``False``.
74+
"""
75+
try:
76+
import gi
77+
78+
gi.require_version("Gtk", "3.0")
79+
from gi.repository import Gtk # noqa: F401
80+
81+
return True
82+
except (ImportError, ValueError):
83+
return False
84+
85+
86+
HAS_GTK: bool = _has_gtk()
87+
88+
89+
# ------------------------
90+
# Plugin-manager singleton
91+
# ------------------------
92+
_plugin_cache: dict[str, Any] = {}
93+
94+
95+
def get_plugin_manager_and_registry() -> tuple[Any, Any]:
96+
"""Return the Gramps plugin manager and registry, initialising on first call.
97+
98+
Registration scans every addon's ``.gpr.py`` and is expensive; the result
99+
is cached for the lifetime of the test process.
100+
101+
:returns: Tuple of ``(plugin_manager, plugin_registry)``.
102+
:rtype: tuple[:class:`BasePluginManager`, :class:`PluginRegister`]
103+
"""
104+
if "pmgr" not in _plugin_cache:
105+
from gramps.gen.const import PLUGINS_DIR
106+
from gramps.gen.plug import BasePluginManager, PluginRegister
107+
108+
pmgr = BasePluginManager.get_instance()
109+
pmgr.reg_plugins(PLUGINS_DIR, None, None)
110+
pmgr.reg_plugins(ADDONS_ROOT, None, None, load_on_reg=True)
111+
_plugin_cache["pmgr"] = pmgr
112+
_plugin_cache["registry"] = PluginRegister.get_instance()
113+
return _plugin_cache["pmgr"], _plugin_cache["registry"]
114+
115+
116+
def make_gramps_user() -> Any:
117+
"""Return a headless :class:`gramps.cli.user.User` for batch import/export.
118+
119+
:returns: A ``User`` configured with ``auto_accept=True`` and ``quiet=True``.
120+
"""
121+
from gramps.cli.user import User
122+
123+
return User(auto_accept=True, quiet=True)
124+
125+
126+
# ------------------------------------------------------------
127+
#
128+
# GrampsTestCase
129+
#
130+
# ------------------------------------------------------------
131+
class GrampsTestCase(unittest.TestCase):
132+
"""
133+
Base TestCase with lazy access to the Gramps plugin manager and registry.
134+
135+
Subclasses may override :meth:`setUp` / :meth:`tearDown` freely; the
136+
plugin registry is a class-level singleton so its cost is paid once.
137+
"""
138+
139+
plugin_manager: ClassVar[Any] = None
140+
plugin_registry: ClassVar[Any] = None
141+
142+
@classmethod
143+
def setUpClass(cls) -> None:
144+
super().setUpClass()
145+
cls.plugin_manager, cls.plugin_registry = get_plugin_manager_and_registry()
146+
147+
148+
# ------------------------------------------------------------
149+
#
150+
# GrampsDbTestCase
151+
#
152+
# ------------------------------------------------------------
153+
class GrampsDbTestCase(GrampsTestCase):
154+
"""
155+
Base class that also provisions a fresh in-memory SQLite Gramps DB per test.
156+
157+
The database is available as ``self.db``; ``setUp`` / ``tearDown`` handle
158+
creation and cleanup of the on-disk temp directory.
159+
"""
160+
161+
db: Any = None
162+
_tmpdir: str = ""
163+
164+
def setUp(self) -> None:
165+
super().setUp()
166+
from gramps.gen.db.utils import make_database
167+
168+
self._tmpdir = tempfile.mkdtemp(prefix="gramps_test_")
169+
self.db = make_database("sqlite")
170+
self.db.load(os.path.join(self._tmpdir, "test_db"), None)
171+
172+
def tearDown(self) -> None:
173+
try:
174+
self.db.close()
175+
except Exception:
176+
pass
177+
shutil.rmtree(self._tmpdir, ignore_errors=True)
178+
super().tearDown()

0 commit comments

Comments
 (0)