Skip to content

Commit 283f2eb

Browse files
chrisdpurcellclaude
andcommitted
feat: implement encoding conversion to UTF-8 (F-001)
Re-saves the current document as UTF-8 when convertEncodingButton is clicked; emits encoding_detected, file_saved, and error_occurred signals. No-op when already UTF-8 or no document is loaded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d12190b commit 283f2eb

4 files changed

Lines changed: 109 additions & 2 deletions

File tree

src/viewmodels/main_viewmodel.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,38 @@ def replace_all(
163163
self.content_updated.emit(new_content)
164164
noun = "occurrence" if count == 1 else "occurrences"
165165
self.status_changed.emit(f"Replaced {count} {noun}")
166+
167+
@Slot(str)
168+
def convert_to_utf8(self, current_text: str) -> None:
169+
"""Re-save the current document in UTF-8 encoding.
170+
171+
Args:
172+
current_text: Live editor text from the View. Used as the content
173+
to save (preserves unsaved edits).
174+
175+
No-op when no document is loaded or the file is already UTF-8.
176+
Encoding comparison normalises dashes so 'utf-8' and 'utf8' both match.
177+
"""
178+
if self._current_document is None:
179+
self.status_changed.emit("No document loaded")
180+
return
181+
# Normalise: strip dashes and lowercase so 'UTF-8', 'utf-8', 'utf8' all match.
182+
current_encoding = self._current_document.encoding.lower().replace("-", "")
183+
if current_encoding in ("utf8",):
184+
self.status_changed.emit("File is already UTF-8")
185+
return
186+
doc = TextDocument(
187+
filepath=self._current_document.filepath,
188+
content=current_text,
189+
encoding="utf-8",
190+
)
191+
try:
192+
self._file_service.save_file(doc)
193+
self._current_document = doc
194+
self.encoding_detected.emit("utf-8")
195+
self.file_saved.emit(doc.filepath)
196+
self.status_changed.emit(f"Converted to UTF-8: {doc.filepath}")
197+
except (ValueError, PermissionError, OSError) as e:
198+
msg = f"Cannot convert file: {e}"
199+
logger.error(msg)
200+
self.error_occurred.emit(msg)

src/views/main_window.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,9 @@ def _connect_signals(self) -> None:
208208
self._replace_button.clicked.connect(self._on_replace_clicked)
209209
self._replace_all_button.clicked.connect(self._on_replace_all_clicked)
210210

211-
# Encoding convert is stubbed for v1
211+
# Encoding conversion: pass live editor text so unsaved edits are preserved
212212
self._convert_button.clicked.connect(
213-
lambda: self.ui.statusBar().showMessage("Encoding conversion — coming soon")
213+
lambda: self._viewmodel.convert_to_utf8(self._plain_text_edit.toPlainText())
214214
)
215215

216216
# Menu actions

tests/integration/test_main_window.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,13 @@ def test_f3_triggers_find_next(self, window, qtbot):
405405
assert window._plain_text_edit.textCursor().selectedText() == "hello"
406406
pos_after_second = window._plain_text_edit.textCursor().position()
407407
assert pos_after_second > pos_after_first
408+
409+
410+
class TestConvertEncodingHandler:
411+
def test_convert_button_calls_viewmodel(self, window, qtbot):
412+
"""Clicking the Convert button must call viewmodel.convert_to_utf8."""
413+
from unittest.mock import patch
414+
window._plain_text_edit.setPlainText("hello")
415+
with patch.object(window._viewmodel, "convert_to_utf8") as mock_convert:
416+
window._convert_button.click()
417+
mock_convert.assert_called_once_with("hello")

tests/unit/test_main_viewmodel.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,65 @@ def test_replace_all_uses_current_text_when_provided(self, vm, qtbot):
148148
with qtbot.waitSignal(vm.content_updated, timeout=1000) as blocker:
149149
vm.replace_all("hello", "goodbye", current_text="hello hello")
150150
assert blocker.args[0] == "goodbye goodbye"
151+
152+
153+
class TestConvertToUtf8:
154+
def test_saves_file_with_utf8_encoding(self, vm, mock_file_svc, qtbot):
155+
"""convert_to_utf8 must call save_file with encoding='utf-8'."""
156+
from src.models.text_document import TextDocument
157+
mock_file_svc.open_file.return_value = TextDocument(
158+
filepath="/tmp/latin.txt", content="caf\u00e9", encoding="ISO-8859-1"
159+
)
160+
vm.load_file("/tmp/latin.txt")
161+
vm.convert_to_utf8("café")
162+
saved_doc = mock_file_svc.save_file.call_args[0][0]
163+
assert saved_doc.encoding == "utf-8"
164+
assert saved_doc.content == "café"
165+
166+
def test_emits_encoding_detected_utf8(self, vm, mock_file_svc, qtbot):
167+
"""convert_to_utf8 must emit encoding_detected('utf-8')."""
168+
from src.models.text_document import TextDocument
169+
mock_file_svc.open_file.return_value = TextDocument(
170+
filepath="/tmp/latin.txt", content="caf\u00e9", encoding="ISO-8859-1"
171+
)
172+
vm.load_file("/tmp/latin.txt")
173+
with qtbot.waitSignal(vm.encoding_detected, timeout=1000) as blocker:
174+
vm.convert_to_utf8("café")
175+
assert blocker.args[0] == "utf-8"
176+
177+
def test_emits_file_saved(self, vm, mock_file_svc, qtbot):
178+
"""convert_to_utf8 must emit file_saved after a successful save."""
179+
from src.models.text_document import TextDocument
180+
mock_file_svc.open_file.return_value = TextDocument(
181+
filepath="/tmp/latin.txt", content="caf\u00e9", encoding="ISO-8859-1"
182+
)
183+
vm.load_file("/tmp/latin.txt")
184+
with qtbot.waitSignal(vm.file_saved, timeout=1000) as blocker:
185+
vm.convert_to_utf8("café")
186+
assert blocker.args[0] == "/tmp/latin.txt"
187+
188+
def test_no_op_when_already_utf8(self, vm, mock_file_svc, qtbot):
189+
"""convert_to_utf8 must not save when encoding is already utf-8."""
190+
from src.models.text_document import TextDocument
191+
mock_file_svc.open_file.return_value = TextDocument(
192+
filepath="/tmp/utf8.txt", content="hello", encoding="utf-8"
193+
)
194+
vm.load_file("/tmp/utf8.txt")
195+
vm.convert_to_utf8("hello")
196+
mock_file_svc.save_file.assert_not_called()
197+
198+
def test_no_op_when_no_document(self, vm, qtbot):
199+
"""convert_to_utf8 with no loaded document must be silent."""
200+
vm.convert_to_utf8("some text") # must not raise
201+
202+
def test_emits_error_on_save_failure(self, vm, mock_file_svc, qtbot):
203+
"""convert_to_utf8 must emit error_occurred when save_file raises."""
204+
from src.models.text_document import TextDocument
205+
mock_file_svc.open_file.return_value = TextDocument(
206+
filepath="/tmp/latin.txt", content="caf\u00e9", encoding="ISO-8859-1"
207+
)
208+
vm.load_file("/tmp/latin.txt")
209+
mock_file_svc.save_file.side_effect = PermissionError("read-only")
210+
with qtbot.waitSignal(vm.error_occurred, timeout=1000) as blocker:
211+
vm.convert_to_utf8("café")
212+
assert "Cannot convert" in blocker.args[0]

0 commit comments

Comments
 (0)