Skip to content

Commit ad4e1a4

Browse files
chrisdpurcellclaude
andcommitted
fix: switch to ubuntu-latest and fix black formatting drift
- CI: switch from self-hosted to ubuntu-latest (self-hosted runner has no passwordless sudo for apt-get; ubuntu-latest has full sudo + more pre-installed libs) - Format 7 files with black (pre-existing drift) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 32d8404 commit ad4e1a4

8 files changed

Lines changed: 85 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88

99
jobs:
1010
test:
11-
runs-on: [self-hosted, linux, l3digital]
11+
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
1414
python-version: ["3.12", "3.13", "3.14"]
@@ -45,7 +45,7 @@ jobs:
4545
fail_ci_if_error: false
4646

4747
lint:
48-
runs-on: [self-hosted, linux, l3digital]
48+
runs-on: ubuntu-latest
4949

5050
steps:
5151
- uses: actions/checkout@v4

src/utils/constants.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,26 @@
99
# File extensions shown in the QFileSystemModel tree.
1010
# Covers common text, config, markup, and script formats.
1111
TEXT_FILE_EXTENSIONS = [
12-
"*.txt", "*.md", "*.rst", "*.csv", "*.log",
13-
"*.json", "*.yaml", "*.yml", "*.toml", "*.xml", "*.html", "*.htm",
14-
"*.css", "*.js", "*.ts", "*.py", "*.sh", "*.bash",
15-
"*.conf", "*.cfg", "*.ini", "*.env",
12+
"*.txt",
13+
"*.md",
14+
"*.rst",
15+
"*.csv",
16+
"*.log",
17+
"*.json",
18+
"*.yaml",
19+
"*.yml",
20+
"*.toml",
21+
"*.xml",
22+
"*.html",
23+
"*.htm",
24+
"*.css",
25+
"*.js",
26+
"*.ts",
27+
"*.py",
28+
"*.sh",
29+
"*.bash",
30+
"*.conf",
31+
"*.cfg",
32+
"*.ini",
33+
"*.env",
1634
]

src/views/main_window.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def _setup_file_tree(self) -> None:
235235
self._fs_model = QFileSystemModel(self.ui)
236236
self._fs_model.setRootPath(QDir.homePath())
237237
self._fs_model.setNameFilters(TEXT_FILE_EXTENSIONS)
238-
self._fs_model.setNameFilterDisables(False) # hide non-matches (not just grey them)
238+
self._fs_model.setNameFilterDisables(
239+
False
240+
) # hide non-matches (not just grey them)
239241
self._file_tree_view.setModel(self._fs_model)
240242
self._file_tree_view.setRootIndex(self._fs_model.index(QDir.homePath()))
241243
# Hide size/type/date columns — name column only
@@ -324,7 +326,9 @@ def _connect_signals(self) -> None:
324326
self._plain_text_edit.cursorPositionChanged.connect(self._update_cursor_label)
325327
# contentsChanged fires on Delete/Backspace where cursor position does not
326328
# change — without this the char count goes stale after in-place deletions.
327-
self._plain_text_edit.document().contentsChanged.connect(self._update_cursor_label)
329+
self._plain_text_edit.document().contentsChanged.connect(
330+
self._update_cursor_label
331+
)
328332

329333
# Keyboard shortcuts not present in the .ui file.
330334
# (Ctrl+S/O/Q/Shift+S are already wired via QAction shortcuts in main_window.ui.)

src/views/preferences_dialog.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030

3131
def _require(widget: "_W | None", name: str) -> "_W":
3232
if widget is None:
33-
raise RuntimeError(f"Required widget '{name}' not found in preferences_dialog.ui")
33+
raise RuntimeError(
34+
f"Required widget '{name}' not found in preferences_dialog.ui"
35+
)
3436
return widget
3537

3638

@@ -98,7 +100,8 @@ def _load_ui(self, parent: QWidget | None) -> None:
98100
self.dialog.findChild(QCheckBox, "wordWrapCheckBox"), "wordWrapCheckBox"
99101
)
100102
self._line_numbers_cb = _require(
101-
self.dialog.findChild(QCheckBox, "lineNumbersCheckBox"), "lineNumbersCheckBox"
103+
self.dialog.findChild(QCheckBox, "lineNumbersCheckBox"),
104+
"lineNumbersCheckBox",
102105
)
103106
self._theme_light_radio = _require(
104107
self.dialog.findChild(QRadioButton, "themeLightRadio"), "themeLightRadio"
@@ -161,7 +164,9 @@ def _write_to_settings(self) -> None:
161164
settings.setValue(KEY_FONT_SIZE, self._font_size_spin.value())
162165
settings.setValue(KEY_WORD_WRAP, self._word_wrap_cb.isChecked())
163166
settings.setValue(KEY_LINE_NUMBERS, self._line_numbers_cb.isChecked())
164-
settings.setValue(KEY_THEME, "dark" if self._theme_dark_radio.isChecked() else "light")
167+
settings.setValue(
168+
KEY_THEME, "dark" if self._theme_dark_radio.isChecked() else "light"
169+
)
165170
settings.setValue(KEY_DEFAULT_DIR, self._default_dir_edit.text())
166171

167172
def _on_apply_clicked(self) -> None:

tests/integration/test_live_scenarios.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,7 @@ def test_title_loses_star_after_save(self, app, tmp_path, qtbot):
8787

8888

8989
class TestCleaningWithRealService:
90-
def test_trim_removes_trailing_spaces_and_blank_lines(
91-
self, app, tmp_path, qtbot
92-
):
90+
def test_trim_removes_trailing_spaces_and_blank_lines(self, app, tmp_path, qtbot):
9391
# trim_whitespace strips trailing spaces per line and removes trailing
9492
# blank lines, but does NOT strip leading spaces.
9593
f = tmp_path / "trim.txt"
@@ -148,9 +146,7 @@ def test_find_wraps_from_end_of_document(self, app, tmp_path, qtbot):
148146
app._on_find_clicked() # wraps to start, then finds
149147
assert app._plain_text_edit.textCursor().selectedText() == "quick"
150148

151-
def test_replace_all_updates_editor_and_emits_signal(
152-
self, app, tmp_path, qtbot
153-
):
149+
def test_replace_all_updates_editor_and_emits_signal(self, app, tmp_path, qtbot):
154150
f = tmp_path / "rep.txt"
155151
f.write_text("cat cat cat", encoding="utf-8")
156152
app._file_name_edit.setText(str(f))

tests/integration/test_main_window.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -105,20 +105,25 @@ def test_title_keeps_star_after_cleaning(self, window, mock_text_svc, qtbot):
105105
class TestOrphanWidgets:
106106
def test_unnamed_checkboxes_not_present(self, window):
107107
from PySide6.QtWidgets import QCheckBox
108+
108109
for name in ("checkBox_2", "checkBox_4", "checkBox_6"):
109-
assert window.ui.findChild(QCheckBox, name) is None, \
110-
f"Orphan widget {name!r} should not exist"
110+
assert (
111+
window.ui.findChild(QCheckBox, name) is None
112+
), f"Orphan widget {name!r} should not exist"
111113

112114
def test_text_label_placeholders_not_present(self, window):
113115
from PySide6.QtWidgets import QLabel
116+
114117
for name in ("label", "label_2"):
115-
assert window.ui.findChild(QLabel, name) is None, \
116-
f"Placeholder label {name!r} should not exist"
118+
assert (
119+
window.ui.findChild(QLabel, name) is None
120+
), f"Placeholder label {name!r} should not exist"
117121

118122

119123
class TestMergeTab:
120124
def test_merge_tab_has_expected_widgets(self, window):
121125
from PySide6.QtWidgets import QListWidget, QPushButton, QLineEdit
126+
122127
assert window.ui.findChild(QListWidget, "mergeFileList") is not None
123128
assert window.ui.findChild(QPushButton, "mergeButton") is not None
124129
assert window.ui.findChild(QPushButton, "mergeAddCurrentButton") is not None
@@ -151,19 +156,15 @@ def test_raises_if_ui_file_unreadable(self, monkeypatch, qapp):
151156
from unittest.mock import MagicMock
152157
from src.views.main_window import MainWindow
153158

154-
monkeypatch.setattr(
155-
"src.views.main_window.QFile.open", lambda *_: False
156-
)
159+
monkeypatch.setattr("src.views.main_window.QFile.open", lambda *_: False)
157160
with pytest.raises(RuntimeError, match="Cannot open UI file"):
158161
MainWindow(MagicMock())
159162

160163
def test_raises_if_loader_returns_none(self, monkeypatch, qapp):
161164
from unittest.mock import MagicMock
162165
from src.views.main_window import MainWindow
163166

164-
monkeypatch.setattr(
165-
"src.views.main_window.QUiLoader.load", lambda *_: None
166-
)
167+
monkeypatch.setattr("src.views.main_window.QUiLoader.load", lambda *_: None)
167168
with pytest.raises(RuntimeError, match="QUiLoader failed"):
168169
MainWindow(MagicMock())
169170

@@ -198,9 +199,7 @@ def test_checking_trim_checkbox_triggers_cleaning(
198199
with qtbot.waitSignal(window._viewmodel.content_updated, timeout=1000):
199200
window._trim_cb.setChecked(True)
200201

201-
def test_clean_options_reflect_checkbox_states(
202-
self, window, mock_text_svc, qtbot
203-
):
202+
def test_clean_options_reflect_checkbox_states(self, window, mock_text_svc, qtbot):
204203
window._viewmodel.load_file("/tmp/test.txt")
205204
qtbot.wait(10)
206205
window._clean_cb.setChecked(False)
@@ -355,6 +354,7 @@ class TestDefaultTab:
355354
def test_opens_on_clean_tab(self, window):
356355
"""Tab widget must default to index 0 (Clean tab), not 2 (Find/Replace)."""
357356
from PySide6.QtWidgets import QTabWidget
357+
358358
tab = window.ui.findChild(QTabWidget, "tabWidget")
359359
assert tab is not None
360360
assert tab.currentIndex() == 0
@@ -377,6 +377,7 @@ class TestKeyboardShortcuts:
377377
def _shortcut_for(self, window, key_string: str):
378378
"""Return the QShortcut whose key matches key_string, or raise."""
379379
from PySide6.QtGui import QKeySequence, QShortcut
380+
380381
target = QKeySequence(key_string)
381382
shortcuts = window.ui.findChildren(QShortcut)
382383
for sc in shortcuts:
@@ -387,6 +388,7 @@ def _shortcut_for(self, window, key_string: str):
387388
def test_ctrl_f_switches_to_find_replace_tab(self, window, qtbot):
388389
"""Ctrl+F switches to the Find/Replace tab (tab index matches _find_replace_tab_index)."""
389390
from PySide6.QtWidgets import QTabWidget
391+
390392
tab = window.ui.findChild(QTabWidget, "tabWidget")
391393
assert tab.currentIndex() == 0, "precondition: starts on Clean tab"
392394
sc = self._shortcut_for(window, "Ctrl+F")
@@ -397,6 +399,7 @@ def test_ctrl_f_switches_to_find_replace_tab(self, window, qtbot):
397399
def test_ctrl_h_switches_to_find_replace_tab(self, window, qtbot):
398400
"""Ctrl+H switches to the Find/Replace tab (tab index matches _find_replace_tab_index)."""
399401
from PySide6.QtWidgets import QTabWidget
402+
400403
tab = window.ui.findChild(QTabWidget, "tabWidget")
401404
assert tab.currentIndex() == 0, "precondition: starts on Clean tab"
402405
sc = self._shortcut_for(window, "Ctrl+H")
@@ -425,6 +428,7 @@ class TestConvertEncodingHandler:
425428
def test_convert_button_calls_viewmodel(self, window, qtbot):
426429
"""Clicking the Convert button must call viewmodel.convert_to_utf8."""
427430
from unittest.mock import patch
431+
428432
window._plain_text_edit.setPlainText("hello")
429433
with patch.object(window._viewmodel, "convert_to_utf8") as mock_convert:
430434
window._convert_button.click()
@@ -473,6 +477,7 @@ def test_char_count_updates_on_delete_without_cursor_move(self, window, qtbot):
473477
def _real_save_svc(mock_file_svc):
474478
"""Extends mock_file_svc with real FileService.save_file for disk-write assertions."""
475479
from src.services.file_service import FileService
480+
476481
mock_file_svc.save_file.side_effect = FileService().save_file
477482
return mock_file_svc
478483

@@ -519,6 +524,7 @@ def isolated_settings(self, tmp_path, monkeypatch):
519524
bodies can construct a matching QSettings instance to read/clear it.
520525
"""
521526
from PySide6.QtCore import QSettings
527+
522528
tmp_ini = str(tmp_path / "test_settings.ini")
523529
monkeypatch.setattr(
524530
"src.views.main_window.QSettings",
@@ -530,6 +536,7 @@ def isolated_settings(self, tmp_path, monkeypatch):
530536
def test_save_settings_writes_geometry(self, window, qtbot):
531537
"""_save_settings must write window/geometry to QSettings."""
532538
from PySide6.QtCore import QSettings
539+
533540
# Clear geometry in the same temp file the window will write to
534541
s = QSettings(self._tmp_ini, QSettings.Format.IniFormat)
535542
s.remove("window/geometry")
@@ -541,6 +548,7 @@ def test_save_settings_writes_geometry(self, window, qtbot):
541548
def test_load_settings_does_not_raise_when_empty(self, window):
542549
"""_load_settings must not raise when no settings have been saved."""
543550
from PySide6.QtCore import QSettings
551+
544552
QSettings(self._tmp_ini, QSettings.Format.IniFormat).clear()
545553
window._load_settings() # must not raise
546554

@@ -562,7 +570,9 @@ def test_save_and_restore_geometry(self, window, qtbot):
562570
class TestMergeWorkflow:
563571
"""Integration tests for the merge tab — real files, real ViewModel signals."""
564572

565-
def test_add_current_and_merge(self, window, mock_file_svc, mock_text_svc, tmp_path, qtbot):
573+
def test_add_current_and_merge(
574+
self, window, mock_file_svc, mock_text_svc, tmp_path, qtbot
575+
):
566576
"""Load a file, add it to the merge list, merge it, verify editor content."""
567577
from PySide6.QtWidgets import QListWidget
568578

@@ -601,7 +611,9 @@ def test_add_current_and_merge(self, window, mock_file_svc, mock_text_svc, tmp_p
601611

602612
assert window._plain_text_edit.toPlainText() == "aaa\nbbb"
603613

604-
def test_merge_tab_list_refreshes_on_add(self, window, mock_file_svc, tmp_path, qtbot):
614+
def test_merge_tab_list_refreshes_on_add(
615+
self, window, mock_file_svc, tmp_path, qtbot
616+
):
605617
"""Adding a file to merge list refreshes the QListWidget display."""
606618
from PySide6.QtWidgets import QListWidget
607619

@@ -631,7 +643,9 @@ def test_merge_empty_list_shows_error(self, window, monkeypatch, qtbot):
631643
"src.views.main_window.QMessageBox.critical",
632644
lambda *a, **kw: None,
633645
)
634-
with qtbot.waitSignal(window._viewmodel.error_occurred, timeout=1000) as blocker:
646+
with qtbot.waitSignal(
647+
window._viewmodel.error_occurred, timeout=1000
648+
) as blocker:
635649
window._viewmodel.execute_merge()
636650
assert "No files in merge list" in blocker.args[0]
637651

@@ -669,7 +683,9 @@ def test_word_wrap_enabled(self, window):
669683
from PySide6.QtWidgets import QPlainTextEdit
670684
from src.views.preferences_dialog import KEY_WORD_WRAP
671685

672-
QSettings(self._tmp_ini, QSettings.Format.IniFormat).setValue(KEY_WORD_WRAP, True)
686+
QSettings(self._tmp_ini, QSettings.Format.IniFormat).setValue(
687+
KEY_WORD_WRAP, True
688+
)
673689
window._apply_preferences()
674690
assert (
675691
window._plain_text_edit.lineWrapMode()
@@ -682,8 +698,7 @@ def test_word_wrap_disabled_by_default(self, window):
682698

683699
window._apply_preferences() # no settings written — default False applies
684700
assert (
685-
window._plain_text_edit.lineWrapMode()
686-
== QPlainTextEdit.LineWrapMode.NoWrap
701+
window._plain_text_edit.lineWrapMode() == QPlainTextEdit.LineWrapMode.NoWrap
687702
)
688703

689704
def test_dark_theme_changes_palette(self, window, qapp):
@@ -714,6 +729,8 @@ def test_open_preferences_dialog(self, window, monkeypatch):
714729
from unittest.mock import MagicMock
715730

716731
mock_dlg = MagicMock()
717-
monkeypatch.setattr("src.views.main_window.PreferencesDialog", lambda *_: mock_dlg)
732+
monkeypatch.setattr(
733+
"src.views.main_window.PreferencesDialog", lambda *_: mock_dlg
734+
)
718735
window._on_action_preferences()
719736
mock_dlg.exec.assert_called_once()

tests/unit/test_file_service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ def test_ascii_content_returns_utf8(self):
106106
is always safe for ASCII content.
107107
"""
108108
from src.services.file_service import _detect_encoding
109+
109110
raw = b"hello world" # pure ASCII
110111
assert _detect_encoding(raw) == "utf-8"
111112

112113
def test_non_ascii_utf8_content_returns_utf8(self):
113114
"""Non-ASCII UTF-8 bytes (é, ñ) must return utf-8."""
114115
from src.services.file_service import _detect_encoding
116+
115117
raw = "café résumé naïve".encode("utf-8")
116118
assert _detect_encoding(raw) == "utf-8"
117119

@@ -126,7 +128,9 @@ def _failing_replace(src: str, dst: str) -> None:
126128
raise OSError("simulated disk full")
127129

128130
monkeypatch.setattr("src.services.file_service.os.replace", _failing_replace)
129-
monkeypatch.setattr("src.services.file_service.os.unlink", lambda p: removed.append(p))
131+
monkeypatch.setattr(
132+
"src.services.file_service.os.unlink", lambda p: removed.append(p)
133+
)
130134

131135
doc = TextDocument(filepath=str(tmp_path / "out.txt"), content="data")
132136
with pytest.raises(OSError, match="simulated disk full"):

tests/unit/test_main_viewmodel.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,9 @@ def test_move_merge_item_backward(self, vm, qtbot):
263263
vm.move_merge_item(2, 0) # move c before a
264264
assert blocker.args[0] == ["c.txt", "a.txt", "b.txt"]
265265

266-
def test_execute_merge_emits_document_loaded(self, vm, mock_file_svc, mock_text_svc, qtbot):
266+
def test_execute_merge_emits_document_loaded(
267+
self, vm, mock_file_svc, mock_text_svc, qtbot
268+
):
267269
mock_text_svc.merge_documents.return_value = "merged content"
268270
vm.add_files_to_merge(["/tmp/a.txt", "/tmp/b.txt"])
269271
with qtbot.waitSignal(vm.document_loaded, timeout=1000) as blocker:

0 commit comments

Comments
 (0)