|
| 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 | +Regression test for Mantis bug 14051. |
| 23 | +
|
| 24 | +`DetailedDescendantBookReport.append_event` reads |
| 25 | +``self.report_app_ref[self.phandle][0]`` to build the "Ref: ..." text |
| 26 | +that prefixes each index-of-dates / index-of-places entry. The lookup |
| 27 | +table ``report_app_ref`` is only filled by the up-front reference pass |
| 28 | +guarded by ``if self.dubperson:`` (the "omit duplicate ancestors" |
| 29 | +option). When that option is unselected but Index of Dates or Index of |
| 30 | +Places is enabled, ``append_event`` still runs from |
| 31 | +``write_person_info`` / ``__write_family_events`` and raises against |
| 32 | +the empty / missing table — historically an ``AttributeError`` (before |
| 33 | +the partial fix for bugs 12857/12859 unconditionally initialised the |
| 34 | +dict), now a ``KeyError`` on the current handle. |
| 35 | +
|
| 36 | +This test exercises ``append_event`` in isolation with the settings |
| 37 | +combination from bug 14051 (``dubperson=False`` + indexes enabled) and |
| 38 | +asserts it completes without raising and that the indexes receive an |
| 39 | +entry referencing the current writing-pass context. |
| 40 | +""" |
| 41 | + |
| 42 | +import os |
| 43 | +import sys |
| 44 | +import types |
| 45 | +import unittest |
| 46 | +from unittest import mock |
| 47 | + |
| 48 | +# Make the addon importable from its parent directory, matching the |
| 49 | +# JSON / TMGimporter / Form test convention. Required when this test |
| 50 | +# is loaded by its dotted path (`DescendantBooks.tests.<module>`). |
| 51 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 52 | + |
| 53 | +# Stub RunReport so importing DetailedDescendantBookReport does not pull |
| 54 | +# in gramps.gui (which requires gi.require_version('Gtk', '4.0') and a |
| 55 | +# display). The append_event method does not touch the report dialog. |
| 56 | +_run_report_stub = types.ModuleType("RunReport") |
| 57 | +_run_report_stub.RunReport = lambda *a, **kw: None |
| 58 | +sys.modules.setdefault("RunReport", _run_report_stub) |
| 59 | + |
| 60 | +import DetailedDescendantBookReport as ddbr |
| 61 | + |
| 62 | + |
| 63 | +def _make_event(year, place_handle=""): |
| 64 | + """Build a stub event_ref / event pair usable by append_event.""" |
| 65 | + date_obj = mock.MagicMock() |
| 66 | + date_obj.get_year.return_value = year |
| 67 | + |
| 68 | + event = mock.MagicMock() |
| 69 | + event.get_date_object.return_value = date_obj |
| 70 | + event.get_place_handle.return_value = place_handle |
| 71 | + event.get_type.return_value = "Birth" |
| 72 | + |
| 73 | + event_ref = mock.MagicMock() |
| 74 | + event_ref.ref = "EVENT-HANDLE" |
| 75 | + return event_ref, event, date_obj |
| 76 | + |
| 77 | + |
| 78 | +class TestAppendEventWithoutOmitDuplicates(unittest.TestCase): |
| 79 | + """append_event must not crash when the reference pre-pass is skipped. |
| 80 | +
|
| 81 | + Bug 14051: with ``Omit duplicate ancestors`` off and an index option |
| 82 | + on, the pre-pass under ``if self.dubperson:`` never populates |
| 83 | + ``report_app_ref``, but ``append_event`` is still invoked for every |
| 84 | + event because the indexes are enabled. |
| 85 | + """ |
| 86 | + |
| 87 | + def _make_report(self): |
| 88 | + # Build a DetailedDescendantBookReport without calling __init__ |
| 89 | + # (the real __init__ takes options/user/database and constructs |
| 90 | + # a Bibliography etc., none of which append_event uses). |
| 91 | + report = ddbr.DetailedDescendantBookReport.__new__( |
| 92 | + ddbr.DetailedDescendantBookReport |
| 93 | + ) |
| 94 | + |
| 95 | + # Minimum attributes append_event reads. Mirrors what |
| 96 | + # write_report would set up before invoking the writing pass. |
| 97 | + report.report_app_ref = {} # the bug's empty table |
| 98 | + report.index_of_dates = {} |
| 99 | + report.index_of_places = {} |
| 100 | + report.dnumber = {"P1": "1"} |
| 101 | + report.dmates = {} |
| 102 | + report.report_count = 3 # third ascendant tree |
| 103 | + report.generation = 1 # second generation in it |
| 104 | + report.phandle = "P1" |
| 105 | + report.inc_index_of_dates = True |
| 106 | + report.inc_index_of_places = True |
| 107 | + |
| 108 | + # _ is the report's translation callable; sgettext-style — just |
| 109 | + # return the string verbatim so we can assert on it. |
| 110 | + report._ = lambda s: s |
| 111 | + # _get_date / _get_type return strings; stub to identity-like. |
| 112 | + report._get_date = lambda d: "1850" |
| 113 | + report._get_type = lambda t: str(t) |
| 114 | + |
| 115 | + # Stub the database so place lookups return a known title. |
| 116 | + place = mock.MagicMock() |
| 117 | + place.get_title.return_value = "Dublin" |
| 118 | + db = mock.MagicMock() |
| 119 | + db.get_event_from_handle.return_value = None # set per-test |
| 120 | + db.get_place_from_handle.return_value = place |
| 121 | + db.get_person_from_handle.return_value = mock.MagicMock( |
| 122 | + get_primary_name=lambda: mock.MagicMock(get_name=lambda: "John Doe") |
| 123 | + ) |
| 124 | + report.database = db |
| 125 | + return report |
| 126 | + |
| 127 | + def test_append_event_does_not_crash_without_prepopulated_table(self): |
| 128 | + """append_event with an empty report_app_ref must not raise. |
| 129 | +
|
| 130 | + Failure pre-fix: raises ``KeyError: 'P1'`` (or, on addon versions |
| 131 | + prior to the partial fix for 12857/12859, ``AttributeError`` on |
| 132 | + ``report_app_ref``) — the exact crash signature from bug 14051. |
| 133 | + """ |
| 134 | + report = self._make_report() |
| 135 | + event_ref, event, _ = _make_event(year=1850, place_handle="PLACE-HANDLE") |
| 136 | + report.database.get_event_from_handle.return_value = event |
| 137 | + |
| 138 | + # This is the call the report system makes from |
| 139 | + # write_person_info when (inc_index_of_dates or inc_index_of_places) |
| 140 | + # is enabled. It must not raise. |
| 141 | + report.append_event(event_ref) |
| 142 | + |
| 143 | + # The indexes should have been populated with an entry tagged |
| 144 | + # with the writing-pass coordinates at the FIRST encounter of |
| 145 | + # this handle — (report_count=3, generation+1=2, |
| 146 | + # dnumber[phandle]=1) — matching the `[0]` semantic the |
| 147 | + # omit-duplicates path emits (see |
| 148 | + # TestRefSemanticsParityWithOmitDuplicates below). |
| 149 | + self.assertIn(1850, report.index_of_dates, |
| 150 | + "Year 1850 should be indexed") |
| 151 | + self.assertIn("Dublin", report.index_of_places, |
| 152 | + "Place 'Dublin' should be indexed") |
| 153 | + |
| 154 | + date_entries = list(report.index_of_dates[1850].values()) |
| 155 | + self.assertTrue(any("Ref: 3 2 1" in entry for entry in date_entries), |
| 156 | + "Index entry should reference first-encounter " |
| 157 | + "coordinates (3 2 1); got %r" % date_entries) |
| 158 | + |
| 159 | + def test_append_event_for_mate_without_dnumber(self): |
| 160 | + """A mate's event where the mate is not in dnumber must not crash. |
| 161 | +
|
| 162 | + ``__write_mate`` sets ``self.phandle = mate_handle`` when the |
| 163 | + mate is being printed in full; the mate may not have a |
| 164 | + ``dnumber`` entry (only descendants do). append_event must |
| 165 | + still produce an entry with a sensible fallback Ref rather |
| 166 | + than KeyError on ``self.dnumber[mate_handle]``. |
| 167 | + """ |
| 168 | + report = self._make_report() |
| 169 | + report.phandle = "MATE-NOT-IN-DNUMBER" |
| 170 | + # dmates is empty in __write_mate's Branch A (the not-inc_materef |
| 171 | + # path that sets phandle = mate_handle). Leave it that way. |
| 172 | + event_ref, event, _ = _make_event(year=1860) |
| 173 | + report.database.get_event_from_handle.return_value = event |
| 174 | + |
| 175 | + report.append_event(event_ref) |
| 176 | + |
| 177 | + # The index should still receive an entry; the Ref's "per" field |
| 178 | + # falls back gracefully when the mate's dnumber isn't known. |
| 179 | + self.assertIn(1860, report.index_of_dates) |
| 180 | + |
| 181 | + |
| 182 | +class TestRefSemanticsParityWithOmitDuplicates(unittest.TestCase): |
| 183 | + """When the same person/event is processed in multiple per-ascendant |
| 184 | + reports (the dubperson=False case with multi-ascendant trees), the |
| 185 | + index entry for that event must resolve to the SAME (repno, gen, |
| 186 | + per) coordinates that the omit-duplicates path would emit — i.e. |
| 187 | + the FIRST encounter's coordinates. Otherwise the dubperson-on and |
| 188 | + dubperson-off documents' index entries point to different sections |
| 189 | + of their respective documents for the same event. |
| 190 | +
|
| 191 | + This test simulates two encounters of person P in reports 1 and 2 |
| 192 | + against append_event, and compares the resulting index Ref to what |
| 193 | + the dubperson-on path's `[0]` read of report_app_ref would have |
| 194 | + produced (the first encounter). |
| 195 | + """ |
| 196 | + |
| 197 | + def _make_report(self): |
| 198 | + report = ddbr.DetailedDescendantBookReport.__new__( |
| 199 | + ddbr.DetailedDescendantBookReport |
| 200 | + ) |
| 201 | + report.report_app_ref = {} |
| 202 | + report.index_of_dates = {} |
| 203 | + report.index_of_places = {} |
| 204 | + report.dnumber = {} |
| 205 | + report.dmates = {} |
| 206 | + report.inc_index_of_dates = True |
| 207 | + report.inc_index_of_places = True |
| 208 | + report._ = lambda s: s |
| 209 | + report._get_date = lambda d: "1850-01-15" |
| 210 | + report._get_type = lambda t: str(t) |
| 211 | + place = mock.MagicMock() |
| 212 | + place.get_title.return_value = "Dublin" |
| 213 | + db = mock.MagicMock() |
| 214 | + db.get_place_from_handle.return_value = place |
| 215 | + db.get_person_from_handle.return_value = mock.MagicMock( |
| 216 | + get_primary_name=lambda: mock.MagicMock(get_name=lambda: "John Doe") |
| 217 | + ) |
| 218 | + report.database = db |
| 219 | + return report |
| 220 | + |
| 221 | + def _make_event(self, year=1850, place_handle="PLACE-DUBLIN"): |
| 222 | + date_obj = mock.MagicMock() |
| 223 | + date_obj.get_year.return_value = year |
| 224 | + event = mock.MagicMock() |
| 225 | + event.get_date_object.return_value = date_obj |
| 226 | + event.get_place_handle.return_value = place_handle |
| 227 | + event.get_type.return_value = "Birth" |
| 228 | + event_ref = mock.MagicMock() |
| 229 | + event_ref.ref = "EVENT-HANDLE" |
| 230 | + return event_ref, event |
| 231 | + |
| 232 | + def test_dubperson_on_baseline_ref_is_first_encounter(self): |
| 233 | + """Baseline: with the pre-pass populated as it is when |
| 234 | + omit-duplicates is on, append_event reads `[0]` — the first |
| 235 | + encounter — and writes that Ref into the index.""" |
| 236 | + report = self._make_report() |
| 237 | + # Simulate the dubperson-on pre-pass having populated |
| 238 | + # report_app_ref for person P with the first encounter |
| 239 | + # (report=1, generation+1=2, dnumber["P"]="3"). |
| 240 | + report.report_app_ref["P"] = [(1, 2, "3", False, "John Doe")] |
| 241 | + report.report_count = 1 |
| 242 | + report.generation = 1 |
| 243 | + report.dnumber = {"P": "3"} |
| 244 | + report.phandle = "P" |
| 245 | + event_ref, event = self._make_event() |
| 246 | + report.database.get_event_from_handle.return_value = event |
| 247 | + report.append_event(event_ref) |
| 248 | + |
| 249 | + # The index Ref should reference the first encounter (1, 2, 3). |
| 250 | + entries = list(report.index_of_places["Dublin"].values()) |
| 251 | + self.assertTrue(any("Ref: 1 2 3" in e for e in entries), |
| 252 | + "dubperson-on baseline must yield Ref '1 2 3'; " |
| 253 | + "got %r" % entries) |
| 254 | + |
| 255 | + def test_dubperson_off_multi_encounter_matches_first_encounter(self): |
| 256 | + """The fallback path (omit-duplicates OFF; report_app_ref |
| 257 | + un-pre-populated) must produce the SAME Ref as the |
| 258 | + omit-duplicates-on baseline above — i.e. the FIRST encounter's |
| 259 | + coordinates — even when the same person/event is processed |
| 260 | + again in a later report.""" |
| 261 | + report = self._make_report() |
| 262 | + event_ref, event = self._make_event() |
| 263 | + report.database.get_event_from_handle.return_value = event |
| 264 | + |
| 265 | + # First encounter in report 1 (P is generation 1 → gen+1=2, |
| 266 | + # dnumber 3 within report 1). |
| 267 | + report.report_count = 1 |
| 268 | + report.generation = 1 |
| 269 | + report.dnumber = {"P": "3"} |
| 270 | + report.phandle = "P" |
| 271 | + report.append_event(event_ref) |
| 272 | + |
| 273 | + # Second encounter in report 2 (DIFFERENT coordinates: P is |
| 274 | + # generation 3 → gen+1=4, dnumber 5 within report 2). |
| 275 | + report.report_count = 2 |
| 276 | + report.generation = 3 |
| 277 | + report.dnumber = {"P": "5"} |
| 278 | + report.phandle = "P" |
| 279 | + report.append_event(event_ref) |
| 280 | + |
| 281 | + # The index entry overwrites on each call. The surviving Ref |
| 282 | + # must point at the FIRST encounter (1, 2, 3) — matching the |
| 283 | + # dubperson-on baseline — NOT the second (2, 4, 5). |
| 284 | + entries = list(report.index_of_places["Dublin"].values()) |
| 285 | + self.assertTrue(any("Ref: 1 2 3" in e for e in entries), |
| 286 | + "Multi-encounter index Ref must match the " |
| 287 | + "first-encounter coordinates the dubperson-on " |
| 288 | + "path would emit; got %r" % entries) |
| 289 | + self.assertFalse(any("Ref: 2 4 5" in e for e in entries), |
| 290 | + "Multi-encounter index Ref must NOT have " |
| 291 | + "drifted to the last encounter's coordinates; " |
| 292 | + "got %r" % entries) |
| 293 | + |
| 294 | + |
| 295 | +if __name__ == "__main__": |
| 296 | + unittest.main() |
0 commit comments