|
| 1 | +# |
| 2 | +# Gramps - a GTK+/GNOME based genealogy program |
| 3 | +# |
| 4 | +# Copyright (C) 2026 Gramps Development Team |
| 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 | +Repro-or-close test for bug 13420: Text Import gramplet death event |
| 23 | +is not shown as the person's death fallback until re-validated in the |
| 24 | +editor. |
| 25 | +
|
| 26 | +The reporter's input was XML produced by a no-code Android tool with |
| 27 | +UUID handles and longer-than-normal change-times; no developer |
| 28 | +reproduced the symptom on conformant Gramps XML. Per the triage |
| 29 | +verdict in `triage/batches/batch-03-confirmed-velocity/issue_13420.md` |
| 30 | +the decisive test is: |
| 31 | +
|
| 32 | + - Build CONFORMANT minimal Gramps XML (one person, Birth + Death |
| 33 | + events both role Primary, standard Gramps handles) |
| 34 | + - Import via the addon's `AtomicGrampsParser` (the wrapper the |
| 35 | + ImportGramplet uses for its XML branch) |
| 36 | + - Assert person.get_death_ref() returns the Death event ref |
| 37 | + WITHOUT manual editor re-save |
| 38 | +
|
| 39 | +If clean XML imports correctly → reporter's XML was malformed; close |
| 40 | +13420 as cannot-reproduce. If clean XML still fails → real addon bug, |
| 41 | +fix needed. |
| 42 | +
|
| 43 | +The fallback-setting logic lives in gramps CORE at |
| 44 | +`gramps/plugins/importer/importxml.py:1434-1473` (`start_eventref`) |
| 45 | +and is shared between the standard XML import and AtomicGrampsParser |
| 46 | +(AtomicGrampsParser only overrides `parse()` for an atomic DbTxn |
| 47 | +wrapper, not the per-element handlers). The note above |
| 48 | +`start_eventref` itself spells out the precondition: "We count here |
| 49 | +on events being already parsed prior to parsing people or families. |
| 50 | +This code will fail if this is not true." So the test fixture lists |
| 51 | +events BEFORE people. |
| 52 | +""" |
| 53 | + |
| 54 | +import os |
| 55 | +import shutil |
| 56 | +import sys |
| 57 | +import tempfile |
| 58 | +import unittest |
| 59 | + |
| 60 | +# AtomicGrampsParser pulls `from gi.repository import Gtk` at module |
| 61 | +# load (via ImportGramplet.py). Pin Gtk to 3.0 before importing; skip |
| 62 | +# cleanly if PyGObject / GTK 3 aren't available. |
| 63 | +try: |
| 64 | + import gi |
| 65 | + |
| 66 | + gi.require_version("Gtk", "3.0") |
| 67 | + gi.require_version("Gdk", "3.0") |
| 68 | +except (ImportError, ValueError) as err: |
| 69 | + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) |
| 70 | + |
| 71 | +# Make sure addon modules are importable from the parent directory. |
| 72 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 73 | + |
| 74 | + |
| 75 | +# Minimal CONFORMANT Gramps XML. Events listed before people, both |
| 76 | +# events role Primary, standard Gramps handles (no UUIDs). Mirrors |
| 77 | +# the structure in `example/gramps/example.gramps`. |
| 78 | +CLEAN_XML = """<?xml version="1.0" encoding="UTF-8"?> |
| 79 | +<!DOCTYPE database PUBLIC "-//Gramps//DTD Gramps XML 1.7.2//EN" |
| 80 | +"http://gramps-project.org/xml/1.7.2/grampsxml.dtd"> |
| 81 | +<database xmlns="http://gramps-project.org/xml/1.7.2/"> |
| 82 | + <header> |
| 83 | + <created date="2026-05-25" version="6.0.0"/> |
| 84 | + <researcher> |
| 85 | + <resname>Bug 13420 Test</resname> |
| 86 | + </researcher> |
| 87 | + </header> |
| 88 | + <events> |
| 89 | + <event handle="_a5af0eb667015e355db" change="1284030602" id="E00001"> |
| 90 | + <type>Birth</type> |
| 91 | + <dateval val="1900-01-01"/> |
| 92 | + <description>Birth of Test, Person</description> |
| 93 | + </event> |
| 94 | + <event handle="_a5af0eb696917232725" change="1284030602" id="E00002"> |
| 95 | + <type>Death</type> |
| 96 | + <dateval val="1970-12-31"/> |
| 97 | + <description>Death of Test, Person</description> |
| 98 | + </event> |
| 99 | + </events> |
| 100 | + <people> |
| 101 | + <person handle="_aaa0bbb111ccc222ddd" change="1284030602" id="I00001"> |
| 102 | + <gender>M</gender> |
| 103 | + <name type="Birth Name"> |
| 104 | + <first>Person</first> |
| 105 | + <surname>Test</surname> |
| 106 | + </name> |
| 107 | + <eventref hlink="_a5af0eb667015e355db" role="Primary"/> |
| 108 | + <eventref hlink="_a5af0eb696917232725" role="Primary"/> |
| 109 | + </person> |
| 110 | + </people> |
| 111 | +</database> |
| 112 | +""" |
| 113 | + |
| 114 | + |
| 115 | +class TestImportGrampletDeathFallback(unittest.TestCase): |
| 116 | + """Repro-or-close for bug 13420.""" |
| 117 | + |
| 118 | + def setUp(self): |
| 119 | + # Per Sqlite/tests/test_sqlite.py: create a sqlite in-memory- |
| 120 | + # equivalent db in a tempdir, import the XML, exercise. |
| 121 | + from gramps.gen.db.utils import make_database # pylint: disable=import-outside-toplevel |
| 122 | + |
| 123 | + self.db_dir = tempfile.mkdtemp(prefix="bug13420_") |
| 124 | + self.database = make_database("sqlite") |
| 125 | + self.database.load(self.db_dir) |
| 126 | + |
| 127 | + def tearDown(self): |
| 128 | + try: |
| 129 | + self.database.close() |
| 130 | + except Exception: # pylint: disable=broad-except |
| 131 | + pass |
| 132 | + shutil.rmtree(self.db_dir, ignore_errors=True) |
| 133 | + |
| 134 | + def test_clean_xml_imports_set_death_ref_via_atomic_parser(self): |
| 135 | + """Clean XML through AtomicGrampsParser must set the |
| 136 | + person's death_ref to the Death event. |
| 137 | +
|
| 138 | + This is the repro-or-close decision point for bug 13420. |
| 139 | + If it passes, the reporter's malformed XML (UUID handles) |
| 140 | + was the cause; if it fails, the addon's atomic parser is |
| 141 | + skipping the fallback wiring. |
| 142 | + """ |
| 143 | + from io import BytesIO # pylint: disable=import-outside-toplevel |
| 144 | + import time # pylint: disable=import-outside-toplevel |
| 145 | + from gramps.cli.user import User # pylint: disable=import-outside-toplevel |
| 146 | + |
| 147 | + # Importing the addon transitively imports Gtk -- this is why |
| 148 | + # the test is gated above on gi.require_version("Gtk", "3.0"). |
| 149 | + from ImportGramplet.ImportGramplet import AtomicGrampsParser # pylint: disable=import-outside-toplevel |
| 150 | + |
| 151 | + user = User() |
| 152 | + change = int(time.time()) |
| 153 | + parser = AtomicGrampsParser(self.database, user, change) |
| 154 | + ifile = BytesIO(CLEAN_XML.encode("utf-8")) |
| 155 | + parser.parse(ifile) |
| 156 | + |
| 157 | + person = self.database.get_person_from_gramps_id("I00001") |
| 158 | + self.assertIsNotNone( |
| 159 | + person, |
| 160 | + "Person I00001 must exist after XML import", |
| 161 | + ) |
| 162 | + |
| 163 | + death_ref = person.get_death_ref() |
| 164 | + self.assertIsNotNone( |
| 165 | + death_ref, |
| 166 | + "Person.get_death_ref() must return the Death event ref " |
| 167 | + "after clean-XML import via AtomicGrampsParser. If this " |
| 168 | + "assertion fails, bug 13420 reproduces on conformant XML " |
| 169 | + "and is a real addon defect.", |
| 170 | + ) |
| 171 | + |
| 172 | + death_event = self.database.get_event_from_handle(death_ref.ref) |
| 173 | + self.assertIsNotNone(death_event) |
| 174 | + self.assertEqual(str(death_event.get_type()), "Death") |
| 175 | + |
| 176 | + # Birth ref must also be wired up -- same logic in core |
| 177 | + # start_eventref. If birth is fine but death is not, the bug |
| 178 | + # is type-specific; if both fail, it's the whole fallback |
| 179 | + # path. |
| 180 | + birth_ref = person.get_birth_ref() |
| 181 | + self.assertIsNotNone(birth_ref) |
| 182 | + birth_event = self.database.get_event_from_handle(birth_ref.ref) |
| 183 | + self.assertEqual(str(birth_event.get_type()), "Birth") |
| 184 | + |
| 185 | + |
| 186 | +if __name__ == "__main__": |
| 187 | + unittest.main() |
0 commit comments