|
4 | 4 |
|
5 | 5 | import pytest |
6 | 6 |
|
| 7 | +from src.utils.constants import APP_VERSION |
| 8 | + |
7 | 9 | from src.models.cleaning_options import CleaningOptions |
8 | 10 | from src.models.text_document import TextDocument |
9 | 11 | from src.viewmodels.main_viewmodel import MainViewModel |
@@ -108,3 +110,228 @@ def test_merge_tab_has_coming_soon_label(self, window): |
108 | 110 | label = window.ui.findChild(QLabel, "mergeComingSoonLabel") |
109 | 111 | assert label is not None |
110 | 112 | 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] |
0 commit comments