From 7941194b40d5403d2a07a7cf6af8cd24a4311a70 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Fri, 17 Apr 2026 19:15:34 +0200 Subject: [PATCH 1/4] Fix CI failures: lint, compile, addon structure, Windows install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves all failing CI jobs in one pass so the pipeline can go green. CI config: - Compile Check: add `shell: bash` (default `sh -e` can't parse the bash process substitution) - Windows: switch to conda environment-file so mamba actually installs Gramps + deps into `addons-ci` (the previous `mamba install` step silently no-op'd). Adds a Verify step that prints `mamba list` and imports the deps to fail fast if they aren't there. Addon structure: - Create po/template.pot stubs for AnniversariesGramplet, ArchiveAssist, GrampsChat, GrampyScript — they had no po/ dir at all. Lint (79 → 0 ruff errors across 29 files): - Missing imports: WindowActiveError, ErrorDialog, DbTxn, EditDate, ReportError, EventType, Surname, Any, display_help, reduce, time, sys, Errors - 3× `os.name is 'nt'` → `== 'nt'`; tuple-in-if bug in LifeLineChartView - Removed dead code after `return` in AttachSourceTool; broken stub line in SourceIndex/index.py - Python 2 leftovers: `except E(msg)` → `except E as msg`, `unicode()` → `str()`, removed `basestring`/`reload` branches - Added `_` translation in MongoDB and libaccess; fixed broken lambda missing `value` param in libaccess - Renamed SurnameMappingGramplet.grp.py → .gpr.py (typo — file was never loaded by Gramps, which only recognizes .gpr.py) - Fixed `print(...).format(...)` missing close paren in QueryQuickview - Dropped invalid `parent=self.uistate.window` at module level in lxmlGramplet (no self exists outside instance methods) - Fixed `displayer.display(...)` → `name_displayer.display(...)` in QuiltView (matches the import alias) - Initialized `tmg_dataset = None` in TMGimporter so ruff can prove the `locals()` guard isn't needed - Fixed `LOG.warn("ignored: " + data)` → `+ line` in JSONImport (wrong var — would crash if it ever hit that branch) Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 12 ++++++++---- .github/workflows/environment.yml | 11 +++++++++++ AnniversariesGramplet/AnniversariesGramplet.py | 1 + AnniversariesGramplet/po/template.pot | 18 ++++++++++++++++++ ArchiveAssist/po/template.pot | 18 ++++++++++++++++++ AttachSourceTool/AttachSourceTool.py | 3 --- CombinedView/combinedview.py | 4 +++- DenominoViso/DenominoViso.py | 1 + DescendantBooks/RunReport.py | 1 + DescendantsLines/ft.py | 1 + DynamicWeb/dynamicweb.py | 9 ++------- DynamicWeb/run_dynamicweb.py | 3 --- EditExifMetadata/editexifmetadata.py | 2 +- Form/selectform.py | 1 + GrampsChat/po/template.pot | 18 ++++++++++++++++++ GrampsWebSync/diffhandler.py | 1 + GrampsWebSync/grampswebsync.py | 1 + GrampyScript/po/template.pot | 18 ++++++++++++++++++ JSON/JSONImport.py | 2 +- LifeLineChartView/lifelinechart.py | 4 ++-- LinesOfDescendency/lines-of-descendency.py | 1 + MongoDB/mongodb.py | 1 + PersonEverything/PersonEverything.py | 1 + PlaceUpdate/PlaceUpdate.py | 1 + PostgreSQLEnhanced/postgresqlenhanced.py | 1 + Query/QueryGramplet.py | 1 + Query/QueryQuickview.py | 2 +- QuiltView/QuiltView.py | 6 +++--- RelID/relation_tab.py | 2 +- SourceIndex/birth.py | 4 ++-- SourceIndex/index.py | 1 - SourceReferences/SourceReferences.py | 1 + ...et.grp.py => SurnameMappingGramplet.gpr.py} | 0 .../SurnameMappingGramplet.py | 10 +++++----- TMGimporter/libtmg.py | 3 ++- TimelinePedigreeView/TimelinePedigreeView.py | 2 +- libaccess/libaccess.py | 6 ++++-- lxml/lxmlGramplet.py | 8 ++++---- 38 files changed, 137 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/environment.yml create mode 100644 AnniversariesGramplet/po/template.pot create mode 100644 ArchiveAssist/po/template.pot create mode 100644 GrampsChat/po/template.pot create mode 100644 GrampyScript/po/template.pot rename SurnameMappingGramplet/{SurnameMappingGramplet.grp.py => SurnameMappingGramplet.gpr.py} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b7db05b9..3446cb27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,7 @@ jobs: - uses: actions/checkout@v4 - name: Compile all Python files (excluding .gpr.py) + shell: bash run: | failed=0 while IFS= read -r f; do @@ -126,12 +127,15 @@ jobs: uses: conda-incubator/setup-miniconda@v3 with: miniforge-version: latest - python-version: "3.12" activate-environment: addons-ci - channels: conda-forge + environment-file: .github/workflows/environment.yml + use-mamba: true - - name: Install Gramps + test deps - run: mamba install -y "gramps>=6.0,<6.1" pygobject gtk3 orjson pytest dbf + - name: Verify environment + run: | + mamba info + mamba list | head -30 + python -c "import pytest, gramps, gi; print('deps OK')" - name: Run per-addon unit tests env: diff --git a/.github/workflows/environment.yml b/.github/workflows/environment.yml new file mode 100644 index 000000000..535d3c2fb --- /dev/null +++ b/.github/workflows/environment.yml @@ -0,0 +1,11 @@ +name: addons-ci +channels: + - conda-forge +dependencies: + - python=3.12 + - gramps>=6.0,<6.1 + - pygobject + - gtk3 + - orjson + - pytest + - dbf diff --git a/AnniversariesGramplet/AnniversariesGramplet.py b/AnniversariesGramplet/AnniversariesGramplet.py index c8a4a9db8..a326cc0c2 100644 --- a/AnniversariesGramplet/AnniversariesGramplet.py +++ b/AnniversariesGramplet/AnniversariesGramplet.py @@ -26,6 +26,7 @@ from gramps.gen.utils.db import get_participant_from_event from gramps.gen.datehandler import get_date from gramps.gen.config import config +from gramps.gen.errors import WindowActiveError from gramps.gui.editors import EditEvent from gramps.gen.const import GRAMPS_LOCALE as glocale diff --git a/AnniversariesGramplet/po/template.pot b/AnniversariesGramplet/po/template.pot new file mode 100644 index 000000000..e340d9263 --- /dev/null +++ b/AnniversariesGramplet/po/template.pot @@ -0,0 +1,18 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-17 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/ArchiveAssist/po/template.pot b/ArchiveAssist/po/template.pot new file mode 100644 index 000000000..e340d9263 --- /dev/null +++ b/ArchiveAssist/po/template.pot @@ -0,0 +1,18 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-17 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/AttachSourceTool/AttachSourceTool.py b/AttachSourceTool/AttachSourceTool.py index 7fa155013..6a560eedd 100644 --- a/AttachSourceTool/AttachSourceTool.py +++ b/AttachSourceTool/AttachSourceTool.py @@ -209,6 +209,3 @@ def create_source(self, source_text): self.db.add_source(source, self.trans) return source - self.db.add_event(event, self.trans) - return event - diff --git a/CombinedView/combinedview.py b/CombinedView/combinedview.py index a88285911..a95a89b15 100644 --- a/CombinedView/combinedview.py +++ b/CombinedView/combinedview.py @@ -54,7 +54,9 @@ from personpage import PersonPage from eventpage import EventPage from gramps.gen.config import config -from gramps.gen.lib import Family, ChildRef, Person +from gramps.gen.lib import Family, ChildRef, Person, EventType +from gramps.gen.utils.db import get_birth_or_fallback, get_death_or_fallback +from gramps.gen.datehandler import get_date from gramps.gui.uimanager import ActionGroup from gramps.gui.selectors import SelectorFactory from gramps.gen.errors import WindowActiveError diff --git a/DenominoViso/DenominoViso.py b/DenominoViso/DenominoViso.py index 66481cbaf..32a1ee9ab 100644 --- a/DenominoViso/DenominoViso.py +++ b/DenominoViso/DenominoViso.py @@ -129,6 +129,7 @@ from urllib.request import pathname2url from xml.sax.saxutils import escape, quoteattr from math import sin,cos,exp,sqrt,e,pi +from functools import reduce import codecs #------------------------------------------------------------------------- diff --git a/DescendantBooks/RunReport.py b/DescendantBooks/RunReport.py index f123c1052..0d9fd1fa8 100644 --- a/DescendantBooks/RunReport.py +++ b/DescendantBooks/RunReport.py @@ -38,6 +38,7 @@ from gramps.gen.plug.report import CATEGORY_TEXT from gramps.gui.utils import open_file_with_default_application from gramps.gui.user import User +from gramps.gui.dialog import ErrorDialog from gramps.gui.plug.report._textreportdialog import TextReportDialog diff --git a/DescendantsLines/ft.py b/DescendantsLines/ft.py index 711c2e9d2..1452a230d 100644 --- a/DescendantsLines/ft.py +++ b/DescendantsLines/ft.py @@ -31,6 +31,7 @@ import xml.dom.minidom import getopt import sys +from functools import reduce #------------------------------------------------------------------------- # # variables diff --git a/DynamicWeb/dynamicweb.py b/DynamicWeb/dynamicweb.py index 3d02c1a74..be8f08d24 100644 --- a/DynamicWeb/dynamicweb.py +++ b/DynamicWeb/dynamicweb.py @@ -123,14 +123,9 @@ import tarfile import tempfile -if sys.version_info[0] < 3: - from cStringIO import StringIO - - string_types = basestring -else: - from io import StringIO +from io import StringIO - string_types = str +string_types = str from unicodedata import normalize from collections import defaultdict, OrderedDict from xml.sax.saxutils import escape diff --git a/DynamicWeb/run_dynamicweb.py b/DynamicWeb/run_dynamicweb.py index 79c41161b..27039545d 100644 --- a/DynamicWeb/run_dynamicweb.py +++ b/DynamicWeb/run_dynamicweb.py @@ -44,9 +44,6 @@ if (not os.path.exists(gramps_path)): raise Exception("Gramps path GRAMPS_RESOURCES not found") sys.path.insert(1, gramps_path) -if sys.version_info[0] < 3: - reload(sys) - sys.setdefaultencoding('utf8') from dynamicweb import * from dynamicweb import _ from report_sets import * diff --git a/EditExifMetadata/editexifmetadata.py b/EditExifMetadata/editexifmetadata.py index dce9543ec..1546ca8a5 100644 --- a/EditExifMetadata/editexifmetadata.py +++ b/EditExifMetadata/editexifmetadata.py @@ -574,7 +574,7 @@ def __create_button(self, text, callback=[], sensitive=False): """ button = Gtk.Button(label=_(text)) - if callback is not []: + if callback != []: for call_ in callback: button.connect("clicked", call_) diff --git a/Form/selectform.py b/Form/selectform.py index 5696ef05f..e65627773 100644 --- a/Form/selectform.py +++ b/Form/selectform.py @@ -43,6 +43,7 @@ # # ------------------------------------------------------------------------ from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gui.display import display_help try: _trans = glocale.get_addon_translator(__file__) diff --git a/GrampsChat/po/template.pot b/GrampsChat/po/template.pot new file mode 100644 index 000000000..e340d9263 --- /dev/null +++ b/GrampsChat/po/template.pot @@ -0,0 +1,18 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-17 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/GrampsWebSync/diffhandler.py b/GrampsWebSync/diffhandler.py index a61da4b4c..811788f48 100644 --- a/GrampsWebSync/diffhandler.py +++ b/GrampsWebSync/diffhandler.py @@ -26,6 +26,7 @@ from datetime import datetime from gramps.gen.db.base import DbReadBase +from gramps.gen.db import DbTxn from gramps.gen.merge.diff import diff_dbs from gramps.gen.user import User diff --git a/GrampsWebSync/grampswebsync.py b/GrampsWebSync/grampswebsync.py index fcd2d1fe6..8b1c6249a 100644 --- a/GrampsWebSync/grampswebsync.py +++ b/GrampsWebSync/grampswebsync.py @@ -26,6 +26,7 @@ import threading from collections.abc import Callable from datetime import datetime +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlparse diff --git a/GrampyScript/po/template.pot b/GrampyScript/po/template.pot new file mode 100644 index 000000000..e340d9263 --- /dev/null +++ b/GrampyScript/po/template.pot @@ -0,0 +1,18 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-17 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/JSON/JSONImport.py b/JSON/JSONImport.py index 0b72660c3..6155989b0 100644 --- a/JSON/JSONImport.py +++ b/JSON/JSONImport.py @@ -92,7 +92,7 @@ def importData(db, filename, user): elif isinstance(obj, Place): db.add_place(obj, trans) else: - LOG.warn("ignored: " + data) + LOG.warn("ignored: " + line) line = fp.readline() except EnvironmentError as err: user.notify_error(_("%s could not be opened\n") % filename, str(err)) diff --git a/LifeLineChartView/lifelinechart.py b/LifeLineChartView/lifelinechart.py index fa6e9ffc1..9ac3a9465 100644 --- a/LifeLineChartView/lifelinechart.py +++ b/LifeLineChartView/lifelinechart.py @@ -1165,8 +1165,8 @@ def on_mouse_move(self, widget, event): self.mouse_x, self.mouse_y = event.x, event.y tooltip = "" if gr_individual: - if (self._tooltip_individual_cache is None or \ - self._tooltip_individual_cache != gr_individual, gr_family): + if (self._tooltip_individual_cache is None or + self._tooltip_individual_cache != (gr_individual, gr_family)): if self._tooltip_individual_cache != (gr_individual, gr_family): self._tooltip_individual_cache = gr_individual, gr_family self.queue_draw_wrapper() diff --git a/LinesOfDescendency/lines-of-descendency.py b/LinesOfDescendency/lines-of-descendency.py index e6ec8a591..ff468d299 100644 --- a/LinesOfDescendency/lines-of-descendency.py +++ b/LinesOfDescendency/lines-of-descendency.py @@ -42,6 +42,7 @@ from gramps.gen.plug.report import Report from gramps.gen.plug.report import CATEGORY_TEXT from gramps.gen.plug.report import MenuReportOptions +from gramps.gen.errors import ReportError import gramps.gen.plug.report.utils as ReportUtils diff --git a/MongoDB/mongodb.py b/MongoDB/mongodb.py index 49e2a1d79..8632dddb8 100644 --- a/MongoDB/mongodb.py +++ b/MongoDB/mongodb.py @@ -49,6 +49,7 @@ from gramps.gen.utils.configmanager import ConfigManager from gramps.gen.config import config from gramps.gen.const import GRAMPS_LOCALE as glocale +_ = glocale.translation.gettext LOG = logging.getLogger(".mongodb") _LOG = logging.getLogger(DBLOGNAME) diff --git a/PersonEverything/PersonEverything.py b/PersonEverything/PersonEverything.py index 4e3712c5d..f0cf2355c 100644 --- a/PersonEverything/PersonEverything.py +++ b/PersonEverything/PersonEverything.py @@ -68,6 +68,7 @@ from gramps.gen.display.name import displayer as global_name_display from gramps.gen.display.place import displayer as place_displayer from gramps.gen.sort import Sort +from gramps.gen.errors import ReportError from gramps.gen.utils.db import (get_birth_or_fallback, get_death_or_fallback) from gramps.gen.utils.lds import TEMPLES from gramps.gen.utils.place import conv_lat_lon diff --git a/PlaceUpdate/PlaceUpdate.py b/PlaceUpdate/PlaceUpdate.py index 27f948fc5..0c22df1c2 100644 --- a/PlaceUpdate/PlaceUpdate.py +++ b/PlaceUpdate/PlaceUpdate.py @@ -38,6 +38,7 @@ from gramps.gui.selectors import SelectorFactory from gramps.gui.widgets import MonitoredDate, ValidatableMaskedEntry +from gramps.gui.editors import EditDate from gramps.gen.const import GRAMPS_LOCALE as glocale try: diff --git a/PostgreSQLEnhanced/postgresqlenhanced.py b/PostgreSQLEnhanced/postgresqlenhanced.py index a275d6fad..43f6e2ad0 100644 --- a/PostgreSQLEnhanced/postgresqlenhanced.py +++ b/PostgreSQLEnhanced/postgresqlenhanced.py @@ -40,6 +40,7 @@ import re import pickle import sys +import time from urllib.parse import urlparse, parse_qs # ------------------------------------------------------------------------- diff --git a/Query/QueryGramplet.py b/Query/QueryGramplet.py index 121190172..311f0f367 100644 --- a/Query/QueryGramplet.py +++ b/Query/QueryGramplet.py @@ -36,6 +36,7 @@ from gramps.gen.plug import Gramplet import gramps.gen +import sys #------------------------------------------------------------------------ # diff --git a/Query/QueryQuickview.py b/Query/QueryQuickview.py index 6edd26763..ce208078e 100644 --- a/Query/QueryQuickview.py +++ b/Query/QueryQuickview.py @@ -344,7 +344,7 @@ def get_rows(self): table = Table() self.sdb = SimpleAccess(self.database) self.process_table(table) # a class that has .row(1, 2, 3, ...) - print(_("{rows:d} rows processed in {secs} seconds.\n").format(rows=self.select, secs=time.time() - start_time) + print(_("{rows:d} rows processed in {secs} seconds.\n").format(rows=self.select, secs=time.time() - start_time)) return table def eval(self): diff --git a/QuiltView/QuiltView.py b/QuiltView/QuiltView.py index 0dc33f3b9..af04c7e19 100755 --- a/QuiltView/QuiltView.py +++ b/QuiltView/QuiltView.py @@ -56,7 +56,7 @@ # Gramps Modules # #------------------------------------------------------------------------- -from gramps.gen.lib import Person, Family, ChildRef, Name +from gramps.gen.lib import Person, Family, ChildRef, Name, Surname from gramps.gui.views.navigationview import NavigationView from gramps.gen.display.name import displayer as name_displayer from gramps.gen.errors import WindowActiveError @@ -1236,9 +1236,9 @@ def add_children_submenu(self, menu, person, family=None): if find_children(self.dbstate.db, child): label = Gtk.Label(label='%s' - % escape(displayer.display(child))) + % escape(name_displayer.display(child))) else: - label = Gtk.Label(label=escape(displayer.display(child))) + label = Gtk.Label(label=escape(name_displayer.display(child))) child_item = Gtk.MenuItem() label.set_use_markup(True) diff --git a/RelID/relation_tab.py b/RelID/relation_tab.py index ad21f6929..30ed50a7a 100644 --- a/RelID/relation_tab.py +++ b/RelID/relation_tab.py @@ -41,7 +41,7 @@ from gramps.gui.plug import tool from gramps.gen.plug.menu import StringOption, FilterOption, PersonOption, EnumeratedListOption import gramps.gen.plug.report.utils as ReportUtils -from gramps.gui.dialog import WarningDialog, OkDialog +from gramps.gui.dialog import WarningDialog, OkDialog, ErrorDialog from gramps.gen.display.name import displayer as name_displayer from gramps.gen.relationship import get_relationship_calculator from gramps.gen.filters import GenericFilterFactory, rules diff --git a/SourceIndex/birth.py b/SourceIndex/birth.py index 99e6a2c34..f388bf132 100644 --- a/SourceIndex/birth.py +++ b/SourceIndex/birth.py @@ -97,8 +97,8 @@ def on_ok_clicked(widget, data=None): print('save') Gtk.main_save() - def on_witness_clicked( widget, data=None): - print(event) + def on_witness_clicked(widget, data=None): + print('witness') #from witness import Witness #Witness.window.show() diff --git a/SourceIndex/index.py b/SourceIndex/index.py index c13bc8bea..caf33ca14 100644 --- a/SourceIndex/index.py +++ b/SourceIndex/index.py @@ -421,7 +421,6 @@ def indexes(self): 'Load indexes' means to load ordered tables, search environment """ - files = f.endswith('.xml') parent_path = os.path.join(USER_PLUGINS, 'SourceIndex') diff --git a/SourceReferences/SourceReferences.py b/SourceReferences/SourceReferences.py index 0b537304b..33dd5fe64 100644 --- a/SourceReferences/SourceReferences.py +++ b/SourceReferences/SourceReferences.py @@ -24,6 +24,7 @@ from Utils import navigation_label from gen.plug import Gramplet import gtk +import gramps.gen.errors as Errors from gramps.gen.const import GRAMPS_LOCALE as glocale try: diff --git a/SurnameMappingGramplet/SurnameMappingGramplet.grp.py b/SurnameMappingGramplet/SurnameMappingGramplet.gpr.py similarity index 100% rename from SurnameMappingGramplet/SurnameMappingGramplet.grp.py rename to SurnameMappingGramplet/SurnameMappingGramplet.gpr.py diff --git a/SurnameMappingGramplet/SurnameMappingGramplet.py b/SurnameMappingGramplet/SurnameMappingGramplet.py index e0f30a0f0..595a1b63f 100644 --- a/SurnameMappingGramplet/SurnameMappingGramplet.py +++ b/SurnameMappingGramplet/SurnameMappingGramplet.py @@ -140,7 +140,7 @@ def add_mapping_clicked(self, event): response = self.show_dialog(_("Create Mapping"), None, None) if response: (surname, group) = response - self.dbstate.db.set_name_group_mapping(unicode(surname), unicode(group)) + self.dbstate.db.set_name_group_mapping(str(surname), str(group)) self.main() def remove_mapping_clicked(self, event): @@ -148,7 +148,7 @@ def remove_mapping_clicked(self, event): for path in pathlist: tree_iter = model.get_iter(path) value = model.get_value(tree_iter, 0) - self.dbstate.db.set_name_group_mapping(unicode(value), None) + self.dbstate.db.set_name_group_mapping(str(value), None) self.main() def edit_row(self, model, path): @@ -159,10 +159,10 @@ def edit_row(self, model, path): if response: (new_surname, new_group) = response if new_surname == surname: - self.dbstate.db.set_name_group_mapping(unicode(surname), unicode(new_group)) + self.dbstate.db.set_name_group_mapping(str(surname), str(new_group)) else: - self.dbstate.db.set_name_group_mapping(unicode(surname), None) - self.dbstate.db.set_name_group_mapping(unicode(new_surname), unicode(new_group)) + self.dbstate.db.set_name_group_mapping(str(surname), None) + self.dbstate.db.set_name_group_mapping(str(new_surname), str(new_group)) def edit_mapping_clicked(self, event): (model, pathlist) = self.treeview.get_selection().get_selected_rows() diff --git a/TMGimporter/libtmg.py b/TMGimporter/libtmg.py index 6b289c69f..9edd2d87d 100644 --- a/TMGimporter/libtmg.py +++ b/TMGimporter/libtmg.py @@ -2312,6 +2312,7 @@ def on_changed(selection): def importData(database, sqzfilename, user): + tmg_dataset = None ######Check if Gramps Family Tree is empty if not stop import if not database.get_total() == 0: LOG.warning("Create a New Family Tree to import your TMG Backup into.") @@ -2439,7 +2440,7 @@ def importData(database, sqzfilename, user): #Process TMG Project for import #------------------------------------------------------ # determine dataset id if it has not been set by GUI selection - if 'tmg_dataset' not in locals() or tmg_dataset is None: + if tmg_dataset is None: if only_has_one_dataset(): tmg_dataset = only_first_dataset() else: diff --git a/TimelinePedigreeView/TimelinePedigreeView.py b/TimelinePedigreeView/TimelinePedigreeView.py index d490f0242..02fbc1cfa 100644 --- a/TimelinePedigreeView/TimelinePedigreeView.py +++ b/TimelinePedigreeView/TimelinePedigreeView.py @@ -578,7 +578,7 @@ def build_tree(self): """ try: self.Tree_Rebuild() - except AttributeError(msg): + except AttributeError as msg: RunDatabaseRepair(str(msg)) def change_db(self, db): diff --git a/libaccess/libaccess.py b/libaccess/libaccess.py index abdaa6656..e51967c25 100644 --- a/libaccess/libaccess.py +++ b/libaccess/libaccess.py @@ -51,6 +51,8 @@ """ from gramps.gen.db import DbTxn +from gramps.gen.const import GRAMPS_LOCALE as glocale +_ = glocale.translation.gettext import itertools @@ -87,7 +89,7 @@ def __call__(self, *args, **kwargs): return NONE def __cmp__(self, other): - return cmp(None, other) + return (None > other) - (None < other) def __hash__(self): return hash(None) @@ -249,7 +251,7 @@ class Person(Object): setters = { "handle": lambda self, value: self.setit("handle", value), - "gramps_id": lambda self: self.setit("gramps_id", value), + "gramps_id": lambda self, value: self.setit("gramps_id", value), } def setit(self, attr, value): diff --git a/lxml/lxmlGramplet.py b/lxml/lxmlGramplet.py index bb86a67ca..d684ea689 100644 --- a/lxml/lxmlGramplet.py +++ b/lxml/lxmlGramplet.py @@ -69,7 +69,7 @@ GZIP_OK = True except ImportError: GZIP_OK = False - ErrorDialog(_('Where is gzip?'), _('"gzip" is missing'), parent=self.uistate.window) + ErrorDialog(_('Where is gzip?'), _('"gzip" is missing')) LOG.error('No gzip') #------------------------------------------------------------------------- @@ -91,7 +91,7 @@ LIBXSLT_VERSION = etree.LIBXSLT_VERSION except ImportError: LXML_OK = False - ErrorDialog(_('Missing python3 lxml'), _('Please, try to install "python3 lxml" package.'), parent=self.uistate.window) + ErrorDialog(_('Missing python3 lxml'), _('Please, try to install "python3 lxml" package.')) LOG.debug('No lxml') #------------------------------------------------------------------------- @@ -397,7 +397,7 @@ def read_xml(self, entry): os.system(f'xmllint') try: - if os.name is 'nt': + if os.name == 'nt': os.system(f'xmllint --relaxng {rng} --noout {filename} {options}') LOG.debug('xmllint (relaxng) : %s' % filename) else: @@ -755,7 +755,7 @@ def check_valid(self, filename): dtd = os.path.join(USER_PLUGINS, 'lxml', 'grampsxml.dtd') try: - if os.name is 'nt': + if os.name == 'nt': os.system(f'xmllint --dtdvalid {dtd} {filename} {options}') else: os.system(f'xmllint --dtdvalid file://{dtd} {filename} {options}') From a17d1e8b8c3490283f55118c7ece0c67eea13909 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Fri, 17 Apr 2026 19:26:41 +0200 Subject: [PATCH 2/4] Fix Windows CI: hybrid conda+pip env, move out of workflows/ Gramps is not published on conda-forge, so keep the binary deps (pygobject, gtk3) on conda and install gramps+orjson+pytest+dbf via pip. Move the env file out of .github/workflows/ so the IDE stops applying the GitHub Actions workflow schema to it. Co-Authored-By: Claude Opus 4.7 --- .github/{workflows => }/environment.yml | 10 ++++++---- .github/workflows/ci.yml | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) rename .github/{workflows => }/environment.yml (52%) diff --git a/.github/workflows/environment.yml b/.github/environment.yml similarity index 52% rename from .github/workflows/environment.yml rename to .github/environment.yml index 535d3c2fb..8f05f3f5b 100644 --- a/.github/workflows/environment.yml +++ b/.github/environment.yml @@ -3,9 +3,11 @@ channels: - conda-forge dependencies: - python=3.12 - - gramps>=6.0,<6.1 - pygobject - gtk3 - - orjson - - pytest - - dbf + - pip + - pip: + - "gramps>=6.0,<6.1" + - orjson + - pytest + - dbf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3446cb27f..69a3ffad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: with: miniforge-version: latest activate-environment: addons-ci - environment-file: .github/workflows/environment.yml + environment-file: .github/environment.yml use-mamba: true - name: Verify environment From b038628efe1472093c7dc107c518d39b53b5e0b4 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Fri, 17 Apr 2026 19:42:31 +0200 Subject: [PATCH 3/4] Split TMG tests: unit (pure logic) vs integration (Gramps DB) In-memory Gramps SQLite DB init hangs on Windows under the pip-installed Gramps + conda-forge GTK stack. Move the 99 DB-using TMG tests into test_integration_libtmg.py so the Windows unit job skips them via the existing --ignore-glob='**/test_integration*.py' filter. The 58 pure-logic tests (strip codes, date parsing, repo type / URL inference) stay in test_libtmg.py and run on both Linux and Windows. Co-Authored-By: Claude Opus 4.7 --- TMGimporter/tests/test_integration_libtmg.py | 1214 ++++++++++++++++++ TMGimporter/tests/test_libtmg.py | 1213 +---------------- 2 files changed, 1220 insertions(+), 1207 deletions(-) create mode 100644 TMGimporter/tests/test_integration_libtmg.py diff --git a/TMGimporter/tests/test_integration_libtmg.py b/TMGimporter/tests/test_integration_libtmg.py new file mode 100644 index 000000000..d4c4810f5 --- /dev/null +++ b/TMGimporter/tests/test_integration_libtmg.py @@ -0,0 +1,1214 @@ +"""Integration tests for libtmg.py — require a Gramps SQLite database. + +Split from test_libtmg.py because in-memory Gramps DB init hangs on Windows +under the pip-installed Gramps + conda-forge GTK stack. Linux CI picks these +up via the test_integration*.py glob. + +Run with: + python -m pytest tests/test_integration_libtmg.py + +Gramps must be installed. The DBF-dependent import functions are tested by +patching the module-level table globals so no real .dbf files are needed. +""" + +import sys +import os +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import libtmg + +from gramps.gen.lib import Event, NoteType, Person, Place, Source +from gramps.gen.db.utils import make_database +from gramps.gen.db import DbTxn + + +# --------------------------------------------------------------------------- +# Helpers shared across test cases +# --------------------------------------------------------------------------- + +class _Rec: + """Minimal fake DBF record — set any field via keyword arguments.""" + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def _table(records): + """Return an object that behaves like a dbf.Table used as a context manager. + + libtmg uses tables in two ways: + with tmgFoo: + for record in tmgFoo: # iterates over context-managed table + """ + class _FakeTable: + def __enter__(self): + return self + def __exit__(self, *_): + return False + def __iter__(self): + return iter(records) + return _FakeTable() + + +def _make_db(): + """Return a fresh in-memory Gramps database.""" + db = make_database("sqlite") + db.load(":memory:", None) + return db + + +def _add_person(db): + """Add an empty Person to db and return (db, handle).""" + p = Person() + with DbTxn("setup", db) as t: + db.add_person(p, t) + return db, p.get_handle() + + +# --------------------------------------------------------------------------- +# import_notes — patches the module-level DBF table globals +# --------------------------------------------------------------------------- + +class TestImportNotes(unittest.TestCase): + + def _patch(self, tagtypes_records, events_records): + """Patch libtmg globals and return a context manager.""" + import unittest.mock as mock + patches = [ + mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), + mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), + ] + return patches + + def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + libtmg.import_notes(db, dataset, per_no_map) + return db + + def test_no_per_no_map_is_noop(self): + """Passing per_no_map=None must not touch any table.""" + import unittest.mock as mock + db = _make_db() + mock_table = mock.MagicMock() + with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ + mock.patch('libtmg.tmgEvents', mock_table, create=True): + libtmg.import_notes(db, 1, per_no_map=None) + mock_table.__enter__.assert_not_called() + + def test_note_attached_to_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {42: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, + efoot='Born in London')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 1) + + def test_note_text_stored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot=' Some note text ')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'Some note text') + + def test_note_type_is_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='hello')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get_type(), NoteType.PERSON) + + def test_tmg_codes_stripped_from_note(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='[:ITAL:]italicised[:ITAL:]')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'italicised') + + def test_empty_note_text_skipped(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 0) + + def test_unknown_person_skipped(self): + db = _make_db() + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, + efoot='orphan note')], + per_no_map={}, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_non_note_etype_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + # etype=10 is not a Note type + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot='should be ignored')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_wrong_dataset_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, + efoot='wrong dataset')], + per_no_map=per_no_map, dataset=1, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_multiple_notes_for_one_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[ + _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), + _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), + ], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 2) + + def test_no_note_tag_type_defined(self): + """If the dataset has no 'Note' tag type, nothing is imported.""" + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[], # no tag types at all + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='orphan')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + +# --------------------------------------------------------------------------- +# trial_events — event import and Note-etype skip +# --------------------------------------------------------------------------- + +# A minimal raw date string for an exact date (1900-06-15) +_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' +_EMPTY_DATE = '' + + +class TestTrialEvents(unittest.TestCase): + + def _run(self, tagtypes_records, events_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + handle_map = libtmg.import_events(db, dataset) + return db, handle_map + + def test_regular_event_creates_db_entry(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertIn(1, hmap) + + def test_handle_map_tuple_has_four_elements(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, + placenum=7, edate=_EMPTY_DATE, efoot='')] + _, hmap = self._run(_tagtypes, _events) + entry = hmap[5] + self.assertEqual(len(entry), 4) + _handle, per1, per2, placenum = entry + self.assertEqual(per1, 3) + self.assertEqual(per2, 0) + self.assertEqual(placenum, 7) + + def test_note_etype_event_not_in_handle_map(self): + _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] + _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 0) + self.assertNotIn(1, hmap) + + def test_event_memo_stored_as_description(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_memo_tmg_codes_stripped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, + efoot='[:CR:]born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_date_set(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EXACT_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + d = event.get_date_object() + self.assertEqual(d.get_year(), 1900) + self.assertEqual(d.get_month(), 6) + + def test_wrong_dataset_skipped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events, dataset=1) + self.assertEqual(db.get_number_of_events(), 0) + + def test_mixed_note_and_regular_events(self): + _tagtypes = [ + _Rec(dsid=1, etypenum=77, etypename='Note'), + _Rec(dsid=1, etypenum=10, etypename='Birth'), + ] + _events = [ + _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note'), + _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot=''), + ] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertNotIn(1, hmap) + self.assertIn(2, hmap) + + +# --------------------------------------------------------------------------- +# import_sources — info-field parsing and author/publication split +# --------------------------------------------------------------------------- + +class TestImportSources(unittest.TestCase): + + def _run(self, src_components, src_repo_links, sources_records, + repo_handle_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ + mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ + mock.patch('libtmg.tmgSources', _table(sources_records), create=True): + smap = libtmg.import_sources(db, dataset, repo_handle_map) + return db, smap + + def _source_rec(self, **kw): + defaults = dict(dsid=1, majnum=1, mactive=True, + title='Test Source', abbrev='', info='', + text='', fform='', sform='', bform='', reminders='') + defaults.update(kw) + return _Rec(**defaults) + + def test_source_created(self): + db, smap = self._run([], [], [self._source_rec()]) + self.assertEqual(db.get_number_of_sources(), 1) + self.assertIn(1, smap) + + def test_title_set(self): + db, smap = self._run([], [], [self._source_rec(title='My Source')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_title(), 'My Source') + + def test_abbreviation_set(self): + db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_abbreviation(), 'MySrc') + + def test_inactive_source_skipped(self): + db, smap = self._run([], [], [self._source_rec(mactive=False)]) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_wrong_dataset_skipped(self): + db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_author_element_sets_author(self): + # recno 1 → position 0 in $!& split + components = [_Rec(recno=1, element='[AUTHOR]')] + rec = self._source_rec(info='John Smith') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'John Smith') + + def test_non_author_element_sets_publication_info(self): + components = [_Rec(recno=1, element='[TITLE]')] + rec = self._source_rec(info='Some Title') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertIn('TITLE', src.get_publication_info()) + self.assertIn('Some Title', src.get_publication_info()) + + def test_multiple_authors_joined_with_semicolon(self): + # positions 0 and 1 → recno 1 and 2 + components = [ + _Rec(recno=1, element='[AUTHOR]'), + _Rec(recno=2, element='[EDITOR]'), + ] + rec = self._source_rec(info='Alice$!&Bob') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Alice; Bob') + + def test_empty_info_position_skipped(self): + # position 0 empty, position 1 filled → recno 2 = [AUTHOR] + components = [ + _Rec(recno=1, element='[TITLE]'), + _Rec(recno=2, element='[AUTHOR]'), + ] + rec = self._source_rec(info='$!&Jane Doe') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Jane Doe') + self.assertEqual(src.get_publication_info(), '') + + def test_note_fields_become_notes(self): + rec = self._source_rec(text='original text', fform='', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 1) + note = db.get_note_from_handle(src.get_note_list()[0]) + self.assertIn('original text', note.get()) + + def test_multiple_note_fields_each_become_a_note(self): + rec = self._source_rec(text='txt', fform='fn', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 2) + + +# --------------------------------------------------------------------------- +# import_places — name reconstruction, type resolution, note parts +# --------------------------------------------------------------------------- + +class TestImportPlaces(unittest.TestCase): + + def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ + mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ + mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ + mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + pmap = libtmg.import_places(db, dataset) + return db, pmap + + # Convenience: build part_type, place_dict, ppv records for a single place + def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): + """ + parts: list of (label, value) e.g. [('City','London'),('Country','UK')] + Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) + """ + part_type_recs = [] + place_dict_recs = [] + ppv_recs = [] + for i, (label, value) in enumerate(parts): + type_id = i + 1 + uid = i + 100 + part_type_recs.append(_Rec(type=type_id, value=label)) + place_dict_recs.append(_Rec(uid=uid, value=value)) + ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) + place_recs = [_Rec(dsid=dataset, recno=recno, + shortplace=shortplace, comment=comment)] + return part_type_recs, place_dict_recs, ppv_recs, place_recs + + def test_city_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'London') + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_country_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.COUNTRY) + + def test_city_state_country_name_order(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + # GEO_ORDER: Addressee, Detail, City, County, State, Country + self.assertEqual(place.get_name().get_value(), + 'Paris, Île-de-France, France') + + def test_most_specific_type_wins(self): + # City is more specific than Country in _GEO_ORDER + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Berlin'), ('Country', 'Germany') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_empty_place_skipped(self): + # No parts and no shortplace → nothing imported + place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertEqual(db.get_number_of_places(), 0) + self.assertNotIn(1, pmap) + + def test_shortplace_fallback(self): + # No parts but shortplace set → use it + place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'Somewhere') + + def test_note_parts_go_to_note(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Rome'), ('Postal', '00100') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(len(place.get_note_list()), 1) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('Postal', note.get()) + self.assertIn('00100', note.get()) + + def test_comment_goes_to_note(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('see also', note.get()) + + def test_wrong_dataset_skipped(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) + db, pmap = self._run(pt, pd, ppv, pl, dataset=1) + self.assertEqual(db.get_number_of_places(), 0) + + def test_returns_recno_to_handle_map(self): + pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(42, pmap) + + +# --------------------------------------------------------------------------- +# link_event_places — event gets its place handle set +# --------------------------------------------------------------------------- + +class TestLinkEventPlaces(unittest.TestCase): + + def _make_event(self, db): + from gramps.gen.db import DbTxn + ev = Event() + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def _make_place(self, db): + from gramps.gen.db import DbTxn + pl = Place() + with DbTxn("setup", db) as t: + db.add_place(pl, t) + return pl.get_handle() + + def test_place_linked_to_event(self): + db = _make_db() + ev_handle = self._make_event(db) + pl_handle = self._make_place(db) + event_handle_map = {1: (ev_handle, 1, 0, 7)} + place_handle_map = {7: pl_handle} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), pl_handle) + + def test_zero_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 0)} + place_handle_map = {0: self._make_place(db)} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_unknown_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 99)} + libtmg.link_event_places(db, event_handle_map, {}) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_event_places(db, {}, {}) # must not raise + libtmg.link_event_places(db, None, None) + + +# --------------------------------------------------------------------------- +# Lookup helpers: short_place_name, tag_type_name +# --------------------------------------------------------------------------- + +class TestShortPlaceName(unittest.TestCase): + + def _run(self, places_records, placenum, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + return libtmg.short_place_name(db, placenum, dataset) + + def test_returns_shortplace(self): + rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=5), 'New York') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=1), 'London') + + def test_wrong_recno_returns_none(self): + rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=1, dataset=1)) + + +class TestTagTypeName(unittest.TestCase): + + def _run(self, tagtypes_records, eventtype, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): + return libtmg.tag_type_name(db, eventtype, dataset) + + def test_returns_name(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') + self.assertEqual(self._run([rec], eventtype=2), 'Birth') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, etypenum=3, etypename='Death ') + self.assertEqual(self._run([rec], eventtype=3), 'Death') + + def test_wrong_eventtype_returns_none(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) + + +# --------------------------------------------------------------------------- +# import_people — name parsing, gender, dataset filter +# --------------------------------------------------------------------------- + +class TestImportPeople(unittest.TestCase): + + def _run(self, names_records, people_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ + mock.patch('libtmg.tmgPeople', _table(people_records), create=True): + per_no_map = libtmg.import_people(db, dataset) + return db, per_no_map + + def _name_rec(self, **kw): + defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') + defaults.update(kw) + return _Rec(**defaults) + + def _person_rec(self, **kw): + defaults = dict(dsid=1, per_no=1, sex='M') + defaults.update(kw) + return _Rec(**defaults) + + def test_person_created(self): + db, pmap = self._run([self._name_rec()], [self._person_rec()]) + self.assertEqual(db.get_number_of_people(), 1) + + def test_returns_per_no_map(self): + db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) + self.assertIn(5, pmap) + + def test_surname_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'JONES') + + def test_given_name_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') + + def test_male_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.MALE) + + def test_female_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.FEMALE) + + def test_unknown_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.UNKNOWN) + + def test_non_primary_name_skipped(self): + db, pmap = self._run( + [self._name_rec(primary=False, srnamedisp='ALT, Name')], + [self._person_rec()] + ) + self.assertEqual(db.get_number_of_people(), 0) + + def test_wrong_dataset_skipped(self): + db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], + dataset=1) + self.assertEqual(db.get_number_of_people(), 0) + + def test_no_comma_surname_only(self): + # srnamedisp with no comma → surname=full string, given='' + db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], + [self._person_rec()]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') + self.assertEqual(p.get_primary_name().get_first_name(), '') + + +# --------------------------------------------------------------------------- +# link_person_events — EventRefs, birth/death special refs +# --------------------------------------------------------------------------- + +class TestLinkPersonEvents(unittest.TestCase): + + def _make_typed_event(self, db, event_type_int): + from gramps.gen.lib import EventType + ev = Event() + ev.set_type(EventType(event_type_int)) + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def test_individual_event_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.OCCUPATION) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 1) + + def test_couple_event_not_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.MARRIAGE) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 2, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 0) + + def test_birth_event_sets_birth_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.BIRTH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_birth_ref()) + self.assertEqual(p.get_birth_ref().ref, ev_handle) + + def test_death_event_sets_death_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.DEATH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_death_ref()) + + def test_unknown_person_skipped(self): + from gramps.gen.lib import EventType + db = _make_db() + ev_handle = self._make_typed_event(db, EventType.BIRTH) + # Must not raise even when per1 has no entry in per_no_map + libtmg.link_person_events(db, + per_no_map={}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_person_events(db, None, None) + libtmg.link_person_events(db, {}, {}) + + +# --------------------------------------------------------------------------- +# import_families — parent-child grouping, couple events, rel type +# --------------------------------------------------------------------------- + +class TestImportFamilies(unittest.TestCase): + + def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, + dataset=1): + """Create persons from per_no_by_gender={per_no: gender}, run import.""" + import unittest.mock as mock + db = _make_db() + pmap = {} + for per_no, gender in per_no_by_gender.items(): + p = Person() + p.set_gender(gender) + with DbTxn("setup", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): + libtmg.import_families(db, dataset, pmap, event_handle_map) + return db, pmap + + def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): + return _Rec(dsid=dsid, parent=parent, child=child, + ptype=ptype, primary=primary, pnote=pnote) + + def _father_type(self, num=1): + return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') + + def _mother_type(self, num=2): + return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') + + def test_father_child_creates_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + + def test_mother_child_creates_family(self): + db, pmap = self._run([self._mother_type()], + [self._pc(1, 2, ptype=2)], + {1: Person.FEMALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_mother_handle(), pmap[1]) + + def test_father_and_mother_same_family(self): + db, pmap = self._run( + [self._father_type(1), self._mother_type(2)], + [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], + {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, + ) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + self.assertEqual(fam.get_mother_handle(), pmap[2]) + + def test_child_added_to_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(len(fam.get_child_ref_list()), 1) + self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) + + def test_child_ref_type_biological(self): + from gramps.gen.lib import ChildRefType + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), + ChildRefType.BIRTH) + + def test_wrong_dataset_skipped(self): + db, _ = self._run([self._father_type()], + [self._pc(1, 2, ptype=1, dsid=2)], + {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) + self.assertEqual(db.get_number_of_families(), 0) + + def test_no_per_no_map_is_noop(self): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): + libtmg.import_families(db, 1, per_no_map=None) + self.assertEqual(db.get_number_of_families(), 0) + + def test_marriage_event_sets_rel_type_married(self): + from gramps.gen.lib import EventType, FamilyRelType + import unittest.mock as mock + + db = _make_db() + pmap = {} + for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): + p = Person() + p.set_gender(gender) + with DbTxn("s", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + + ev = Event() + ev.set_type(EventType(EventType.MARRIAGE)) + with DbTxn("s", db) as t: + db.add_event(ev, t) + + tagtypes = [self._father_type(1), self._mother_type(2)] + pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] + event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} + + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): + libtmg.import_families(db, 1, pmap, event_handle_map) + + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) + self.assertEqual(len(fam.get_event_ref_list()), 1) + + +# --------------------------------------------------------------------------- +# import_repositories — name, type inference, URL, notes +# --------------------------------------------------------------------------- + +class TestImportRepositories(unittest.TestCase): + + def _run(self, repo_records, per_no_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): + repo_map = libtmg.import_repositories(db, dataset, per_no_map) + return db, repo_map + + def _repo_rec(self, **kw): + defaults = dict(dsid=1, recno=1, name='City Library', + abbrev='', rnote='', rperno=0) + defaults.update(kw) + return _Rec(**defaults) + + def test_repository_created(self): + db, rmap = self._run([self._repo_rec()]) + self.assertEqual(db.get_number_of_repositories(), 1) + self.assertIn(1, rmap) + + def test_name_set(self): + db, rmap = self._run([self._repo_rec(name='National Archives')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'National Archives') + + def test_wrong_dataset_skipped(self): + db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_repositories(), 0) + + def test_type_inferred_from_name(self): + from gramps.gen.lib import RepositoryType + db, rmap = self._run([self._repo_rec(name='ancestry.com')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) + + def test_url_added_for_web_repo(self): + db, rmap = self._run([self._repo_rec(name='familysearch.org')]) + repo = db.get_repository_from_handle(rmap[1]) + urls = repo.get_url_list() + self.assertEqual(len(urls), 1) + self.assertIn('familysearch', urls[0].get_path()) + + def test_no_url_for_non_web_repo(self): + db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_url_list()), 0) + + def test_blank_name_falls_back_to_abbrev(self): + db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'TNA') + + def test_note_added_when_rnote_set(self): + db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_note_list()), 1) + note = db.get_note_from_handle(repo.get_note_list()[0]) + self.assertIn('Open Mon-Fri', note.get()) + + def test_returns_recno_to_handle_map(self): + db, rmap = self._run([self._repo_rec(recno=42)]) + self.assertIn(42, rmap) + + +# --------------------------------------------------------------------------- +# import_citations — creation and attachment to events / persons +# --------------------------------------------------------------------------- + +class TestImportCitations(unittest.TestCase): + + def _run(self, citation_records, names_records=None, pc_records=None, + source_handle_map=None, event_handle_map=None, per_no_map=None, + dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ + mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): + libtmg.import_citations(db, dataset, + source_handle_map=source_handle_map, + event_handle_map=event_handle_map, + per_no_map=per_no_map) + return db + + def _cit_rec(self, **kw): + defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, + exclude=False, subsource='', citref='', citmemo='', + sdsure='', snsure='', sssure='', spsure='', sfsure='') + defaults.update(kw) + return _Rec(**defaults) + + def test_no_source_map_is_noop(self): + db = self._run([self._cit_rec()]) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_created(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec()], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 1) + + def test_excluded_citation_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(exclude=True)], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_wrong_dataset_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(dsid=2)], + source_handle_map={1: src.get_handle()}, db=db, dataset=1) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_unknown_source_skipped(self): + db = _make_db() + db = self._run([self._cit_rec(majsource=99)], + source_handle_map={1: 'some_handle'}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_attached_to_event(self): + db = _make_db() + src = Source() + ev = Event() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_event(ev, t) + ev_handle = ev.get_handle() + + db = self._run([self._cit_rec(stype='E', refrec=7)], + source_handle_map={1: src.get_handle()}, + event_handle_map={7: (ev_handle, 1, 0, 0)}, + db=db) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(len(event.get_citation_list()), 1) + + def test_citation_attached_to_person_via_name(self): + db = _make_db() + src = Source() + p = Person() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_person(p, t) + phandle = p.get_handle() + + # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle + db = self._run( + [self._cit_rec(stype='N', refrec=3)], + names_records=[_Rec(dsid=1, recno=3, nper=5)], + source_handle_map={1: src.get_handle()}, + per_no_map={5: phandle}, + db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_citation_list()), 1) + + def test_subsource_becomes_page(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(subsource='p.42')], + source_handle_map={1: src.get_handle()}, db=db) + cit_handle = list(db.get_citation_handles())[0] + cit = db.get_citation_from_handle(cit_handle) + self.assertEqual(cit.get_page(), 'p.42') + + + +# ── TmgProject._read_pjc_config ────────────────────────────────────────── + +def _make_minimal_sqz(tmp_dir, pjc_content): + """Create a minimal .SQZ zip containing only a PJC file.""" + import zipfile as _zf + pjc_path = os.path.join(tmp_dir, 'test.pjc') + sqz_path = os.path.join(tmp_dir, 'test.sqz') + with open(pjc_path, 'w', encoding='latin-1') as f: + f.write(pjc_content) + with _zf.ZipFile(sqz_path, 'w') as zf: + zf.write(pjc_path, 'test.pjc') + return sqz_path + + +class _MockUser: + """Minimal stand-in for the Gramps user object used in importData.""" + def __init__(self): + self.error_shown = False + self.error_message = None + self.uistate = None + + def notify_error(self, title, message=''): + self.error_shown = True + self.error_message = message + + def begin_progress(self, *a, **kw): pass + def end_progress(self): pass + def step_progress(self): pass + + +class TestReadPjcConfig(unittest.TestCase): + """Tests for TmgProject._read_pjc_config PJC parsing.""" + + _MINIMAL_PJC = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Researcher]\n" + "Name=Test User\n" + ) + + def _make_project(self, pjc_content, tmp_path): + pjc_file = os.path.join(tmp_path, "test.pjc") + with open(pjc_file, 'w', encoding='latin-1') as f: + f.write(pjc_content) + return libtmg.TmgProject(pjc_file) + + def test_well_formed_pjc_returns_version(self): + """A clean PJC file parses successfully and version() returns a float.""" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(self._MINIMAL_PJC, tmp) + self.assertEqual(project.version(), 11.0) + + def test_malformed_section_header_does_not_raise(self): + """Lines like '[Exho' (no closing bracket) are silently dropped.""" + pjc = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Exho\n" # malformed — the crash trigger + "SomeGarbage\n" + "[Researcher]\n" + "Name=Test User\n" + ) + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + # Must not raise; must still find [Stamp] + self.assertEqual(project.version(), 11.0) + + def test_null_bytes_stripped(self): + """NUL bytes in the PJC file are stripped before parsing.""" + pjc = "[Stamp]\x00\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + self.assertEqual(project.version(), 11.0) + + def test_parse_error_returns_partial_config(self): + """If configparser still raises after filtering, a warning is logged + and a (possibly empty) config object is returned rather than crashing.""" + import logging + # Feed content that survives the filter but still breaks configparser: + # a key=value line before any section header is technically invalid. + pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: + cfg = project._read_pjc_config() + self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() + for m in cm.output)) + # Config object is returned (not None), even if incomplete + self.assertIsNotNone(cfg) + + def test_version_too_old_notifies_user(self): + """A PJC version < 11.0 calls user.notify_error and aborts import.""" + pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for old version") + self.assertIn('9.05', user.error_message or '', + "Error message should mention TMG 9.05") + + def test_missing_pjc_version_notifies_user(self): + """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" + pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for missing version") + +if __name__ == '__main__': + unittest.main() diff --git a/TMGimporter/tests/test_libtmg.py b/TMGimporter/tests/test_libtmg.py index 2f92a59b7..166f6927a 100644 --- a/TMGimporter/tests/test_libtmg.py +++ b/TMGimporter/tests/test_libtmg.py @@ -1,68 +1,20 @@ -"""Unit tests for libtmg.py +"""Unit tests for libtmg.py — pure-logic only. -Run with: - python -m pytest tests/ -or: - python -m unittest discover -s tests +Tests that require a Gramps database live in test_integration_libtmg.py. +These tests run everywhere (Linux, Windows) without touching the DB stack. -Gramps must be installed. The DBF-dependent import functions are tested by -patching the module-level table globals so no real .dbf files are needed. +Run with: + python -m pytest tests/test_libtmg.py """ import sys import os -import tempfile import unittest -# Make sure libtmg is importable from the parent directory sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import libtmg -from gramps.gen.lib import Date, Event, NoteType, Person, Place, Source -from gramps.gen.db.utils import make_database -from gramps.gen.db import DbTxn - - -# --------------------------------------------------------------------------- -# Helpers shared across test cases -# --------------------------------------------------------------------------- - -class _Rec: - """Minimal fake DBF record — set any field via keyword arguments.""" - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - -def _table(records): - """Return an object that behaves like a dbf.Table used as a context manager. - - libtmg uses tables in two ways: - with tmgFoo: - for record in tmgFoo: # iterates over context-managed table - """ - class _FakeTable: - def __enter__(self): - return self - def __exit__(self, *_): - return False - def __iter__(self): - return iter(records) - return _FakeTable() - - -def _make_db(): - """Return a fresh in-memory Gramps database.""" - db = make_database("sqlite") - db.load(":memory:", None) - return db - - -def _add_person(db): - """Add an empty Person to db and return (db, handle).""" - p = Person() - with DbTxn("setup", db) as t: - db.add_person(p, t) - return db, p.get_handle() +from gramps.gen.lib import Date # --------------------------------------------------------------------------- @@ -119,11 +71,9 @@ def test_empty_string_returns_none(self): self.assertIsNone(libtmg.tmg_date_to_gramps_date('')) def test_type_flag_zero_returns_none(self): - # datefieldtype '0' means no date self.assertIsNone(libtmg.tmg_date_to_gramps_date('0' + '0' * 20)) def test_all_zero_date_returns_none(self): - # type=1 but all date digits zero → effectively empty self.assertIsNone(libtmg.tmg_date_to_gramps_date(_raw(date1='00000000'))) def test_exact_date_fields(self): @@ -171,7 +121,6 @@ def test_uncertain_sets_estimated_quality(self): self.assertEqual(d.get_quality(), Date.QUAL_ESTIMATED) def test_say_sets_estimated_quality(self): - # "Say" (mod=1) also triggers QUAL_ESTIMATED regardless of uncertain flag d = libtmg.tmg_date_to_gramps_date(_raw(mod='1')) self.assertEqual(d.get_quality(), Date.QUAL_ESTIMATED) @@ -180,529 +129,6 @@ def test_exact_certain_has_no_quality(self): self.assertEqual(d.get_quality(), Date.QUAL_NONE) -# --------------------------------------------------------------------------- -# import_notes — patches the module-level DBF table globals -# --------------------------------------------------------------------------- - -class TestImportNotes(unittest.TestCase): - - def _patch(self, tagtypes_records, events_records): - """Patch libtmg globals and return a context manager.""" - import unittest.mock as mock - patches = [ - mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), - mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), - ] - return patches - - def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - libtmg.import_notes(db, dataset, per_no_map) - return db - - def test_no_per_no_map_is_noop(self): - """Passing per_no_map=None must not touch any table.""" - import unittest.mock as mock - db = _make_db() - mock_table = mock.MagicMock() - with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ - mock.patch('libtmg.tmgEvents', mock_table, create=True): - libtmg.import_notes(db, 1, per_no_map=None) - mock_table.__enter__.assert_not_called() - - def test_note_attached_to_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {42: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, - efoot='Born in London')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 1) - - def test_note_text_stored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot=' Some note text ')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'Some note text') - - def test_note_type_is_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='hello')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get_type(), NoteType.PERSON) - - def test_tmg_codes_stripped_from_note(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='[:ITAL:]italicised[:ITAL:]')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'italicised') - - def test_empty_note_text_skipped(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 0) - - def test_unknown_person_skipped(self): - db = _make_db() - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, - efoot='orphan note')], - per_no_map={}, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_non_note_etype_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - # etype=10 is not a Note type - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot='should be ignored')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_wrong_dataset_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, - efoot='wrong dataset')], - per_no_map=per_no_map, dataset=1, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_multiple_notes_for_one_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[ - _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), - _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), - ], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 2) - - def test_no_note_tag_type_defined(self): - """If the dataset has no 'Note' tag type, nothing is imported.""" - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[], # no tag types at all - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='orphan')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - -# --------------------------------------------------------------------------- -# trial_events — event import and Note-etype skip -# --------------------------------------------------------------------------- - -# A minimal raw date string for an exact date (1900-06-15) -_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' -_EMPTY_DATE = '' - - -class TestTrialEvents(unittest.TestCase): - - def _run(self, tagtypes_records, events_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - handle_map = libtmg.import_events(db, dataset) - return db, handle_map - - def test_regular_event_creates_db_entry(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertIn(1, hmap) - - def test_handle_map_tuple_has_four_elements(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, - placenum=7, edate=_EMPTY_DATE, efoot='')] - _, hmap = self._run(_tagtypes, _events) - entry = hmap[5] - self.assertEqual(len(entry), 4) - _handle, per1, per2, placenum = entry - self.assertEqual(per1, 3) - self.assertEqual(per2, 0) - self.assertEqual(placenum, 7) - - def test_note_etype_event_not_in_handle_map(self): - _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] - _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 0) - self.assertNotIn(1, hmap) - - def test_event_memo_stored_as_description(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_memo_tmg_codes_stripped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, - efoot='[:CR:]born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_date_set(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EXACT_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - d = event.get_date_object() - self.assertEqual(d.get_year(), 1900) - self.assertEqual(d.get_month(), 6) - - def test_wrong_dataset_skipped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events, dataset=1) - self.assertEqual(db.get_number_of_events(), 0) - - def test_mixed_note_and_regular_events(self): - _tagtypes = [ - _Rec(dsid=1, etypenum=77, etypename='Note'), - _Rec(dsid=1, etypenum=10, etypename='Birth'), - ] - _events = [ - _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note'), - _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot=''), - ] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertNotIn(1, hmap) - self.assertIn(2, hmap) - - -# --------------------------------------------------------------------------- -# import_sources — info-field parsing and author/publication split -# --------------------------------------------------------------------------- - -class TestImportSources(unittest.TestCase): - - def _run(self, src_components, src_repo_links, sources_records, - repo_handle_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ - mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ - mock.patch('libtmg.tmgSources', _table(sources_records), create=True): - smap = libtmg.import_sources(db, dataset, repo_handle_map) - return db, smap - - def _source_rec(self, **kw): - defaults = dict(dsid=1, majnum=1, mactive=True, - title='Test Source', abbrev='', info='', - text='', fform='', sform='', bform='', reminders='') - defaults.update(kw) - return _Rec(**defaults) - - def test_source_created(self): - db, smap = self._run([], [], [self._source_rec()]) - self.assertEqual(db.get_number_of_sources(), 1) - self.assertIn(1, smap) - - def test_title_set(self): - db, smap = self._run([], [], [self._source_rec(title='My Source')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_title(), 'My Source') - - def test_abbreviation_set(self): - db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_abbreviation(), 'MySrc') - - def test_inactive_source_skipped(self): - db, smap = self._run([], [], [self._source_rec(mactive=False)]) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_wrong_dataset_skipped(self): - db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_author_element_sets_author(self): - # recno 1 → position 0 in $!& split - components = [_Rec(recno=1, element='[AUTHOR]')] - rec = self._source_rec(info='John Smith') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'John Smith') - - def test_non_author_element_sets_publication_info(self): - components = [_Rec(recno=1, element='[TITLE]')] - rec = self._source_rec(info='Some Title') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertIn('TITLE', src.get_publication_info()) - self.assertIn('Some Title', src.get_publication_info()) - - def test_multiple_authors_joined_with_semicolon(self): - # positions 0 and 1 → recno 1 and 2 - components = [ - _Rec(recno=1, element='[AUTHOR]'), - _Rec(recno=2, element='[EDITOR]'), - ] - rec = self._source_rec(info='Alice$!&Bob') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Alice; Bob') - - def test_empty_info_position_skipped(self): - # position 0 empty, position 1 filled → recno 2 = [AUTHOR] - components = [ - _Rec(recno=1, element='[TITLE]'), - _Rec(recno=2, element='[AUTHOR]'), - ] - rec = self._source_rec(info='$!&Jane Doe') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Jane Doe') - self.assertEqual(src.get_publication_info(), '') - - def test_note_fields_become_notes(self): - rec = self._source_rec(text='original text', fform='', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 1) - note = db.get_note_from_handle(src.get_note_list()[0]) - self.assertIn('original text', note.get()) - - def test_multiple_note_fields_each_become_a_note(self): - rec = self._source_rec(text='txt', fform='fn', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 2) - - -# --------------------------------------------------------------------------- -# import_places — name reconstruction, type resolution, note parts -# --------------------------------------------------------------------------- - -class TestImportPlaces(unittest.TestCase): - - def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ - mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ - mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ - mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - pmap = libtmg.import_places(db, dataset) - return db, pmap - - # Convenience: build part_type, place_dict, ppv records for a single place - def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): - """ - parts: list of (label, value) e.g. [('City','London'),('Country','UK')] - Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) - """ - part_type_recs = [] - place_dict_recs = [] - ppv_recs = [] - for i, (label, value) in enumerate(parts): - type_id = i + 1 - uid = i + 100 - part_type_recs.append(_Rec(type=type_id, value=label)) - place_dict_recs.append(_Rec(uid=uid, value=value)) - ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) - place_recs = [_Rec(dsid=dataset, recno=recno, - shortplace=shortplace, comment=comment)] - return part_type_recs, place_dict_recs, ppv_recs, place_recs - - def test_city_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'London') - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_country_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.COUNTRY) - - def test_city_state_country_name_order(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - # GEO_ORDER: Addressee, Detail, City, County, State, Country - self.assertEqual(place.get_name().get_value(), - 'Paris, Île-de-France, France') - - def test_most_specific_type_wins(self): - # City is more specific than Country in _GEO_ORDER - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Berlin'), ('Country', 'Germany') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_empty_place_skipped(self): - # No parts and no shortplace → nothing imported - place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertEqual(db.get_number_of_places(), 0) - self.assertNotIn(1, pmap) - - def test_shortplace_fallback(self): - # No parts but shortplace set → use it - place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'Somewhere') - - def test_note_parts_go_to_note(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Rome'), ('Postal', '00100') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(len(place.get_note_list()), 1) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('Postal', note.get()) - self.assertIn('00100', note.get()) - - def test_comment_goes_to_note(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('see also', note.get()) - - def test_wrong_dataset_skipped(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) - db, pmap = self._run(pt, pd, ppv, pl, dataset=1) - self.assertEqual(db.get_number_of_places(), 0) - - def test_returns_recno_to_handle_map(self): - pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(42, pmap) - - -# --------------------------------------------------------------------------- -# link_event_places — event gets its place handle set -# --------------------------------------------------------------------------- - -class TestLinkEventPlaces(unittest.TestCase): - - def _make_event(self, db): - from gramps.gen.db import DbTxn - ev = Event() - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def _make_place(self, db): - from gramps.gen.db import DbTxn - pl = Place() - with DbTxn("setup", db) as t: - db.add_place(pl, t) - return pl.get_handle() - - def test_place_linked_to_event(self): - db = _make_db() - ev_handle = self._make_event(db) - pl_handle = self._make_place(db) - event_handle_map = {1: (ev_handle, 1, 0, 7)} - place_handle_map = {7: pl_handle} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), pl_handle) - - def test_zero_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 0)} - place_handle_map = {0: self._make_place(db)} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_unknown_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 99)} - libtmg.link_event_places(db, event_handle_map, {}) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_event_places(db, {}, {}) # must not raise - libtmg.link_event_places(db, None, None) - - # --------------------------------------------------------------------------- # Pure functions: num_to_month, num_to_date, parse_date # --------------------------------------------------------------------------- @@ -737,7 +163,6 @@ def test_all_zeros_returns_none(self): self.assertIsNone(libtmg.num_to_date('00000000')) def test_day_month_only(self): - # YYYY=0000 MM=09 DD=20 → "20 Sep" self.assertEqual(libtmg.num_to_date('00000920'), '20 Sep') def test_day_only(self): @@ -796,7 +221,6 @@ def test_certain_flag_no_question_mark(self): self.assertFalse(result.endswith('?')) def test_irregular_date_code_returns_raw(self): - # type '0' → return the 28-char raw value after the type flag raw = '0' + 'about 1900' + ' ' * 18 result = libtmg.parse_date(raw[:29]) self.assertEqual(result, raw[1:29]) @@ -860,628 +284,3 @@ def test_gov_domain(self): def test_domain_embedded_in_name(self): url = libtmg._url_from_name('Ancestry (ancestry.com) family trees') self.assertEqual(url, 'https://www.ancestry.com') - - -# --------------------------------------------------------------------------- -# Lookup helpers: short_place_name, tag_type_name -# --------------------------------------------------------------------------- - -class TestShortPlaceName(unittest.TestCase): - - def _run(self, places_records, placenum, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - return libtmg.short_place_name(db, placenum, dataset) - - def test_returns_shortplace(self): - rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=5), 'New York') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=1), 'London') - - def test_wrong_recno_returns_none(self): - rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=1, dataset=1)) - - -class TestTagTypeName(unittest.TestCase): - - def _run(self, tagtypes_records, eventtype, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): - return libtmg.tag_type_name(db, eventtype, dataset) - - def test_returns_name(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') - self.assertEqual(self._run([rec], eventtype=2), 'Birth') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, etypenum=3, etypename='Death ') - self.assertEqual(self._run([rec], eventtype=3), 'Death') - - def test_wrong_eventtype_returns_none(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) - - -# --------------------------------------------------------------------------- -# import_people — name parsing, gender, dataset filter -# --------------------------------------------------------------------------- - -class TestImportPeople(unittest.TestCase): - - def _run(self, names_records, people_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ - mock.patch('libtmg.tmgPeople', _table(people_records), create=True): - per_no_map = libtmg.import_people(db, dataset) - return db, per_no_map - - def _name_rec(self, **kw): - defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') - defaults.update(kw) - return _Rec(**defaults) - - def _person_rec(self, **kw): - defaults = dict(dsid=1, per_no=1, sex='M') - defaults.update(kw) - return _Rec(**defaults) - - def test_person_created(self): - db, pmap = self._run([self._name_rec()], [self._person_rec()]) - self.assertEqual(db.get_number_of_people(), 1) - - def test_returns_per_no_map(self): - db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) - self.assertIn(5, pmap) - - def test_surname_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'JONES') - - def test_given_name_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') - - def test_male_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.MALE) - - def test_female_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.FEMALE) - - def test_unknown_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.UNKNOWN) - - def test_non_primary_name_skipped(self): - db, pmap = self._run( - [self._name_rec(primary=False, srnamedisp='ALT, Name')], - [self._person_rec()] - ) - self.assertEqual(db.get_number_of_people(), 0) - - def test_wrong_dataset_skipped(self): - db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], - dataset=1) - self.assertEqual(db.get_number_of_people(), 0) - - def test_no_comma_surname_only(self): - # srnamedisp with no comma → surname=full string, given='' - db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], - [self._person_rec()]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') - self.assertEqual(p.get_primary_name().get_first_name(), '') - - -# --------------------------------------------------------------------------- -# link_person_events — EventRefs, birth/death special refs -# --------------------------------------------------------------------------- - -class TestLinkPersonEvents(unittest.TestCase): - - def _make_typed_event(self, db, event_type_int): - from gramps.gen.lib import EventType - ev = Event() - ev.set_type(EventType(event_type_int)) - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def test_individual_event_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.OCCUPATION) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 1) - - def test_couple_event_not_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.MARRIAGE) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 2, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 0) - - def test_birth_event_sets_birth_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.BIRTH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_birth_ref()) - self.assertEqual(p.get_birth_ref().ref, ev_handle) - - def test_death_event_sets_death_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.DEATH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_death_ref()) - - def test_unknown_person_skipped(self): - from gramps.gen.lib import EventType - db = _make_db() - ev_handle = self._make_typed_event(db, EventType.BIRTH) - # Must not raise even when per1 has no entry in per_no_map - libtmg.link_person_events(db, - per_no_map={}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_person_events(db, None, None) - libtmg.link_person_events(db, {}, {}) - - -# --------------------------------------------------------------------------- -# import_families — parent-child grouping, couple events, rel type -# --------------------------------------------------------------------------- - -class TestImportFamilies(unittest.TestCase): - - def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, - dataset=1): - """Create persons from per_no_by_gender={per_no: gender}, run import.""" - import unittest.mock as mock - db = _make_db() - pmap = {} - for per_no, gender in per_no_by_gender.items(): - p = Person() - p.set_gender(gender) - with DbTxn("setup", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): - libtmg.import_families(db, dataset, pmap, event_handle_map) - return db, pmap - - def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): - return _Rec(dsid=dsid, parent=parent, child=child, - ptype=ptype, primary=primary, pnote=pnote) - - def _father_type(self, num=1): - return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') - - def _mother_type(self, num=2): - return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') - - def test_father_child_creates_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - - def test_mother_child_creates_family(self): - db, pmap = self._run([self._mother_type()], - [self._pc(1, 2, ptype=2)], - {1: Person.FEMALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_mother_handle(), pmap[1]) - - def test_father_and_mother_same_family(self): - db, pmap = self._run( - [self._father_type(1), self._mother_type(2)], - [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], - {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, - ) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - self.assertEqual(fam.get_mother_handle(), pmap[2]) - - def test_child_added_to_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(len(fam.get_child_ref_list()), 1) - self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) - - def test_child_ref_type_biological(self): - from gramps.gen.lib import ChildRefType - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), - ChildRefType.BIRTH) - - def test_wrong_dataset_skipped(self): - db, _ = self._run([self._father_type()], - [self._pc(1, 2, ptype=1, dsid=2)], - {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) - self.assertEqual(db.get_number_of_families(), 0) - - def test_no_per_no_map_is_noop(self): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): - libtmg.import_families(db, 1, per_no_map=None) - self.assertEqual(db.get_number_of_families(), 0) - - def test_marriage_event_sets_rel_type_married(self): - from gramps.gen.lib import EventType, FamilyRelType - import unittest.mock as mock - - db = _make_db() - pmap = {} - for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): - p = Person() - p.set_gender(gender) - with DbTxn("s", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - - ev = Event() - ev.set_type(EventType(EventType.MARRIAGE)) - with DbTxn("s", db) as t: - db.add_event(ev, t) - - tagtypes = [self._father_type(1), self._mother_type(2)] - pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] - event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} - - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): - libtmg.import_families(db, 1, pmap, event_handle_map) - - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) - self.assertEqual(len(fam.get_event_ref_list()), 1) - - -# --------------------------------------------------------------------------- -# import_repositories — name, type inference, URL, notes -# --------------------------------------------------------------------------- - -class TestImportRepositories(unittest.TestCase): - - def _run(self, repo_records, per_no_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): - repo_map = libtmg.import_repositories(db, dataset, per_no_map) - return db, repo_map - - def _repo_rec(self, **kw): - defaults = dict(dsid=1, recno=1, name='City Library', - abbrev='', rnote='', rperno=0) - defaults.update(kw) - return _Rec(**defaults) - - def test_repository_created(self): - db, rmap = self._run([self._repo_rec()]) - self.assertEqual(db.get_number_of_repositories(), 1) - self.assertIn(1, rmap) - - def test_name_set(self): - db, rmap = self._run([self._repo_rec(name='National Archives')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'National Archives') - - def test_wrong_dataset_skipped(self): - db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_repositories(), 0) - - def test_type_inferred_from_name(self): - from gramps.gen.lib import RepositoryType - db, rmap = self._run([self._repo_rec(name='ancestry.com')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) - - def test_url_added_for_web_repo(self): - db, rmap = self._run([self._repo_rec(name='familysearch.org')]) - repo = db.get_repository_from_handle(rmap[1]) - urls = repo.get_url_list() - self.assertEqual(len(urls), 1) - self.assertIn('familysearch', urls[0].get_path()) - - def test_no_url_for_non_web_repo(self): - db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_url_list()), 0) - - def test_blank_name_falls_back_to_abbrev(self): - db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'TNA') - - def test_note_added_when_rnote_set(self): - db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_note_list()), 1) - note = db.get_note_from_handle(repo.get_note_list()[0]) - self.assertIn('Open Mon-Fri', note.get()) - - def test_returns_recno_to_handle_map(self): - db, rmap = self._run([self._repo_rec(recno=42)]) - self.assertIn(42, rmap) - - -# --------------------------------------------------------------------------- -# import_citations — creation and attachment to events / persons -# --------------------------------------------------------------------------- - -class TestImportCitations(unittest.TestCase): - - def _run(self, citation_records, names_records=None, pc_records=None, - source_handle_map=None, event_handle_map=None, per_no_map=None, - dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ - mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): - libtmg.import_citations(db, dataset, - source_handle_map=source_handle_map, - event_handle_map=event_handle_map, - per_no_map=per_no_map) - return db - - def _cit_rec(self, **kw): - defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, - exclude=False, subsource='', citref='', citmemo='', - sdsure='', snsure='', sssure='', spsure='', sfsure='') - defaults.update(kw) - return _Rec(**defaults) - - def test_no_source_map_is_noop(self): - db = self._run([self._cit_rec()]) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_created(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec()], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 1) - - def test_excluded_citation_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(exclude=True)], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_wrong_dataset_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(dsid=2)], - source_handle_map={1: src.get_handle()}, db=db, dataset=1) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_unknown_source_skipped(self): - db = _make_db() - db = self._run([self._cit_rec(majsource=99)], - source_handle_map={1: 'some_handle'}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_attached_to_event(self): - db = _make_db() - src = Source() - ev = Event() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_event(ev, t) - ev_handle = ev.get_handle() - - db = self._run([self._cit_rec(stype='E', refrec=7)], - source_handle_map={1: src.get_handle()}, - event_handle_map={7: (ev_handle, 1, 0, 0)}, - db=db) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(len(event.get_citation_list()), 1) - - def test_citation_attached_to_person_via_name(self): - db = _make_db() - src = Source() - p = Person() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_person(p, t) - phandle = p.get_handle() - - # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle - db = self._run( - [self._cit_rec(stype='N', refrec=3)], - names_records=[_Rec(dsid=1, recno=3, nper=5)], - source_handle_map={1: src.get_handle()}, - per_no_map={5: phandle}, - db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_citation_list()), 1) - - def test_subsource_becomes_page(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(subsource='p.42')], - source_handle_map={1: src.get_handle()}, db=db) - cit_handle = list(db.get_citation_handles())[0] - cit = db.get_citation_from_handle(cit_handle) - self.assertEqual(cit.get_page(), 'p.42') - - - -# ── TmgProject._read_pjc_config ────────────────────────────────────────── - -def _make_minimal_sqz(tmp_dir, pjc_content): - """Create a minimal .SQZ zip containing only a PJC file.""" - import zipfile as _zf - pjc_path = os.path.join(tmp_dir, 'test.pjc') - sqz_path = os.path.join(tmp_dir, 'test.sqz') - with open(pjc_path, 'w', encoding='latin-1') as f: - f.write(pjc_content) - with _zf.ZipFile(sqz_path, 'w') as zf: - zf.write(pjc_path, 'test.pjc') - return sqz_path - - -class _MockUser: - """Minimal stand-in for the Gramps user object used in importData.""" - def __init__(self): - self.error_shown = False - self.error_message = None - self.uistate = None - - def notify_error(self, title, message=''): - self.error_shown = True - self.error_message = message - - def begin_progress(self, *a, **kw): pass - def end_progress(self): pass - def step_progress(self): pass - - -class TestReadPjcConfig(unittest.TestCase): - """Tests for TmgProject._read_pjc_config PJC parsing.""" - - _MINIMAL_PJC = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Researcher]\n" - "Name=Test User\n" - ) - - def _make_project(self, pjc_content, tmp_path): - pjc_file = os.path.join(tmp_path, "test.pjc") - with open(pjc_file, 'w', encoding='latin-1') as f: - f.write(pjc_content) - return libtmg.TmgProject(pjc_file) - - def test_well_formed_pjc_returns_version(self): - """A clean PJC file parses successfully and version() returns a float.""" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(self._MINIMAL_PJC, tmp) - self.assertEqual(project.version(), 11.0) - - def test_malformed_section_header_does_not_raise(self): - """Lines like '[Exho' (no closing bracket) are silently dropped.""" - pjc = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Exho\n" # malformed — the crash trigger - "SomeGarbage\n" - "[Researcher]\n" - "Name=Test User\n" - ) - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - # Must not raise; must still find [Stamp] - self.assertEqual(project.version(), 11.0) - - def test_null_bytes_stripped(self): - """NUL bytes in the PJC file are stripped before parsing.""" - pjc = "[Stamp]\x00\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - self.assertEqual(project.version(), 11.0) - - def test_parse_error_returns_partial_config(self): - """If configparser still raises after filtering, a warning is logged - and a (possibly empty) config object is returned rather than crashing.""" - import logging - # Feed content that survives the filter but still breaks configparser: - # a key=value line before any section header is technically invalid. - pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: - cfg = project._read_pjc_config() - self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() - for m in cm.output)) - # Config object is returned (not None), even if incomplete - self.assertIsNotNone(cfg) - - def test_version_too_old_notifies_user(self): - """A PJC version < 11.0 calls user.notify_error and aborts import.""" - pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for old version") - self.assertIn('9.05', user.error_message or '', - "Error message should mention TMG 9.05") - - def test_missing_pjc_version_notifies_user(self): - """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" - pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for missing version") - -if __name__ == '__main__': - unittest.main() From 09526104f0de631d63c410e9bd11893f8e8436dd Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Fri, 17 Apr 2026 20:50:15 +0200 Subject: [PATCH 4/4] Consolidate TMGimporter integration tests into one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge test_integration_libtmg.py into test_integration.py — shared helpers (_Rec, _table, _make_db, _add_person) are deduplicated at the top, and the E2E RELPH-pipeline class now sits alongside the 12 DB-backed function-level test classes (117 tests total). Co-Authored-By: Claude Opus 4.7 --- TMGimporter/tests/test_integration.py | 1224 +++++++++++++++++- TMGimporter/tests/test_integration_libtmg.py | 1214 ----------------- 2 files changed, 1213 insertions(+), 1225 deletions(-) delete mode 100644 TMGimporter/tests/test_integration_libtmg.py diff --git a/TMGimporter/tests/test_integration.py b/TMGimporter/tests/test_integration.py index f77475855..32983006f 100644 --- a/TMGimporter/tests/test_integration.py +++ b/TMGimporter/tests/test_integration.py @@ -1,24 +1,37 @@ -"""Integration tests for the TMG importer using the real RELPH SQZ file. +"""Integration tests for the TMG importer. -Runs the full import pipeline against the real backup and asserts expected -counts and specific known records. +Two kinds of tests live here: + * TestFullPipelineRelph — full end-to-end pipeline against a real SQZ backup + (skipped automatically when TMG_TEST_SQZ / integration_test.sqz is absent). + * Everything else — DB-backed unit-style tests for individual libtmg + functions using mocked DBF tables. + +All tests need a real Gramps SQLite database; CI picks them up via the +test_integration*.py glob on Linux only (the Windows unit job skips them +because in-memory Gramps DB init hangs under the pip-installed Gramps + +conda-forge GTK stack). Run with: - python3 -m unittest tests.test_integration -v + python3 -m pytest TMGimporter/tests/test_integration.py -v -The SQZ path is read from the environment variable TMG_TEST_SQZ. If not set -it defaults to the RELPH backup used during development. Tests are skipped -automatically when the file is not present. +The SQZ path is read from the environment variable TMG_TEST_SQZ. If not set +it defaults to the backup used during development. """ import os -import sys import shutil +import sys import tempfile import unittest import zipfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import libtmg + +from gramps.gen.lib import Event, NoteType, Person, Place, Source +from gramps.gen.db.utils import make_database +from gramps.gen.db import DbTxn + _TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) SQZ_PATH = os.environ.get( @@ -36,6 +49,52 @@ def _find_pjc(root): raise FileNotFoundError("No .pjc file found under " + root) +# --------------------------------------------------------------------------- +# Helpers shared across the function-level DB tests +# --------------------------------------------------------------------------- + +class _Rec: + """Minimal fake DBF record — set any field via keyword arguments.""" + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def _table(records): + """Return an object that behaves like a dbf.Table used as a context manager. + + libtmg uses tables in two ways: + with tmgFoo: + for record in tmgFoo: # iterates over context-managed table + """ + class _FakeTable: + def __enter__(self): + return self + def __exit__(self, *_): + return False + def __iter__(self): + return iter(records) + return _FakeTable() + + +def _make_db(): + """Return a fresh in-memory Gramps database.""" + db = make_database("sqlite") + db.load(":memory:", None) + return db + + +def _add_person(db): + """Add an empty Person to db and return (db, handle).""" + p = Person() + with DbTxn("setup", db) as t: + db.add_person(p, t) + return db, p.get_handle() + + +# --------------------------------------------------------------------------- +# Full end-to-end pipeline against the real SQZ backup +# --------------------------------------------------------------------------- + @unittest.skipUnless(os.path.exists(SQZ_PATH), f'SQZ not found: {SQZ_PATH}') class TestFullPipelineRelph(unittest.TestCase): """Run the complete import pipeline once for the whole class, then assert.""" @@ -49,9 +108,6 @@ def setUpClass(cls): pjc_path = _find_pjc(cls._tmpdir) pjc_dir = os.path.dirname(pjc_path) + os.sep - import libtmg - from gramps.gen.db.utils import make_database - cls.db = make_database("sqlite") cls.db.load(":memory:", None) @@ -208,3 +264,1149 @@ def test_no_event_with_note_type(self): ev = self.db.get_event_from_handle(handle) self.assertNotEqual(str(ev.get_type()), 'Note', "Found an event with type 'Note' — should be a Note object") + + +# --------------------------------------------------------------------------- +# import_notes — patches the module-level DBF table globals +# --------------------------------------------------------------------------- + +class TestImportNotes(unittest.TestCase): + + def _patch(self, tagtypes_records, events_records): + """Patch libtmg globals and return a context manager.""" + import unittest.mock as mock + patches = [ + mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), + mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), + ] + return patches + + def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + libtmg.import_notes(db, dataset, per_no_map) + return db + + def test_no_per_no_map_is_noop(self): + """Passing per_no_map=None must not touch any table.""" + import unittest.mock as mock + db = _make_db() + mock_table = mock.MagicMock() + with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ + mock.patch('libtmg.tmgEvents', mock_table, create=True): + libtmg.import_notes(db, 1, per_no_map=None) + mock_table.__enter__.assert_not_called() + + def test_note_attached_to_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {42: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, + efoot='Born in London')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 1) + + def test_note_text_stored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot=' Some note text ')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'Some note text') + + def test_note_type_is_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='hello')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get_type(), NoteType.PERSON) + + def test_tmg_codes_stripped_from_note(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='[:ITAL:]italicised[:ITAL:]')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'italicised') + + def test_empty_note_text_skipped(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 0) + + def test_unknown_person_skipped(self): + db = _make_db() + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, + efoot='orphan note')], + per_no_map={}, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_non_note_etype_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + # etype=10 is not a Note type + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot='should be ignored')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_wrong_dataset_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, + efoot='wrong dataset')], + per_no_map=per_no_map, dataset=1, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_multiple_notes_for_one_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[ + _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), + _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), + ], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 2) + + def test_no_note_tag_type_defined(self): + """If the dataset has no 'Note' tag type, nothing is imported.""" + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[], # no tag types at all + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='orphan')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + +# --------------------------------------------------------------------------- +# trial_events — event import and Note-etype skip +# --------------------------------------------------------------------------- + +# A minimal raw date string for an exact date (1900-06-15) +_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' +_EMPTY_DATE = '' + + +class TestTrialEvents(unittest.TestCase): + + def _run(self, tagtypes_records, events_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + handle_map = libtmg.import_events(db, dataset) + return db, handle_map + + def test_regular_event_creates_db_entry(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertIn(1, hmap) + + def test_handle_map_tuple_has_four_elements(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, + placenum=7, edate=_EMPTY_DATE, efoot='')] + _, hmap = self._run(_tagtypes, _events) + entry = hmap[5] + self.assertEqual(len(entry), 4) + _handle, per1, per2, placenum = entry + self.assertEqual(per1, 3) + self.assertEqual(per2, 0) + self.assertEqual(placenum, 7) + + def test_note_etype_event_not_in_handle_map(self): + _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] + _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 0) + self.assertNotIn(1, hmap) + + def test_event_memo_stored_as_description(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_memo_tmg_codes_stripped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, + efoot='[:CR:]born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_date_set(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EXACT_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + d = event.get_date_object() + self.assertEqual(d.get_year(), 1900) + self.assertEqual(d.get_month(), 6) + + def test_wrong_dataset_skipped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events, dataset=1) + self.assertEqual(db.get_number_of_events(), 0) + + def test_mixed_note_and_regular_events(self): + _tagtypes = [ + _Rec(dsid=1, etypenum=77, etypename='Note'), + _Rec(dsid=1, etypenum=10, etypename='Birth'), + ] + _events = [ + _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note'), + _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot=''), + ] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertNotIn(1, hmap) + self.assertIn(2, hmap) + + +# --------------------------------------------------------------------------- +# import_sources — info-field parsing and author/publication split +# --------------------------------------------------------------------------- + +class TestImportSources(unittest.TestCase): + + def _run(self, src_components, src_repo_links, sources_records, + repo_handle_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ + mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ + mock.patch('libtmg.tmgSources', _table(sources_records), create=True): + smap = libtmg.import_sources(db, dataset, repo_handle_map) + return db, smap + + def _source_rec(self, **kw): + defaults = dict(dsid=1, majnum=1, mactive=True, + title='Test Source', abbrev='', info='', + text='', fform='', sform='', bform='', reminders='') + defaults.update(kw) + return _Rec(**defaults) + + def test_source_created(self): + db, smap = self._run([], [], [self._source_rec()]) + self.assertEqual(db.get_number_of_sources(), 1) + self.assertIn(1, smap) + + def test_title_set(self): + db, smap = self._run([], [], [self._source_rec(title='My Source')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_title(), 'My Source') + + def test_abbreviation_set(self): + db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_abbreviation(), 'MySrc') + + def test_inactive_source_skipped(self): + db, smap = self._run([], [], [self._source_rec(mactive=False)]) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_wrong_dataset_skipped(self): + db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_author_element_sets_author(self): + # recno 1 → position 0 in $!& split + components = [_Rec(recno=1, element='[AUTHOR]')] + rec = self._source_rec(info='John Smith') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'John Smith') + + def test_non_author_element_sets_publication_info(self): + components = [_Rec(recno=1, element='[TITLE]')] + rec = self._source_rec(info='Some Title') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertIn('TITLE', src.get_publication_info()) + self.assertIn('Some Title', src.get_publication_info()) + + def test_multiple_authors_joined_with_semicolon(self): + # positions 0 and 1 → recno 1 and 2 + components = [ + _Rec(recno=1, element='[AUTHOR]'), + _Rec(recno=2, element='[EDITOR]'), + ] + rec = self._source_rec(info='Alice$!&Bob') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Alice; Bob') + + def test_empty_info_position_skipped(self): + # position 0 empty, position 1 filled → recno 2 = [AUTHOR] + components = [ + _Rec(recno=1, element='[TITLE]'), + _Rec(recno=2, element='[AUTHOR]'), + ] + rec = self._source_rec(info='$!&Jane Doe') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Jane Doe') + self.assertEqual(src.get_publication_info(), '') + + def test_note_fields_become_notes(self): + rec = self._source_rec(text='original text', fform='', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 1) + note = db.get_note_from_handle(src.get_note_list()[0]) + self.assertIn('original text', note.get()) + + def test_multiple_note_fields_each_become_a_note(self): + rec = self._source_rec(text='txt', fform='fn', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 2) + + +# --------------------------------------------------------------------------- +# import_places — name reconstruction, type resolution, note parts +# --------------------------------------------------------------------------- + +class TestImportPlaces(unittest.TestCase): + + def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ + mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ + mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ + mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + pmap = libtmg.import_places(db, dataset) + return db, pmap + + # Convenience: build part_type, place_dict, ppv records for a single place + def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): + """ + parts: list of (label, value) e.g. [('City','London'),('Country','UK')] + Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) + """ + part_type_recs = [] + place_dict_recs = [] + ppv_recs = [] + for i, (label, value) in enumerate(parts): + type_id = i + 1 + uid = i + 100 + part_type_recs.append(_Rec(type=type_id, value=label)) + place_dict_recs.append(_Rec(uid=uid, value=value)) + ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) + place_recs = [_Rec(dsid=dataset, recno=recno, + shortplace=shortplace, comment=comment)] + return part_type_recs, place_dict_recs, ppv_recs, place_recs + + def test_city_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'London') + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_country_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.COUNTRY) + + def test_city_state_country_name_order(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + # GEO_ORDER: Addressee, Detail, City, County, State, Country + self.assertEqual(place.get_name().get_value(), + 'Paris, Île-de-France, France') + + def test_most_specific_type_wins(self): + # City is more specific than Country in _GEO_ORDER + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Berlin'), ('Country', 'Germany') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_empty_place_skipped(self): + # No parts and no shortplace → nothing imported + place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertEqual(db.get_number_of_places(), 0) + self.assertNotIn(1, pmap) + + def test_shortplace_fallback(self): + # No parts but shortplace set → use it + place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'Somewhere') + + def test_note_parts_go_to_note(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Rome'), ('Postal', '00100') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(len(place.get_note_list()), 1) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('Postal', note.get()) + self.assertIn('00100', note.get()) + + def test_comment_goes_to_note(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('see also', note.get()) + + def test_wrong_dataset_skipped(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) + db, pmap = self._run(pt, pd, ppv, pl, dataset=1) + self.assertEqual(db.get_number_of_places(), 0) + + def test_returns_recno_to_handle_map(self): + pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(42, pmap) + + +# --------------------------------------------------------------------------- +# link_event_places — event gets its place handle set +# --------------------------------------------------------------------------- + +class TestLinkEventPlaces(unittest.TestCase): + + def _make_event(self, db): + from gramps.gen.db import DbTxn + ev = Event() + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def _make_place(self, db): + from gramps.gen.db import DbTxn + pl = Place() + with DbTxn("setup", db) as t: + db.add_place(pl, t) + return pl.get_handle() + + def test_place_linked_to_event(self): + db = _make_db() + ev_handle = self._make_event(db) + pl_handle = self._make_place(db) + event_handle_map = {1: (ev_handle, 1, 0, 7)} + place_handle_map = {7: pl_handle} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), pl_handle) + + def test_zero_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 0)} + place_handle_map = {0: self._make_place(db)} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_unknown_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 99)} + libtmg.link_event_places(db, event_handle_map, {}) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_event_places(db, {}, {}) # must not raise + libtmg.link_event_places(db, None, None) + + +# --------------------------------------------------------------------------- +# Lookup helpers: short_place_name, tag_type_name +# --------------------------------------------------------------------------- + +class TestShortPlaceName(unittest.TestCase): + + def _run(self, places_records, placenum, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + return libtmg.short_place_name(db, placenum, dataset) + + def test_returns_shortplace(self): + rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=5), 'New York') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=1), 'London') + + def test_wrong_recno_returns_none(self): + rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=1, dataset=1)) + + +class TestTagTypeName(unittest.TestCase): + + def _run(self, tagtypes_records, eventtype, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): + return libtmg.tag_type_name(db, eventtype, dataset) + + def test_returns_name(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') + self.assertEqual(self._run([rec], eventtype=2), 'Birth') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, etypenum=3, etypename='Death ') + self.assertEqual(self._run([rec], eventtype=3), 'Death') + + def test_wrong_eventtype_returns_none(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) + + +# --------------------------------------------------------------------------- +# import_people — name parsing, gender, dataset filter +# --------------------------------------------------------------------------- + +class TestImportPeople(unittest.TestCase): + + def _run(self, names_records, people_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ + mock.patch('libtmg.tmgPeople', _table(people_records), create=True): + per_no_map = libtmg.import_people(db, dataset) + return db, per_no_map + + def _name_rec(self, **kw): + defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') + defaults.update(kw) + return _Rec(**defaults) + + def _person_rec(self, **kw): + defaults = dict(dsid=1, per_no=1, sex='M') + defaults.update(kw) + return _Rec(**defaults) + + def test_person_created(self): + db, pmap = self._run([self._name_rec()], [self._person_rec()]) + self.assertEqual(db.get_number_of_people(), 1) + + def test_returns_per_no_map(self): + db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) + self.assertIn(5, pmap) + + def test_surname_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'JONES') + + def test_given_name_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') + + def test_male_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.MALE) + + def test_female_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.FEMALE) + + def test_unknown_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.UNKNOWN) + + def test_non_primary_name_skipped(self): + db, pmap = self._run( + [self._name_rec(primary=False, srnamedisp='ALT, Name')], + [self._person_rec()] + ) + self.assertEqual(db.get_number_of_people(), 0) + + def test_wrong_dataset_skipped(self): + db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], + dataset=1) + self.assertEqual(db.get_number_of_people(), 0) + + def test_no_comma_surname_only(self): + # srnamedisp with no comma → surname=full string, given='' + db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], + [self._person_rec()]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') + self.assertEqual(p.get_primary_name().get_first_name(), '') + + +# --------------------------------------------------------------------------- +# link_person_events — EventRefs, birth/death special refs +# --------------------------------------------------------------------------- + +class TestLinkPersonEvents(unittest.TestCase): + + def _make_typed_event(self, db, event_type_int): + from gramps.gen.lib import EventType + ev = Event() + ev.set_type(EventType(event_type_int)) + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def test_individual_event_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.OCCUPATION) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 1) + + def test_couple_event_not_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.MARRIAGE) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 2, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 0) + + def test_birth_event_sets_birth_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.BIRTH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_birth_ref()) + self.assertEqual(p.get_birth_ref().ref, ev_handle) + + def test_death_event_sets_death_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.DEATH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_death_ref()) + + def test_unknown_person_skipped(self): + from gramps.gen.lib import EventType + db = _make_db() + ev_handle = self._make_typed_event(db, EventType.BIRTH) + # Must not raise even when per1 has no entry in per_no_map + libtmg.link_person_events(db, + per_no_map={}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_person_events(db, None, None) + libtmg.link_person_events(db, {}, {}) + + +# --------------------------------------------------------------------------- +# import_families — parent-child grouping, couple events, rel type +# --------------------------------------------------------------------------- + +class TestImportFamilies(unittest.TestCase): + + def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, + dataset=1): + """Create persons from per_no_by_gender={per_no: gender}, run import.""" + import unittest.mock as mock + db = _make_db() + pmap = {} + for per_no, gender in per_no_by_gender.items(): + p = Person() + p.set_gender(gender) + with DbTxn("setup", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): + libtmg.import_families(db, dataset, pmap, event_handle_map) + return db, pmap + + def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): + return _Rec(dsid=dsid, parent=parent, child=child, + ptype=ptype, primary=primary, pnote=pnote) + + def _father_type(self, num=1): + return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') + + def _mother_type(self, num=2): + return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') + + def test_father_child_creates_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + + def test_mother_child_creates_family(self): + db, pmap = self._run([self._mother_type()], + [self._pc(1, 2, ptype=2)], + {1: Person.FEMALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_mother_handle(), pmap[1]) + + def test_father_and_mother_same_family(self): + db, pmap = self._run( + [self._father_type(1), self._mother_type(2)], + [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], + {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, + ) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + self.assertEqual(fam.get_mother_handle(), pmap[2]) + + def test_child_added_to_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(len(fam.get_child_ref_list()), 1) + self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) + + def test_child_ref_type_biological(self): + from gramps.gen.lib import ChildRefType + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), + ChildRefType.BIRTH) + + def test_wrong_dataset_skipped(self): + db, _ = self._run([self._father_type()], + [self._pc(1, 2, ptype=1, dsid=2)], + {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) + self.assertEqual(db.get_number_of_families(), 0) + + def test_no_per_no_map_is_noop(self): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): + libtmg.import_families(db, 1, per_no_map=None) + self.assertEqual(db.get_number_of_families(), 0) + + def test_marriage_event_sets_rel_type_married(self): + from gramps.gen.lib import EventType, FamilyRelType + import unittest.mock as mock + + db = _make_db() + pmap = {} + for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): + p = Person() + p.set_gender(gender) + with DbTxn("s", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + + ev = Event() + ev.set_type(EventType(EventType.MARRIAGE)) + with DbTxn("s", db) as t: + db.add_event(ev, t) + + tagtypes = [self._father_type(1), self._mother_type(2)] + pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] + event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} + + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): + libtmg.import_families(db, 1, pmap, event_handle_map) + + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) + self.assertEqual(len(fam.get_event_ref_list()), 1) + + +# --------------------------------------------------------------------------- +# import_repositories — name, type inference, URL, notes +# --------------------------------------------------------------------------- + +class TestImportRepositories(unittest.TestCase): + + def _run(self, repo_records, per_no_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): + repo_map = libtmg.import_repositories(db, dataset, per_no_map) + return db, repo_map + + def _repo_rec(self, **kw): + defaults = dict(dsid=1, recno=1, name='City Library', + abbrev='', rnote='', rperno=0) + defaults.update(kw) + return _Rec(**defaults) + + def test_repository_created(self): + db, rmap = self._run([self._repo_rec()]) + self.assertEqual(db.get_number_of_repositories(), 1) + self.assertIn(1, rmap) + + def test_name_set(self): + db, rmap = self._run([self._repo_rec(name='National Archives')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'National Archives') + + def test_wrong_dataset_skipped(self): + db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_repositories(), 0) + + def test_type_inferred_from_name(self): + from gramps.gen.lib import RepositoryType + db, rmap = self._run([self._repo_rec(name='ancestry.com')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) + + def test_url_added_for_web_repo(self): + db, rmap = self._run([self._repo_rec(name='familysearch.org')]) + repo = db.get_repository_from_handle(rmap[1]) + urls = repo.get_url_list() + self.assertEqual(len(urls), 1) + self.assertIn('familysearch', urls[0].get_path()) + + def test_no_url_for_non_web_repo(self): + db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_url_list()), 0) + + def test_blank_name_falls_back_to_abbrev(self): + db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'TNA') + + def test_note_added_when_rnote_set(self): + db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_note_list()), 1) + note = db.get_note_from_handle(repo.get_note_list()[0]) + self.assertIn('Open Mon-Fri', note.get()) + + def test_returns_recno_to_handle_map(self): + db, rmap = self._run([self._repo_rec(recno=42)]) + self.assertIn(42, rmap) + + +# --------------------------------------------------------------------------- +# import_citations — creation and attachment to events / persons +# --------------------------------------------------------------------------- + +class TestImportCitations(unittest.TestCase): + + def _run(self, citation_records, names_records=None, pc_records=None, + source_handle_map=None, event_handle_map=None, per_no_map=None, + dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ + mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): + libtmg.import_citations(db, dataset, + source_handle_map=source_handle_map, + event_handle_map=event_handle_map, + per_no_map=per_no_map) + return db + + def _cit_rec(self, **kw): + defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, + exclude=False, subsource='', citref='', citmemo='', + sdsure='', snsure='', sssure='', spsure='', sfsure='') + defaults.update(kw) + return _Rec(**defaults) + + def test_no_source_map_is_noop(self): + db = self._run([self._cit_rec()]) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_created(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec()], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 1) + + def test_excluded_citation_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(exclude=True)], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_wrong_dataset_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(dsid=2)], + source_handle_map={1: src.get_handle()}, db=db, dataset=1) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_unknown_source_skipped(self): + db = _make_db() + db = self._run([self._cit_rec(majsource=99)], + source_handle_map={1: 'some_handle'}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_attached_to_event(self): + db = _make_db() + src = Source() + ev = Event() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_event(ev, t) + ev_handle = ev.get_handle() + + db = self._run([self._cit_rec(stype='E', refrec=7)], + source_handle_map={1: src.get_handle()}, + event_handle_map={7: (ev_handle, 1, 0, 0)}, + db=db) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(len(event.get_citation_list()), 1) + + def test_citation_attached_to_person_via_name(self): + db = _make_db() + src = Source() + p = Person() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_person(p, t) + phandle = p.get_handle() + + # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle + db = self._run( + [self._cit_rec(stype='N', refrec=3)], + names_records=[_Rec(dsid=1, recno=3, nper=5)], + source_handle_map={1: src.get_handle()}, + per_no_map={5: phandle}, + db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_citation_list()), 1) + + def test_subsource_becomes_page(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(subsource='p.42')], + source_handle_map={1: src.get_handle()}, db=db) + cit_handle = list(db.get_citation_handles())[0] + cit = db.get_citation_from_handle(cit_handle) + self.assertEqual(cit.get_page(), 'p.42') + + + +# ── TmgProject._read_pjc_config ────────────────────────────────────────── + +def _make_minimal_sqz(tmp_dir, pjc_content): + """Create a minimal .SQZ zip containing only a PJC file.""" + import zipfile as _zf + pjc_path = os.path.join(tmp_dir, 'test.pjc') + sqz_path = os.path.join(tmp_dir, 'test.sqz') + with open(pjc_path, 'w', encoding='latin-1') as f: + f.write(pjc_content) + with _zf.ZipFile(sqz_path, 'w') as zf: + zf.write(pjc_path, 'test.pjc') + return sqz_path + + +class _MockUser: + """Minimal stand-in for the Gramps user object used in importData.""" + def __init__(self): + self.error_shown = False + self.error_message = None + self.uistate = None + + def notify_error(self, title, message=''): + self.error_shown = True + self.error_message = message + + def begin_progress(self, *a, **kw): pass + def end_progress(self): pass + def step_progress(self): pass + + +class TestReadPjcConfig(unittest.TestCase): + """Tests for TmgProject._read_pjc_config PJC parsing.""" + + _MINIMAL_PJC = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Researcher]\n" + "Name=Test User\n" + ) + + def _make_project(self, pjc_content, tmp_path): + pjc_file = os.path.join(tmp_path, "test.pjc") + with open(pjc_file, 'w', encoding='latin-1') as f: + f.write(pjc_content) + return libtmg.TmgProject(pjc_file) + + def test_well_formed_pjc_returns_version(self): + """A clean PJC file parses successfully and version() returns a float.""" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(self._MINIMAL_PJC, tmp) + self.assertEqual(project.version(), 11.0) + + def test_malformed_section_header_does_not_raise(self): + """Lines like '[Exho' (no closing bracket) are silently dropped.""" + pjc = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Exho\n" # malformed — the crash trigger + "SomeGarbage\n" + "[Researcher]\n" + "Name=Test User\n" + ) + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + # Must not raise; must still find [Stamp] + self.assertEqual(project.version(), 11.0) + + def test_null_bytes_stripped(self): + """NUL bytes in the PJC file are stripped before parsing.""" + pjc = "[Stamp]\x00\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + self.assertEqual(project.version(), 11.0) + + def test_parse_error_returns_partial_config(self): + """If configparser still raises after filtering, a warning is logged + and a (possibly empty) config object is returned rather than crashing.""" + import logging + # Feed content that survives the filter but still breaks configparser: + # a key=value line before any section header is technically invalid. + pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: + cfg = project._read_pjc_config() + self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() + for m in cm.output)) + # Config object is returned (not None), even if incomplete + self.assertIsNotNone(cfg) + + def test_version_too_old_notifies_user(self): + """A PJC version < 11.0 calls user.notify_error and aborts import.""" + pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for old version") + self.assertIn('9.05', user.error_message or '', + "Error message should mention TMG 9.05") + + def test_missing_pjc_version_notifies_user(self): + """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" + pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for missing version") + diff --git a/TMGimporter/tests/test_integration_libtmg.py b/TMGimporter/tests/test_integration_libtmg.py deleted file mode 100644 index d4c4810f5..000000000 --- a/TMGimporter/tests/test_integration_libtmg.py +++ /dev/null @@ -1,1214 +0,0 @@ -"""Integration tests for libtmg.py — require a Gramps SQLite database. - -Split from test_libtmg.py because in-memory Gramps DB init hangs on Windows -under the pip-installed Gramps + conda-forge GTK stack. Linux CI picks these -up via the test_integration*.py glob. - -Run with: - python -m pytest tests/test_integration_libtmg.py - -Gramps must be installed. The DBF-dependent import functions are tested by -patching the module-level table globals so no real .dbf files are needed. -""" - -import sys -import os -import tempfile -import unittest - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import libtmg - -from gramps.gen.lib import Event, NoteType, Person, Place, Source -from gramps.gen.db.utils import make_database -from gramps.gen.db import DbTxn - - -# --------------------------------------------------------------------------- -# Helpers shared across test cases -# --------------------------------------------------------------------------- - -class _Rec: - """Minimal fake DBF record — set any field via keyword arguments.""" - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - -def _table(records): - """Return an object that behaves like a dbf.Table used as a context manager. - - libtmg uses tables in two ways: - with tmgFoo: - for record in tmgFoo: # iterates over context-managed table - """ - class _FakeTable: - def __enter__(self): - return self - def __exit__(self, *_): - return False - def __iter__(self): - return iter(records) - return _FakeTable() - - -def _make_db(): - """Return a fresh in-memory Gramps database.""" - db = make_database("sqlite") - db.load(":memory:", None) - return db - - -def _add_person(db): - """Add an empty Person to db and return (db, handle).""" - p = Person() - with DbTxn("setup", db) as t: - db.add_person(p, t) - return db, p.get_handle() - - -# --------------------------------------------------------------------------- -# import_notes — patches the module-level DBF table globals -# --------------------------------------------------------------------------- - -class TestImportNotes(unittest.TestCase): - - def _patch(self, tagtypes_records, events_records): - """Patch libtmg globals and return a context manager.""" - import unittest.mock as mock - patches = [ - mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), - mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), - ] - return patches - - def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - libtmg.import_notes(db, dataset, per_no_map) - return db - - def test_no_per_no_map_is_noop(self): - """Passing per_no_map=None must not touch any table.""" - import unittest.mock as mock - db = _make_db() - mock_table = mock.MagicMock() - with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ - mock.patch('libtmg.tmgEvents', mock_table, create=True): - libtmg.import_notes(db, 1, per_no_map=None) - mock_table.__enter__.assert_not_called() - - def test_note_attached_to_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {42: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, - efoot='Born in London')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 1) - - def test_note_text_stored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot=' Some note text ')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'Some note text') - - def test_note_type_is_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='hello')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get_type(), NoteType.PERSON) - - def test_tmg_codes_stripped_from_note(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='[:ITAL:]italicised[:ITAL:]')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'italicised') - - def test_empty_note_text_skipped(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 0) - - def test_unknown_person_skipped(self): - db = _make_db() - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, - efoot='orphan note')], - per_no_map={}, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_non_note_etype_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - # etype=10 is not a Note type - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot='should be ignored')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_wrong_dataset_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, - efoot='wrong dataset')], - per_no_map=per_no_map, dataset=1, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_multiple_notes_for_one_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[ - _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), - _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), - ], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 2) - - def test_no_note_tag_type_defined(self): - """If the dataset has no 'Note' tag type, nothing is imported.""" - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[], # no tag types at all - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='orphan')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - -# --------------------------------------------------------------------------- -# trial_events — event import and Note-etype skip -# --------------------------------------------------------------------------- - -# A minimal raw date string for an exact date (1900-06-15) -_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' -_EMPTY_DATE = '' - - -class TestTrialEvents(unittest.TestCase): - - def _run(self, tagtypes_records, events_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - handle_map = libtmg.import_events(db, dataset) - return db, handle_map - - def test_regular_event_creates_db_entry(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertIn(1, hmap) - - def test_handle_map_tuple_has_four_elements(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, - placenum=7, edate=_EMPTY_DATE, efoot='')] - _, hmap = self._run(_tagtypes, _events) - entry = hmap[5] - self.assertEqual(len(entry), 4) - _handle, per1, per2, placenum = entry - self.assertEqual(per1, 3) - self.assertEqual(per2, 0) - self.assertEqual(placenum, 7) - - def test_note_etype_event_not_in_handle_map(self): - _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] - _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 0) - self.assertNotIn(1, hmap) - - def test_event_memo_stored_as_description(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_memo_tmg_codes_stripped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, - efoot='[:CR:]born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_date_set(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EXACT_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - d = event.get_date_object() - self.assertEqual(d.get_year(), 1900) - self.assertEqual(d.get_month(), 6) - - def test_wrong_dataset_skipped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events, dataset=1) - self.assertEqual(db.get_number_of_events(), 0) - - def test_mixed_note_and_regular_events(self): - _tagtypes = [ - _Rec(dsid=1, etypenum=77, etypename='Note'), - _Rec(dsid=1, etypenum=10, etypename='Birth'), - ] - _events = [ - _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note'), - _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot=''), - ] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertNotIn(1, hmap) - self.assertIn(2, hmap) - - -# --------------------------------------------------------------------------- -# import_sources — info-field parsing and author/publication split -# --------------------------------------------------------------------------- - -class TestImportSources(unittest.TestCase): - - def _run(self, src_components, src_repo_links, sources_records, - repo_handle_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ - mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ - mock.patch('libtmg.tmgSources', _table(sources_records), create=True): - smap = libtmg.import_sources(db, dataset, repo_handle_map) - return db, smap - - def _source_rec(self, **kw): - defaults = dict(dsid=1, majnum=1, mactive=True, - title='Test Source', abbrev='', info='', - text='', fform='', sform='', bform='', reminders='') - defaults.update(kw) - return _Rec(**defaults) - - def test_source_created(self): - db, smap = self._run([], [], [self._source_rec()]) - self.assertEqual(db.get_number_of_sources(), 1) - self.assertIn(1, smap) - - def test_title_set(self): - db, smap = self._run([], [], [self._source_rec(title='My Source')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_title(), 'My Source') - - def test_abbreviation_set(self): - db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_abbreviation(), 'MySrc') - - def test_inactive_source_skipped(self): - db, smap = self._run([], [], [self._source_rec(mactive=False)]) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_wrong_dataset_skipped(self): - db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_author_element_sets_author(self): - # recno 1 → position 0 in $!& split - components = [_Rec(recno=1, element='[AUTHOR]')] - rec = self._source_rec(info='John Smith') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'John Smith') - - def test_non_author_element_sets_publication_info(self): - components = [_Rec(recno=1, element='[TITLE]')] - rec = self._source_rec(info='Some Title') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertIn('TITLE', src.get_publication_info()) - self.assertIn('Some Title', src.get_publication_info()) - - def test_multiple_authors_joined_with_semicolon(self): - # positions 0 and 1 → recno 1 and 2 - components = [ - _Rec(recno=1, element='[AUTHOR]'), - _Rec(recno=2, element='[EDITOR]'), - ] - rec = self._source_rec(info='Alice$!&Bob') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Alice; Bob') - - def test_empty_info_position_skipped(self): - # position 0 empty, position 1 filled → recno 2 = [AUTHOR] - components = [ - _Rec(recno=1, element='[TITLE]'), - _Rec(recno=2, element='[AUTHOR]'), - ] - rec = self._source_rec(info='$!&Jane Doe') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Jane Doe') - self.assertEqual(src.get_publication_info(), '') - - def test_note_fields_become_notes(self): - rec = self._source_rec(text='original text', fform='', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 1) - note = db.get_note_from_handle(src.get_note_list()[0]) - self.assertIn('original text', note.get()) - - def test_multiple_note_fields_each_become_a_note(self): - rec = self._source_rec(text='txt', fform='fn', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 2) - - -# --------------------------------------------------------------------------- -# import_places — name reconstruction, type resolution, note parts -# --------------------------------------------------------------------------- - -class TestImportPlaces(unittest.TestCase): - - def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ - mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ - mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ - mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - pmap = libtmg.import_places(db, dataset) - return db, pmap - - # Convenience: build part_type, place_dict, ppv records for a single place - def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): - """ - parts: list of (label, value) e.g. [('City','London'),('Country','UK')] - Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) - """ - part_type_recs = [] - place_dict_recs = [] - ppv_recs = [] - for i, (label, value) in enumerate(parts): - type_id = i + 1 - uid = i + 100 - part_type_recs.append(_Rec(type=type_id, value=label)) - place_dict_recs.append(_Rec(uid=uid, value=value)) - ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) - place_recs = [_Rec(dsid=dataset, recno=recno, - shortplace=shortplace, comment=comment)] - return part_type_recs, place_dict_recs, ppv_recs, place_recs - - def test_city_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'London') - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_country_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.COUNTRY) - - def test_city_state_country_name_order(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - # GEO_ORDER: Addressee, Detail, City, County, State, Country - self.assertEqual(place.get_name().get_value(), - 'Paris, Île-de-France, France') - - def test_most_specific_type_wins(self): - # City is more specific than Country in _GEO_ORDER - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Berlin'), ('Country', 'Germany') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_empty_place_skipped(self): - # No parts and no shortplace → nothing imported - place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertEqual(db.get_number_of_places(), 0) - self.assertNotIn(1, pmap) - - def test_shortplace_fallback(self): - # No parts but shortplace set → use it - place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'Somewhere') - - def test_note_parts_go_to_note(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Rome'), ('Postal', '00100') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(len(place.get_note_list()), 1) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('Postal', note.get()) - self.assertIn('00100', note.get()) - - def test_comment_goes_to_note(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('see also', note.get()) - - def test_wrong_dataset_skipped(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) - db, pmap = self._run(pt, pd, ppv, pl, dataset=1) - self.assertEqual(db.get_number_of_places(), 0) - - def test_returns_recno_to_handle_map(self): - pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(42, pmap) - - -# --------------------------------------------------------------------------- -# link_event_places — event gets its place handle set -# --------------------------------------------------------------------------- - -class TestLinkEventPlaces(unittest.TestCase): - - def _make_event(self, db): - from gramps.gen.db import DbTxn - ev = Event() - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def _make_place(self, db): - from gramps.gen.db import DbTxn - pl = Place() - with DbTxn("setup", db) as t: - db.add_place(pl, t) - return pl.get_handle() - - def test_place_linked_to_event(self): - db = _make_db() - ev_handle = self._make_event(db) - pl_handle = self._make_place(db) - event_handle_map = {1: (ev_handle, 1, 0, 7)} - place_handle_map = {7: pl_handle} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), pl_handle) - - def test_zero_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 0)} - place_handle_map = {0: self._make_place(db)} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_unknown_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 99)} - libtmg.link_event_places(db, event_handle_map, {}) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_event_places(db, {}, {}) # must not raise - libtmg.link_event_places(db, None, None) - - -# --------------------------------------------------------------------------- -# Lookup helpers: short_place_name, tag_type_name -# --------------------------------------------------------------------------- - -class TestShortPlaceName(unittest.TestCase): - - def _run(self, places_records, placenum, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - return libtmg.short_place_name(db, placenum, dataset) - - def test_returns_shortplace(self): - rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=5), 'New York') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=1), 'London') - - def test_wrong_recno_returns_none(self): - rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=1, dataset=1)) - - -class TestTagTypeName(unittest.TestCase): - - def _run(self, tagtypes_records, eventtype, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): - return libtmg.tag_type_name(db, eventtype, dataset) - - def test_returns_name(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') - self.assertEqual(self._run([rec], eventtype=2), 'Birth') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, etypenum=3, etypename='Death ') - self.assertEqual(self._run([rec], eventtype=3), 'Death') - - def test_wrong_eventtype_returns_none(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) - - -# --------------------------------------------------------------------------- -# import_people — name parsing, gender, dataset filter -# --------------------------------------------------------------------------- - -class TestImportPeople(unittest.TestCase): - - def _run(self, names_records, people_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ - mock.patch('libtmg.tmgPeople', _table(people_records), create=True): - per_no_map = libtmg.import_people(db, dataset) - return db, per_no_map - - def _name_rec(self, **kw): - defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') - defaults.update(kw) - return _Rec(**defaults) - - def _person_rec(self, **kw): - defaults = dict(dsid=1, per_no=1, sex='M') - defaults.update(kw) - return _Rec(**defaults) - - def test_person_created(self): - db, pmap = self._run([self._name_rec()], [self._person_rec()]) - self.assertEqual(db.get_number_of_people(), 1) - - def test_returns_per_no_map(self): - db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) - self.assertIn(5, pmap) - - def test_surname_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'JONES') - - def test_given_name_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') - - def test_male_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.MALE) - - def test_female_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.FEMALE) - - def test_unknown_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.UNKNOWN) - - def test_non_primary_name_skipped(self): - db, pmap = self._run( - [self._name_rec(primary=False, srnamedisp='ALT, Name')], - [self._person_rec()] - ) - self.assertEqual(db.get_number_of_people(), 0) - - def test_wrong_dataset_skipped(self): - db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], - dataset=1) - self.assertEqual(db.get_number_of_people(), 0) - - def test_no_comma_surname_only(self): - # srnamedisp with no comma → surname=full string, given='' - db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], - [self._person_rec()]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') - self.assertEqual(p.get_primary_name().get_first_name(), '') - - -# --------------------------------------------------------------------------- -# link_person_events — EventRefs, birth/death special refs -# --------------------------------------------------------------------------- - -class TestLinkPersonEvents(unittest.TestCase): - - def _make_typed_event(self, db, event_type_int): - from gramps.gen.lib import EventType - ev = Event() - ev.set_type(EventType(event_type_int)) - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def test_individual_event_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.OCCUPATION) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 1) - - def test_couple_event_not_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.MARRIAGE) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 2, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 0) - - def test_birth_event_sets_birth_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.BIRTH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_birth_ref()) - self.assertEqual(p.get_birth_ref().ref, ev_handle) - - def test_death_event_sets_death_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.DEATH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_death_ref()) - - def test_unknown_person_skipped(self): - from gramps.gen.lib import EventType - db = _make_db() - ev_handle = self._make_typed_event(db, EventType.BIRTH) - # Must not raise even when per1 has no entry in per_no_map - libtmg.link_person_events(db, - per_no_map={}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_person_events(db, None, None) - libtmg.link_person_events(db, {}, {}) - - -# --------------------------------------------------------------------------- -# import_families — parent-child grouping, couple events, rel type -# --------------------------------------------------------------------------- - -class TestImportFamilies(unittest.TestCase): - - def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, - dataset=1): - """Create persons from per_no_by_gender={per_no: gender}, run import.""" - import unittest.mock as mock - db = _make_db() - pmap = {} - for per_no, gender in per_no_by_gender.items(): - p = Person() - p.set_gender(gender) - with DbTxn("setup", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): - libtmg.import_families(db, dataset, pmap, event_handle_map) - return db, pmap - - def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): - return _Rec(dsid=dsid, parent=parent, child=child, - ptype=ptype, primary=primary, pnote=pnote) - - def _father_type(self, num=1): - return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') - - def _mother_type(self, num=2): - return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') - - def test_father_child_creates_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - - def test_mother_child_creates_family(self): - db, pmap = self._run([self._mother_type()], - [self._pc(1, 2, ptype=2)], - {1: Person.FEMALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_mother_handle(), pmap[1]) - - def test_father_and_mother_same_family(self): - db, pmap = self._run( - [self._father_type(1), self._mother_type(2)], - [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], - {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, - ) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - self.assertEqual(fam.get_mother_handle(), pmap[2]) - - def test_child_added_to_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(len(fam.get_child_ref_list()), 1) - self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) - - def test_child_ref_type_biological(self): - from gramps.gen.lib import ChildRefType - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), - ChildRefType.BIRTH) - - def test_wrong_dataset_skipped(self): - db, _ = self._run([self._father_type()], - [self._pc(1, 2, ptype=1, dsid=2)], - {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) - self.assertEqual(db.get_number_of_families(), 0) - - def test_no_per_no_map_is_noop(self): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): - libtmg.import_families(db, 1, per_no_map=None) - self.assertEqual(db.get_number_of_families(), 0) - - def test_marriage_event_sets_rel_type_married(self): - from gramps.gen.lib import EventType, FamilyRelType - import unittest.mock as mock - - db = _make_db() - pmap = {} - for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): - p = Person() - p.set_gender(gender) - with DbTxn("s", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - - ev = Event() - ev.set_type(EventType(EventType.MARRIAGE)) - with DbTxn("s", db) as t: - db.add_event(ev, t) - - tagtypes = [self._father_type(1), self._mother_type(2)] - pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] - event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} - - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): - libtmg.import_families(db, 1, pmap, event_handle_map) - - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) - self.assertEqual(len(fam.get_event_ref_list()), 1) - - -# --------------------------------------------------------------------------- -# import_repositories — name, type inference, URL, notes -# --------------------------------------------------------------------------- - -class TestImportRepositories(unittest.TestCase): - - def _run(self, repo_records, per_no_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): - repo_map = libtmg.import_repositories(db, dataset, per_no_map) - return db, repo_map - - def _repo_rec(self, **kw): - defaults = dict(dsid=1, recno=1, name='City Library', - abbrev='', rnote='', rperno=0) - defaults.update(kw) - return _Rec(**defaults) - - def test_repository_created(self): - db, rmap = self._run([self._repo_rec()]) - self.assertEqual(db.get_number_of_repositories(), 1) - self.assertIn(1, rmap) - - def test_name_set(self): - db, rmap = self._run([self._repo_rec(name='National Archives')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'National Archives') - - def test_wrong_dataset_skipped(self): - db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_repositories(), 0) - - def test_type_inferred_from_name(self): - from gramps.gen.lib import RepositoryType - db, rmap = self._run([self._repo_rec(name='ancestry.com')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) - - def test_url_added_for_web_repo(self): - db, rmap = self._run([self._repo_rec(name='familysearch.org')]) - repo = db.get_repository_from_handle(rmap[1]) - urls = repo.get_url_list() - self.assertEqual(len(urls), 1) - self.assertIn('familysearch', urls[0].get_path()) - - def test_no_url_for_non_web_repo(self): - db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_url_list()), 0) - - def test_blank_name_falls_back_to_abbrev(self): - db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'TNA') - - def test_note_added_when_rnote_set(self): - db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_note_list()), 1) - note = db.get_note_from_handle(repo.get_note_list()[0]) - self.assertIn('Open Mon-Fri', note.get()) - - def test_returns_recno_to_handle_map(self): - db, rmap = self._run([self._repo_rec(recno=42)]) - self.assertIn(42, rmap) - - -# --------------------------------------------------------------------------- -# import_citations — creation and attachment to events / persons -# --------------------------------------------------------------------------- - -class TestImportCitations(unittest.TestCase): - - def _run(self, citation_records, names_records=None, pc_records=None, - source_handle_map=None, event_handle_map=None, per_no_map=None, - dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ - mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): - libtmg.import_citations(db, dataset, - source_handle_map=source_handle_map, - event_handle_map=event_handle_map, - per_no_map=per_no_map) - return db - - def _cit_rec(self, **kw): - defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, - exclude=False, subsource='', citref='', citmemo='', - sdsure='', snsure='', sssure='', spsure='', sfsure='') - defaults.update(kw) - return _Rec(**defaults) - - def test_no_source_map_is_noop(self): - db = self._run([self._cit_rec()]) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_created(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec()], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 1) - - def test_excluded_citation_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(exclude=True)], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_wrong_dataset_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(dsid=2)], - source_handle_map={1: src.get_handle()}, db=db, dataset=1) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_unknown_source_skipped(self): - db = _make_db() - db = self._run([self._cit_rec(majsource=99)], - source_handle_map={1: 'some_handle'}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_attached_to_event(self): - db = _make_db() - src = Source() - ev = Event() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_event(ev, t) - ev_handle = ev.get_handle() - - db = self._run([self._cit_rec(stype='E', refrec=7)], - source_handle_map={1: src.get_handle()}, - event_handle_map={7: (ev_handle, 1, 0, 0)}, - db=db) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(len(event.get_citation_list()), 1) - - def test_citation_attached_to_person_via_name(self): - db = _make_db() - src = Source() - p = Person() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_person(p, t) - phandle = p.get_handle() - - # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle - db = self._run( - [self._cit_rec(stype='N', refrec=3)], - names_records=[_Rec(dsid=1, recno=3, nper=5)], - source_handle_map={1: src.get_handle()}, - per_no_map={5: phandle}, - db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_citation_list()), 1) - - def test_subsource_becomes_page(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(subsource='p.42')], - source_handle_map={1: src.get_handle()}, db=db) - cit_handle = list(db.get_citation_handles())[0] - cit = db.get_citation_from_handle(cit_handle) - self.assertEqual(cit.get_page(), 'p.42') - - - -# ── TmgProject._read_pjc_config ────────────────────────────────────────── - -def _make_minimal_sqz(tmp_dir, pjc_content): - """Create a minimal .SQZ zip containing only a PJC file.""" - import zipfile as _zf - pjc_path = os.path.join(tmp_dir, 'test.pjc') - sqz_path = os.path.join(tmp_dir, 'test.sqz') - with open(pjc_path, 'w', encoding='latin-1') as f: - f.write(pjc_content) - with _zf.ZipFile(sqz_path, 'w') as zf: - zf.write(pjc_path, 'test.pjc') - return sqz_path - - -class _MockUser: - """Minimal stand-in for the Gramps user object used in importData.""" - def __init__(self): - self.error_shown = False - self.error_message = None - self.uistate = None - - def notify_error(self, title, message=''): - self.error_shown = True - self.error_message = message - - def begin_progress(self, *a, **kw): pass - def end_progress(self): pass - def step_progress(self): pass - - -class TestReadPjcConfig(unittest.TestCase): - """Tests for TmgProject._read_pjc_config PJC parsing.""" - - _MINIMAL_PJC = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Researcher]\n" - "Name=Test User\n" - ) - - def _make_project(self, pjc_content, tmp_path): - pjc_file = os.path.join(tmp_path, "test.pjc") - with open(pjc_file, 'w', encoding='latin-1') as f: - f.write(pjc_content) - return libtmg.TmgProject(pjc_file) - - def test_well_formed_pjc_returns_version(self): - """A clean PJC file parses successfully and version() returns a float.""" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(self._MINIMAL_PJC, tmp) - self.assertEqual(project.version(), 11.0) - - def test_malformed_section_header_does_not_raise(self): - """Lines like '[Exho' (no closing bracket) are silently dropped.""" - pjc = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Exho\n" # malformed — the crash trigger - "SomeGarbage\n" - "[Researcher]\n" - "Name=Test User\n" - ) - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - # Must not raise; must still find [Stamp] - self.assertEqual(project.version(), 11.0) - - def test_null_bytes_stripped(self): - """NUL bytes in the PJC file are stripped before parsing.""" - pjc = "[Stamp]\x00\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - self.assertEqual(project.version(), 11.0) - - def test_parse_error_returns_partial_config(self): - """If configparser still raises after filtering, a warning is logged - and a (possibly empty) config object is returned rather than crashing.""" - import logging - # Feed content that survives the filter but still breaks configparser: - # a key=value line before any section header is technically invalid. - pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: - cfg = project._read_pjc_config() - self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() - for m in cm.output)) - # Config object is returned (not None), even if incomplete - self.assertIsNotNone(cfg) - - def test_version_too_old_notifies_user(self): - """A PJC version < 11.0 calls user.notify_error and aborts import.""" - pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for old version") - self.assertIn('9.05', user.error_message or '', - "Error message should mention TMG 9.05") - - def test_missing_pjc_version_notifies_user(self): - """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" - pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for missing version") - -if __name__ == '__main__': - unittest.main()