diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 085053b..3e2a329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: - name: Install Project Dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt pytest + python -m pip install -r requirements.txt pytest Pillow tomli - name: Run Pytest Test Suite run: pytest -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 95547cb..7acbd88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ Alle wesentlichen Änderungen an CodeBox werden hier dokumentiert. ## [Unreleased] +### Fixed (2026-10-02: Editor-Dateien bei Speicherfehlern erhalten) +- Editor-Tabs kodieren UTF-8 vor dem Öffnen der Ausgabe und verwenden QSaveFile ohne direkten Schreib-Fallback. Teilwrites und fehlgeschlagene Commits überschreiben den bisherigen Code nicht. +- Ungültiges UTF-8 wird als Speicherfehler gemeldet; geänderte Dokumente und geteilte Ansichten werden erst nach erfolgreichem Commit als unverändert markiert. +- Regressionen prüfen Öffnungs-, Schreib- und Commitfehler, leere Dokumente, native Zeilenenden und geteilte Dokumente. Grenzen stehen in SAVE_SAFETY.md; Version und bestehender EXE-Build bleiben unverändert. + ### Added (I18N Expansion & Tier-2 6-Sprachen-Architektur 2026-09-30) - **Policy P-006 Tier-2 6-Sprachen-Standard (`translator.py`, `manage_translations.py`, `locales/translations.json`)**: - `translator.py`: Vollwertiges `TranslationSystem` v2.0 mit 6 Standardsprachen (`de`, `en`, `es`, `zh`, `ja`, `ru`), deterministischer 4-Stufen-Fallback-Kette (`target -> en -> de -> key`), Systemsprachenerkennung (`detect_system_language()`), thread-sicherem Singleton `get_translator()` und globalem Helper `t(key, **kwargs)` mit kwargs-Interpolation und Fehlerisolation. diff --git a/SAVE_SAFETY.md b/SAVE_SAFETY.md new file mode 100644 index 0000000..04f8b90 --- /dev/null +++ b/SAVE_SAFETY.md @@ -0,0 +1,23 @@ +# Speichern von Editor-Dateien + +`EditorTab.save()` kodiert den vollständigen Text vor dem Öffnen einer Ausgabe +als UTF-8. Die bisherige Umwandlung in native Zeilenenden bleibt erhalten. +Qt `QSaveFile` schreibt einen privaten Zwischenstand und übernimmt ihn erst, +wenn Schreiben und `commit()` erfolgreich waren. Der direkte Schreib-Fallback +ist ausdrücklich deaktiviert; bei eingeschränkten Verzeichnisrechten kann das +Speichern deshalb fehlschlagen, obwohl direktes Überschreiben möglich wäre. + +Fehler beim Öffnen, Teilwrites, Schreibfehler, ungültiges UTF-8 und ein +fehlgeschlagenes Commit melden einen Fehler und lassen den bisherigen +Dateiinhalt bestehen. Das Dokument bleibt geändert; Schließen und Ausführen +nutzen weiterhin den bestehenden Erfolgsstatus. Geteilte Ansichten werden +erst nach erfolgreichem Speichern gemeinsam als unverändert markiert. +Auch ein bewusst leeres Dokument darf erfolgreich gespeichert werden. + +Diese Änderung betrifft lokale Editor-Dateien. Sie ist keine Abnahme von +SFTP-Uploads, Workspace-/Snippet-/Einstellungsspeichern, Datei-Lade-Encoding, +parallelen externen Änderungen, einer In-place-Erhaltung von Hardlinks oder +mehrteiligen Transaktionen. Ein neuer +EXE-Build und Geräte-/Office-/Store-Prüfungen sind nicht Teil der Quelländerung. + +API-Verhalten: [Qt-QSaveFile-Dokumentation](https://doc.qt.io/qtforpython-6/PySide6/QtCore/QSaveFile.html). diff --git a/core/tabs.py b/core/tabs.py index 7b11e52..09a39c6 100644 --- a/core/tabs.py +++ b/core/tabs.py @@ -4,11 +4,12 @@ from __future__ import annotations +import os from pathlib import Path from typing import Optional from PySide6.QtWidgets import QTabWidget, QMessageBox -from PySide6.QtCore import Signal +from PySide6.QtCore import Signal, QIODevice, QSaveFile from PySide6.QtGui import QTextDocument from .editor import CodeEditor @@ -78,11 +79,23 @@ def _on_modified(self, modified): def save(self) -> bool: """Speichert die Datei""" if self.file_path: + output = None try: - self.file_path.write_text( - self.editor.toPlainText(), encoding='utf-8' - ) - except OSError as e: + # Preserve the native newline conversion of Path.write_text, but + # validate UTF-8 before opening any output device. + payload = self.editor.toPlainText().replace('\n', os.linesep).encode('utf-8') + output = QSaveFile(str(self.file_path)) + output.setDirectWriteFallback(False) + if not output.open(QIODevice.OpenModeFlag.WriteOnly): + raise OSError(output.errorString()) + if output.write(payload) != len(payload): + raise OSError(output.errorString() or 'Datei nicht vollständig geschrieben') + if not output.commit(): + raise OSError(output.errorString()) + except (OSError, UnicodeError) as e: + if output is not None and output.isOpen(): + output.cancelWriting() + output.commit() # Discard and close the private output. QMessageBox.critical(None, "Speichern fehlgeschlagen", f"Konnte nicht speichern:\n{e}") return False diff --git a/features/project_view.py b/features/project_view.py index 01eacd5..a2590d3 100644 --- a/features/project_view.py +++ b/features/project_view.py @@ -10,7 +10,7 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Optional, Dict, TYPE_CHECKING from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTreeView, QFileSystemModel, @@ -52,10 +52,13 @@ def status_for_path( Returns: GitFileStatus oder None. """ + path_type = PureWindowsPath if PureWindowsPath(repo_root).drive else PurePosixPath try: - rel = Path(abs_path).relative_to(repo_root) + rel = path_type(abs_path).relative_to(path_type(repo_root)) except ValueError: return None + if ".." in rel.parts: + return None return status_dict.get(rel.as_posix()) diff --git a/tests/linux_platform_smoke.py b/tests/linux_platform_smoke.py index 4f6afa2..819ce93 100644 --- a/tests/linux_platform_smoke.py +++ b/tests/linux_platform_smoke.py @@ -67,6 +67,7 @@ def __init__(self, parent=None) -> None: self.readyReadStandardOutput = _DummySignal() self.readyReadStandardError = _DummySignal() self.finished = _DummySignal() + self.errorOccurred = _DummySignal() def setWorkingDirectory(self, cwd: str) -> None: self.cwd = cwd @@ -93,7 +94,7 @@ def _exercise_window_open_and_run() -> None: print("Test 1: Offscreen-Hauptfenster öffnet Datei und löst Run-Command aus") app = _ensure_app() with tempfile.TemporaryDirectory(prefix="codebox-linux-window-") as tmpdir_str: - tmpdir = Path(tmpdir_str) + tmpdir = Path(tmpdir_str).resolve() project_dir = tmpdir / "Projekt Übersicht" project_dir.mkdir(parents=True) script_path = project_dir / "überblick.py" @@ -119,10 +120,17 @@ def _exercise_window_open_and_run() -> None: _assert(window.output.run_btn.isEnabled(), "Run-Button blieb deaktiviert.") captured: list[list[str]] = [] - window.output.run_command = lambda command: captured.append(command) + captured_debug_modes: list[bool] = [] + + def capture_command(command, is_debug=False): + captured.append(command) + captured_debug_modes.append(is_debug) + + window.output.run_command = capture_command window.run_current() - _assert(captured == [["python", "-u", str(script_path)]], repr(captured)) + _assert(captured == [[sys.executable or "python", "-u", str(script_path)]], repr(captured)) + _assert(captured_debug_modes == [False], repr(captured_debug_modes)) finally: window.close() app.processEvents() diff --git a/tests/macos_platform_smoke.py b/tests/macos_platform_smoke.py index 8b09715..46c5abc 100644 --- a/tests/macos_platform_smoke.py +++ b/tests/macos_platform_smoke.py @@ -67,6 +67,7 @@ def __init__(self, parent=None) -> None: self.readyReadStandardOutput = _DummySignal() self.readyReadStandardError = _DummySignal() self.finished = _DummySignal() + self.errorOccurred = _DummySignal() def setWorkingDirectory(self, cwd: str) -> None: self.cwd = cwd @@ -93,7 +94,7 @@ def _exercise_window_open_and_run() -> None: print("Test 1: Offscreen-Hauptfenster öffnet Datei und löst Run-Command aus") app = _ensure_app() with tempfile.TemporaryDirectory(prefix="codebox-macos-window-") as tmpdir_str: - tmpdir = Path(tmpdir_str) + tmpdir = Path(tmpdir_str).resolve() project_dir = tmpdir / "Projekt Übersicht" project_dir.mkdir(parents=True) script_path = project_dir / "überblick.py" @@ -122,10 +123,17 @@ def _exercise_window_open_and_run() -> None: _assert(window.output.run_btn.isEnabled(), "Run-Button blieb deaktiviert.") captured: list[list[str]] = [] - window.output.run_command = lambda command: captured.append(command) + captured_debug_modes: list[bool] = [] + + def capture_command(command, is_debug=False): + captured.append(command) + captured_debug_modes.append(is_debug) + + window.output.run_command = capture_command window.run_current() - _assert(captured == [["python", "-u", str(script_path)]], repr(captured)) + _assert(captured == [[sys.executable or "python", "-u", str(script_path)]], repr(captured)) + _assert(captured_debug_modes == [False], repr(captured_debug_modes)) finally: window.close() app.processEvents() diff --git a/tests/test_editor_atomic_save.py b/tests/test_editor_atomic_save.py new file mode 100644 index 0000000..04b1396 --- /dev/null +++ b/tests/test_editor_atomic_save.py @@ -0,0 +1,81 @@ +"""Verify target preservation and document state at the real Qt save boundary.""" +import os + +import pytest +from PySide6.QtCore import QSaveFile +from PySide6.QtWidgets import QApplication, QMessageBox + +import core.tabs as tabs + + +@pytest.fixture +def document(tmp_path, monkeypatch): + app = QApplication.instance() or QApplication([]) + target = tmp_path / 'Grüße.py' + target.write_bytes(b'original code\r\n') + tab = tabs.EditorTab(target) + tab.editor.setPlainText("print('Grüße')\n") + tab.editor.document().setModified(True) + errors = [] + monkeypatch.setattr(QMessageBox, 'critical', lambda *args: errors.append(args)) + yield tab, target, errors, app + tab.editor.close() + + +@pytest.mark.parametrize('failure', ['open', 'short', 'exception', 'commit']) +def test_save_failure_preserves_existing_code_and_dirty_state(document, monkeypatch, failure): + tab, target, errors, _ = document + original_files = set(target.parent.iterdir()) + + class BrokenSaveFile(QSaveFile): + def open(self, mode): + assert not self.directWriteFallback() + return False if failure == 'open' else super().open(mode) + + def write(self, payload): + assert target.read_bytes() == b'original code\r\n' + if failure in ('short', 'exception'): + written = super().write(payload[:5]) + if failure == 'exception': + raise OSError('disk full after partial write') + return written + return super().write(payload) + + def commit(self): + if failure == 'commit': + self.cancelWriting() + return super().commit() + + monkeypatch.setattr(tabs, 'QSaveFile', BrokenSaveFile) + assert tab.save() is False + assert target.read_bytes() == b'original code\r\n' + assert tab.is_modified and tab.editor.document().isModified() + assert len(errors) == 1 + assert set(target.parent.iterdir()) == original_files + + +def test_invalid_utf8_does_not_open_output(document, monkeypatch): + tab, target, errors, _ = document + monkeypatch.setattr(tab.editor, 'toPlainText', lambda: 'bad\ud800text') + monkeypatch.setattr(tabs, 'QSaveFile', lambda *_: pytest.fail('output opened before encoding')) + assert tab.save() is False + assert target.read_bytes() == b'original code\r\n' + assert tab.is_modified and tab.editor.document().isModified() + assert len(errors) == 1 + + +@pytest.mark.parametrize('text', ['', 'Grüße\nzweite Zeile\n']) +def test_success_preserves_native_newlines_and_cleans_shared_document(document, text): + tab, target, errors, _ = document + clone = tabs.EditorTab.create_clone(tab) + try: + tab.editor.setPlainText(text) + tab.editor.document().setModified(True) + assert tab.save() is True + assert target.read_bytes() == text.replace('\n', os.linesep).encode('utf-8') + assert clone.editor.toPlainText() == text + assert not tab.is_modified and not clone.is_modified + assert not tab.editor.document().isModified() + assert not errors + finally: + clone.editor.close() diff --git a/tests/test_git_project_view_status.py b/tests/test_git_project_view_status.py index 7835aac..9fc1493 100644 --- a/tests/test_git_project_view_status.py +++ b/tests/test_git_project_view_status.py @@ -96,6 +96,32 @@ def test_windows_nested_backslash(self): self.assertIsNotNone(result) self.assertTrue(result.is_modified) + def test_windows_forward_slashes_and_case(self): + status = _file_status("src/main.py", is_modified=True) + result = status_for_path("c:/PROJECT/src/main.py", r"C:\project", {"src/main.py": status}) + self.assertIs(result, status) + + def test_windows_unc_share(self): + status = _file_status("src/main.py", is_modified=True) + result = status_for_path( + r"\\server\share\repo\src\main.py", r"\\server\share\repo", {"src/main.py": status} + ) + self.assertIs(result, status) + + def test_windows_other_drive_is_outside(self): + self.assertIsNone(status_for_path(r"D:\repo\main.py", r"C:\repo", {"main.py": _file_status("main.py")})) + + def test_sibling_prefix_is_outside(self): + self.assertIsNone(status_for_path("/repo-other/main.py", "/repo", {"main.py": _file_status("main.py")})) + + def test_parent_traversal_is_outside(self): + self.assertIsNone(status_for_path("/repo/../main.py", "/repo", {"../main.py": _file_status("../main.py")})) + + def test_posix_literal_backslash_filename(self): + name = r"file\name.py" + status = _file_status(name, is_modified=True) + self.assertIs(status_for_path("/repo/" + name, "/repo", {name: status}), status) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_metadata.py b/tests/test_metadata.py index b93e6ab..8127578 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,9 +2,13 @@ import json import re -import tomllib from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib + import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] diff --git a/tests/test_save_failure_guards.py b/tests/test_save_failure_guards.py index 39cc79f..67927b0 100644 --- a/tests/test_save_failure_guards.py +++ b/tests/test_save_failure_guards.py @@ -102,7 +102,7 @@ def test_initial_save_failure_restores_untitled_state(): "ui.main_window.QFileDialog.getSaveFileName", return_value=("C:/tmp/never-written.py", "Python (*.py)"), ), - patch("pathlib.Path.write_text", side_effect=OSError("disk full")), + patch("core.tabs.QSaveFile.open", return_value=False), patch("PySide6.QtWidgets.QMessageBox.critical", return_value=None), ): window.save_file()