Skip to content

Commit 806173f

Browse files
committed
Merge branch 'testing'
2 parents 69be1d8 + d8d3abe commit 806173f

8 files changed

Lines changed: 2080 additions & 2 deletions

File tree

docs/plans/2026-02-21-qt-implementation-fixes.md

Lines changed: 706 additions & 0 deletions
Large diffs are not rendered by default.

docs/plans/2026-02-21-test-coverage-plan.md

Lines changed: 574 additions & 0 deletions
Large diffs are not rendered by default.

docs/plans/2026-02-21-test-coverage.md

Lines changed: 495 additions & 0 deletions
Large diffs are not rendered by default.

src/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def create_application() -> MainWindow:
3232
return MainWindow(viewmodel)
3333

3434

35-
def main() -> None:
35+
def main() -> None: # pragma: no cover
3636
"""Application entry point."""
3737
logger.info("Starting TextTools")
3838
app = QApplication(sys.argv)
@@ -46,5 +46,5 @@ def main() -> None:
4646
sys.exit(app.exec())
4747

4848

49-
if __name__ == "__main__":
49+
if __name__ == "__main__": # pragma: no cover
5050
main()

tests/integration/test_main_window.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import pytest
66

7+
from src.utils.constants import APP_VERSION
8+
79
from src.models.cleaning_options import CleaningOptions
810
from src.models.text_document import TextDocument
911
from src.viewmodels.main_viewmodel import MainViewModel
@@ -108,3 +110,228 @@ def test_merge_tab_has_coming_soon_label(self, window):
108110
label = window.ui.findChild(QLabel, "mergeComingSoonLabel")
109111
assert label is not None
110112
assert "coming soon" in label.text().lower()
113+
114+
115+
class TestRequireHelper:
116+
def test_raises_for_none_widget(self):
117+
from src.views.main_window import _require
118+
119+
with pytest.raises(RuntimeError, match="Required widget 'myWidget'"):
120+
_require(None, "myWidget")
121+
122+
def test_returns_widget_when_present(self):
123+
from src.views.main_window import _require
124+
125+
sentinel = object()
126+
assert _require(sentinel, "any") is sentinel
127+
128+
129+
class TestWindowShow:
130+
def test_show_does_not_raise(self, window):
131+
# Covers MainWindow.show() — line 81
132+
window.show()
133+
134+
135+
class TestLoadUiErrors:
136+
def test_raises_if_ui_file_unreadable(self, monkeypatch, qapp):
137+
from unittest.mock import MagicMock
138+
from src.views.main_window import MainWindow
139+
140+
monkeypatch.setattr(
141+
"src.views.main_window.QFile.open", lambda *_: False
142+
)
143+
with pytest.raises(RuntimeError, match="Cannot open UI file"):
144+
MainWindow(MagicMock())
145+
146+
def test_raises_if_loader_returns_none(self, monkeypatch, qapp):
147+
from unittest.mock import MagicMock
148+
from src.views.main_window import MainWindow
149+
150+
monkeypatch.setattr(
151+
"src.views.main_window.QUiLoader.load", lambda *_: None
152+
)
153+
with pytest.raises(RuntimeError, match="QUiLoader failed"):
154+
MainWindow(MagicMock())
155+
156+
157+
class TestSaveHandler:
158+
def test_warning_shown_when_filepath_empty(self, window, monkeypatch):
159+
"""Empty fileNameEdit triggers QMessageBox.warning — lines 240-244."""
160+
warnings: list = []
161+
monkeypatch.setattr(
162+
"src.views.main_window.QMessageBox.warning",
163+
lambda *a: warnings.append(a),
164+
)
165+
window._file_name_edit.setText("")
166+
window._on_save_clicked()
167+
assert len(warnings) == 1
168+
169+
def test_save_delegates_to_viewmodel(self, window, qtbot):
170+
"""Non-empty filepath triggers ViewModel.save_file — line 246."""
171+
window._file_name_edit.setText("/tmp/out.txt")
172+
window._plain_text_edit.setPlainText("some content")
173+
with qtbot.waitSignal(window._viewmodel.file_saved, timeout=1000):
174+
window._on_save_clicked()
175+
176+
177+
class TestCleanHandler:
178+
def test_checking_trim_checkbox_triggers_cleaning(
179+
self, window, mock_text_svc, qtbot
180+
):
181+
"""stateChanged → _on_clean_requested — lines 253-258."""
182+
window._viewmodel.load_file("/tmp/test.txt")
183+
qtbot.wait(10)
184+
with qtbot.waitSignal(window._viewmodel.content_updated, timeout=1000):
185+
window._trim_cb.setChecked(True)
186+
187+
def test_clean_options_reflect_checkbox_states(
188+
self, window, mock_text_svc, qtbot
189+
):
190+
window._viewmodel.load_file("/tmp/test.txt")
191+
qtbot.wait(10)
192+
window._clean_cb.setChecked(False)
193+
window._remove_tabs_cb.setChecked(True)
194+
window._on_clean_requested()
195+
call_args = mock_text_svc.apply_options.call_args
196+
opts = call_args[0][1]
197+
assert opts.remove_tabs is True
198+
assert opts.clean_whitespace is False
199+
200+
201+
class TestFindHandler:
202+
def test_empty_term_is_no_op(self, window):
203+
"""Empty findLineEdit → early return at line 264."""
204+
window._find_edit.setText("")
205+
window._on_find_clicked() # should not raise or select anything
206+
assert not window._plain_text_edit.textCursor().hasSelection()
207+
208+
def test_finds_existing_text(self, window, qtbot):
209+
"""find() succeeds — line 265 (found=True branch)."""
210+
window._viewmodel.load_file("/tmp/test.txt")
211+
qtbot.wait(10)
212+
window._plain_text_edit.setPlainText("hello world")
213+
window._find_edit.setText("hello")
214+
window._on_find_clicked()
215+
assert window._plain_text_edit.textCursor().hasSelection()
216+
217+
def test_wraps_when_not_found_from_end(self, window):
218+
"""find() returns False → cursor wraps to start — lines 267-271."""
219+
window._plain_text_edit.setPlainText("hello")
220+
# Move cursor to end so the first find('hello') misses
221+
cursor = window._plain_text_edit.textCursor()
222+
cursor.movePosition(cursor.MoveOperation.End)
223+
window._plain_text_edit.setTextCursor(cursor)
224+
window._find_edit.setText("hello")
225+
window._on_find_clicked() # wraps and finds from start
226+
assert window._plain_text_edit.textCursor().hasSelection()
227+
228+
229+
class TestReplaceHandler:
230+
def test_empty_find_term_is_no_op(self, window):
231+
"""Empty findLineEdit → early return at line 278."""
232+
window._find_edit.setText("")
233+
window._on_replace_clicked() # should not raise
234+
235+
def test_replace_with_no_selection_calls_find(self, window):
236+
"""No selection → skips replacement, falls through to find — lines 279-282."""
237+
window._plain_text_edit.setPlainText("hello world")
238+
window._find_edit.setText("hello")
239+
window._replace_edit.setText("goodbye")
240+
window._on_replace_clicked()
241+
# find() advances cursor to 'hello' after replace falls through
242+
assert window._plain_text_edit.textCursor().hasSelection()
243+
244+
def test_replace_matching_selection_inserts_replacement(self, window):
245+
"""Matching selection → text replaced — line 281."""
246+
window._plain_text_edit.setPlainText("hello world")
247+
window._find_edit.setText("hello")
248+
window._replace_edit.setText("goodbye")
249+
# Manually select 'hello'
250+
cursor = window._plain_text_edit.textCursor()
251+
cursor.setPosition(0)
252+
cursor.setPosition(5, cursor.MoveMode.KeepAnchor)
253+
window._plain_text_edit.setTextCursor(cursor)
254+
window._on_replace_clicked()
255+
assert "goodbye" in window._plain_text_edit.toPlainText()
256+
257+
258+
class TestReplaceAllHandler:
259+
def test_replace_all_emits_content_updated(self, window, qtbot):
260+
"""_on_replace_all_clicked delegates to ViewModel — line 289."""
261+
window._viewmodel.load_file("/tmp/test.txt")
262+
qtbot.wait(10)
263+
window._find_edit.setText("hello")
264+
window._replace_edit.setText("goodbye")
265+
with qtbot.waitSignal(window._viewmodel.content_updated, timeout=1000):
266+
window._on_replace_all_clicked()
267+
268+
269+
class TestTreeClickHandler:
270+
def test_file_click_loads_file(self, window, tmp_path, qtbot):
271+
"""Clicking a file path sets fileNameEdit and calls load_file — lines 233-234."""
272+
f = tmp_path / "click.txt"
273+
f.write_text("click content", encoding="utf-8")
274+
window._fs_model.filePath = MagicMock(return_value=str(f))
275+
with qtbot.waitSignal(window._viewmodel.document_loaded, timeout=1000):
276+
window._on_tree_item_clicked(MagicMock())
277+
assert window._file_name_edit.text() == str(f) # confirms line 233 ran
278+
279+
def test_directory_click_is_ignored(self, window, tmp_path):
280+
"""Clicking a directory does not call load_file — line 232 branch."""
281+
window._fs_model.filePath = MagicMock(return_value=str(tmp_path))
282+
emitted: list = []
283+
window._viewmodel.document_loaded.connect(emitted.append)
284+
window._on_tree_item_clicked(MagicMock())
285+
assert emitted == []
286+
287+
288+
class TestActionOpenHandler:
289+
def test_chosen_file_is_loaded(self, window, tmp_path, monkeypatch, qtbot):
290+
"""QFileDialog returns path → file loaded — lines 304-306."""
291+
f = tmp_path / "opened.txt"
292+
f.write_text("opened", encoding="utf-8")
293+
monkeypatch.setattr(
294+
"src.views.main_window.QFileDialog.getOpenFileName",
295+
lambda *a, **kw: (str(f), ""),
296+
)
297+
with qtbot.waitSignal(window._viewmodel.document_loaded, timeout=1000):
298+
window._on_action_open()
299+
assert window._file_name_edit.text() == str(f) # confirms line 305 ran
300+
301+
def test_cancelled_dialog_is_no_op(self, window, monkeypatch):
302+
"""QFileDialog returns '' → nothing happens — lines 303-304 branch."""
303+
monkeypatch.setattr(
304+
"src.views.main_window.QFileDialog.getOpenFileName",
305+
lambda *a, **kw: ("", ""),
306+
)
307+
emitted: list = []
308+
window._viewmodel.document_loaded.connect(emitted.append)
309+
window._on_action_open()
310+
assert emitted == []
311+
312+
313+
class TestActionAboutHandler:
314+
def test_about_dialog_is_shown(self, window, monkeypatch):
315+
"""actionAbout → QMessageBox.about called — line 310."""
316+
calls: list = []
317+
monkeypatch.setattr(
318+
"src.views.main_window.QMessageBox.about",
319+
lambda *a: calls.append(a),
320+
)
321+
window._on_action_about()
322+
assert len(calls) == 1
323+
assert APP_VERSION in calls[0][2]
324+
325+
326+
class TestErrorHandler:
327+
def test_error_signal_shows_critical_dialog(self, window, monkeypatch, qtbot):
328+
"""error_occurred signal → QMessageBox.critical — line 366."""
329+
calls: list = []
330+
monkeypatch.setattr(
331+
"src.views.main_window.QMessageBox.critical",
332+
lambda *a, **kw: calls.append(a),
333+
)
334+
window._viewmodel.error_occurred.emit("something went wrong")
335+
qtbot.wait(10)
336+
assert len(calls) == 1
337+
assert "something went wrong" in calls[0][2]

tests/unit/test_file_service.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,49 @@ def test_overwrites_existing_file(self, svc, tmp_path):
6868
doc = TextDocument(filepath=str(f), content="new content")
6969
svc.save_file(doc)
7070
assert f.read_text(encoding="utf-8") == "new content"
71+
72+
73+
class TestDetectEncoding:
74+
"""Tests for _detect_encoding — module-level function in file_service.py."""
75+
76+
def test_utf16_le_detected_by_chardet(self):
77+
"""UTF-16 with BOM gives chardet confidence=1.0, exercising the chardet branch.
78+
79+
Using encode('utf-16') emits a BOM so chardet reliably returns 'UTF-16'.
80+
Raw UTF-16-LE without BOM requires long content for chardet to reach the
81+
0.7 confidence threshold, making it fragile for short test strings.
82+
"""
83+
pytest.importorskip("chardet") # skip gracefully if chardet not installed
84+
from src.services.file_service import _detect_encoding
85+
86+
# encode('utf-16') prepends a BOM; chardet returns 'UTF-16' with confidence=1.0
87+
raw = "hello world".encode("utf-16")
88+
encoding = _detect_encoding(raw)
89+
# chardet returns 'UTF-16' (with BOM) or 'utf-16le'/'utf-16-le' (without BOM)
90+
assert encoding.lower() in ("utf-16", "utf-16le", "utf-16-le")
91+
92+
def test_falls_back_to_utf8_for_undetectable_bytes(self):
93+
"""Empty bytes: chardet returns None encoding → fallback to utf-8."""
94+
from src.services.file_service import _detect_encoding
95+
96+
encoding = _detect_encoding(b"")
97+
assert encoding == "utf-8"
98+
99+
100+
class TestAtomicSaveCleanup:
101+
"""Verify temp file is cleaned up when os.replace fails (lines 70-75)."""
102+
103+
def test_temp_file_removed_on_replace_failure(self, svc, tmp_path, monkeypatch):
104+
removed: list[str] = []
105+
106+
def _failing_replace(src: str, dst: str) -> None:
107+
raise OSError("simulated disk full")
108+
109+
monkeypatch.setattr("src.services.file_service.os.replace", _failing_replace)
110+
monkeypatch.setattr("src.services.file_service.os.unlink", lambda p: removed.append(p))
111+
112+
doc = TextDocument(filepath=str(tmp_path / "out.txt"), content="data")
113+
with pytest.raises(OSError, match="simulated disk full"):
114+
svc.save_file(doc)
115+
116+
assert len(removed) == 1, "temp file should have been unlinked on failure"

tests/unit/test_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Unit tests for src/main.py — composition root."""
2+
3+
import pytest
4+
5+
import src.main as main_module
6+
from src.main import create_application
7+
from src.views.main_window import MainWindow
8+
9+
10+
@pytest.fixture()
11+
def window(qapp):
12+
w = create_application()
13+
yield w
14+
w.ui.close()
15+
16+
17+
class TestModuleImport:
18+
def test_module_has_logger(self):
19+
assert main_module.logger is not None
20+
21+
22+
class TestCreateApplication:
23+
def test_returns_main_window(self, window):
24+
assert isinstance(window, MainWindow)
25+
26+
def test_window_has_viewmodel(self, window):
27+
assert window._viewmodel is not None

uv.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)