Skip to content

Commit 991a732

Browse files
authored
Merge pull request #1664 from kaixxx/master
MacOS: Fix merge dialog
2 parents 6488138 + 9167bfd commit 991a732

3 files changed

Lines changed: 177 additions & 12 deletions

File tree

‎src/qualcoder/special_functions.py‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
https://qualcoder.org/
2222
"""
2323

24+
from contextlib import closing
2425
import logging
2526
from pathlib import Path
27+
import sqlite3
2628

2729
import qtawesome as qta # see: https://pictogrammers.com/library/mdi/
2830

@@ -101,25 +103,48 @@ def keyPressEvent(self, event):
101103
return
102104

103105
# Functions to merge external project into this project
104-
def select_project_folder(self):
105-
""" Select another .qda project """
106+
def select_project_folder(self) -> None:
107+
"""Select and validate another .qda project for merging."""
106108

107109
self.merge_project_path = ""
110+
self.ui.pushButton_merge.setEnabled(False)
108111
default_directory = self.app.settings['directory']
109112
if default_directory == "":
110113
default_directory = Path('~').expanduser()
111-
self.merge_project_path = QtWidgets.QFileDialog.getExistingDirectory(self,
112-
_('Open project directory'),
113-
default_directory)
114-
if self.merge_project_path is False or len(self.merge_project_path) < 5:
115-
Message(self.app, _("Error"), _("No project selected")).exec()
116-
return
117-
if self.merge_project_path[-4:] != ".qda":
118-
Message(self.app, _("Error"), _("Not a QualCoder project")).exec()
114+
selected_path = QtWidgets.QFileDialog.getExistingDirectory(
115+
self, _('Open project directory'), default_directory
116+
)
117+
if not selected_path:
119118
return
120-
if self.merge_project_path == self.app.project_path:
121-
Message(self.app, _("Error"), _("The same project")).exec()
119+
project_directory = Path(selected_path)
120+
error_message = ""
121+
try:
122+
if project_directory.suffix.lower() != ".qda":
123+
error_message = _("The selected folder is not a QualCoder project folder. "
124+
"Its name must end with .qda.")
125+
elif not project_directory.is_dir():
126+
error_message = _("The project folder could not be found or accessed.")
127+
elif project_directory.samefile(self.app.project_path):
128+
error_message = _("The same project")
129+
elif not (project_directory / "data.qda").is_file():
130+
error_message = _("No data.qda database found in the selected folder.")
131+
else:
132+
# Read-only mode prevents validation from creating or changing a database.
133+
database_uri = (project_directory / "data.qda").resolve().as_uri() + "?mode=ro"
134+
with closing(sqlite3.connect(database_uri, uri=True)) as connection:
135+
result = connection.execute(
136+
"select databaseversion, date, memo, about from project"
137+
).fetchone()
138+
if result is None or not isinstance(result[3], str) or "QualCoder" not in result[3]:
139+
error_message = _("Not a QualCoder project")
140+
except sqlite3.DatabaseError as err:
141+
error_message = _("The project database could not be opened.") + f"\n{err}"
142+
except OSError as err:
143+
error_message = _("The project folder could not be found or accessed.") + f"\n{err}"
144+
if error_message:
145+
Message(self.app, _("Error"), error_message).exec()
122146
return
147+
self.merge_project_path = project_directory.as_posix()
123148
msg = _("Merge") + f"\n{self.merge_project_path}\n" + _("into") + f"\n{self.app.project_path}\n"
124149
msg += _("Press Run Button to merge projects")
125150
Message(self.app, _("Merge projects"), msg).exec()
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Regression tests for selecting a project to merge."""
2+
3+
from contextlib import ExitStack
4+
from pathlib import Path
5+
import sqlite3
6+
import tempfile
7+
from types import SimpleNamespace
8+
from unittest import TestCase
9+
from unittest.mock import MagicMock, call, patch
10+
11+
from qualcoder.special_functions import DialogSpecialFunctions
12+
13+
14+
class TestMergeProjectSelection(TestCase):
15+
16+
def setUp(self):
17+
temporary_directory = tempfile.TemporaryDirectory()
18+
self.addCleanup(temporary_directory.cleanup)
19+
self.directory = Path(temporary_directory.name)
20+
self.current_project = self.directory / "current.qda"
21+
self.current_project.mkdir()
22+
self.dialog = SimpleNamespace(
23+
app=SimpleNamespace(
24+
settings={"directory": ""},
25+
project_path=self.current_project.as_posix(),
26+
),
27+
merge_project_path="previous.qda",
28+
ui=SimpleNamespace(pushButton_merge=MagicMock()),
29+
)
30+
stack = self.enterContext(ExitStack())
31+
stack.enter_context(patch("builtins._", side_effect=lambda text: text, create=True))
32+
self.picker = stack.enter_context(patch(
33+
"qualcoder.special_functions.QtWidgets.QFileDialog.getExistingDirectory"
34+
))
35+
self.message = stack.enter_context(patch("qualcoder.special_functions.Message"))
36+
37+
def create_project(self, name: str = "source.qda", about: str = "QualCoder test") -> Path:
38+
"""Create a minimal project with the requested folder name and identity."""
39+
project_directory = self.directory / name
40+
project_directory.mkdir()
41+
with sqlite3.connect(project_directory / "data.qda") as connection:
42+
connection.execute(
43+
"create table project (databaseversion text, date text, memo text, about text)"
44+
)
45+
connection.execute("insert into project values ('v17', '', '', ?)", (about,))
46+
connection.close()
47+
return project_directory
48+
49+
def select(self, selected_path: str) -> None:
50+
"""Simulate selecting a folder in the merge dialog."""
51+
self.picker.return_value = selected_path
52+
DialogSpecialFunctions.select_project_folder(self.dialog)
53+
54+
def assert_rejected(self, expected_message: str) -> None:
55+
"""Check that a rejected selection cannot run a stale merge."""
56+
self.assertEqual("", self.dialog.merge_project_path)
57+
self.dialog.ui.pushButton_merge.setEnabled.assert_called_once_with(False)
58+
self.message.assert_called_once()
59+
self.assertIn(expected_message, self.message.call_args.args[2])
60+
61+
def test_trailing_separator_and_uppercase_suffix_are_accepted(self):
62+
project_directory = self.create_project("source.QDA")
63+
self.select(project_directory.as_posix() + "/")
64+
self.assertEqual(project_directory.as_posix(), self.dialog.merge_project_path)
65+
self.assertEqual(
66+
[call(False), call(True)],
67+
self.dialog.ui.pushButton_merge.setEnabled.call_args_list,
68+
)
69+
70+
def test_cancel_clears_previous_selection_without_error(self):
71+
self.select("")
72+
self.assertEqual("", self.dialog.merge_project_path)
73+
self.dialog.ui.pushButton_merge.setEnabled.assert_called_once_with(False)
74+
self.message.assert_not_called()
75+
76+
def test_wrong_suffix_is_rejected(self):
77+
self.select(self.directory.as_posix())
78+
self.assert_rejected("name must end with .qda")
79+
80+
def test_missing_folder_is_rejected(self):
81+
self.select((self.directory / "missing.qda").as_posix())
82+
self.assert_rejected("could not be found or accessed")
83+
84+
def test_same_project_with_trailing_separator_is_rejected(self):
85+
self.select(self.current_project.as_posix() + "/")
86+
self.assert_rejected("The same project")
87+
88+
def test_same_project_with_relative_components_is_rejected(self):
89+
alias_path = self.current_project / ".." / self.current_project.name
90+
self.select(alias_path.as_posix())
91+
self.assert_rejected("The same project")
92+
93+
def test_missing_database_is_not_created(self):
94+
project_directory = self.directory / "empty.qda"
95+
project_directory.mkdir()
96+
self.select(project_directory.as_posix())
97+
self.assert_rejected("No data.qda database found")
98+
self.assertFalse((project_directory / "data.qda").exists())
99+
100+
def test_corrupt_database_is_rejected_without_changes(self):
101+
project_directory = self.directory / "corrupt.qda"
102+
project_directory.mkdir()
103+
database_path = project_directory / "data.qda"
104+
database_path.write_bytes(b"not a database")
105+
self.select(project_directory.as_posix())
106+
self.assert_rejected("database could not be opened")
107+
self.assertEqual(b"not a database", database_path.read_bytes())
108+
109+
def test_unrelated_database_is_rejected(self):
110+
self.select(self.create_project(about="Another application").as_posix())
111+
self.assert_rejected("Not a QualCoder project")
112+
113+
def test_empty_project_table_is_rejected(self):
114+
project_directory = self.create_project()
115+
with sqlite3.connect(project_directory / "data.qda") as connection:
116+
connection.execute("delete from project")
117+
connection.close()
118+
self.select(project_directory.as_posix())
119+
self.assert_rejected("Not a QualCoder project")
120+
121+
def test_database_access_error_is_reported(self):
122+
project_directory = self.create_project()
123+
with patch("qualcoder.special_functions.sqlite3.connect",
124+
side_effect=sqlite3.OperationalError("unable to open database file")):
125+
self.select(project_directory.as_posix())
126+
self.assert_rejected("unable to open database file")
127+
128+
def test_validation_opens_database_read_only_and_closes_connection(self):
129+
project_directory = self.create_project("source #1.qda")
130+
with patch("qualcoder.special_functions.sqlite3.connect") as connect:
131+
connection = connect.return_value
132+
connection.execute.return_value.fetchone.return_value = ("v17", "", "", "QualCoder")
133+
self.select(project_directory.as_posix())
134+
connect.assert_called_once_with(
135+
(project_directory / "data.qda").resolve().as_uri() + "?mode=ro", uri=True
136+
)
137+
connection.close.assert_called_once()
138+
self.assertEqual(project_directory.as_posix(), self.dialog.merge_project_path)

‎tests/test_project_opening.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def _window_with_open_project() -> tuple[SimpleNamespace, object, object]:
4141
journal_display=journal_display,
4242
close_project=MagicMock(),
4343
_open_project_connection=MainWindow._open_project_connection,
44+
_show_project_open_progress=MagicMock(),
4445
)
46+
window._open_project_steps = MainWindow._open_project_steps.__get__(window)
4547
return window, existing_connection, journal_display
4648

4749
def test_trailing_separator_is_normalized_before_validation(self):

0 commit comments

Comments
 (0)