diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bd994..cbd91c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] ### Fixed +- **Plattformstabiler Screenshot-Fonttest:** Die CI setzt nicht mehr voraus, + dass Qt unter `offscreen` auf jedem Linux-Runner zwangsläufig Tofu rendert. + Deterministische Tests sichern jetzt getrennt die explizite Offscreen-Sperre + und den negativen Glyphen-Probe-Befund auf einer nativen Plattform ab. +- **Settings persistence during maintenance self-tests:** + - `python main.py --self-test` now runs with a temporary `APPDATA` directory, so its deliberate language, theme, scroll-sync, and export-path changes never read or overwrite the user's real `%APPDATA%\CleanMarkdown\settings.json`. + - Added regression coverage for all 13 settings across a save/restart/load roundtrip and for byte- and timestamp-stable user settings while the full self-test runs. + - Corrected `SUPPORT.md` and `PRIVACY_POLICY.md`: the desktop app stores settings in JSON, not QSettings/the Windows Registry. - **Bilder im Lesemodus wirkten wie Hintergrundelemente statt echter Bloecke im Dokumentfluss (T-20260728-01):** Qt's `QTextDocument`-HTML-Engine behandelt `
` nicht als Block-Container und ignoriert diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 4d1b1df..c6bdbed 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -16,7 +16,7 @@ CleanMarkdown ist eine vollständig lokale Desktop-Anwendung. Es werden **keine - **Keine eigenen Netzwerkverbindungen:** CleanMarkdown stellt selbst keine Internetverbindungen her. Es gibt keine Telemetrie, kein Tracking, keine Cloud-Synchronisation und keinen automatischen Update-Mechanismus. Wenn ein Markdown-Dokument anklickbare Links enthält, öffnet ein Klick darauf den Standard-Browser des Systems — die App selbst überträgt dabei keine Daten. - **Lokale Dateien:** Alle Markdown-Dateien (.md, .markdown, .txt) werden ausschließlich lokal auf dem Gerät des Nutzers gelesen und geschrieben. -- **Einstellungen:** Benutzereinstellungen (Theme, Sprache, Scroll-Sync) werden lokal über QSettings gespeichert. +- **Einstellungen:** Benutzereinstellungen (Theme, Sprache, Scroll-Sync) werden lokal in `%APPDATA%\CleanMarkdown\settings.json` gespeichert. - **PDF-Export:** Der PDF-Export erfolgt lokal über QPrinter. Es wird kein externer Dienst genutzt. - **Session-Export:** Das optionale Session-Format (`cleanmarkdown-session-v1.json`) speichert ausschließlich lokale Metadaten (Dateipfad, Cursor-Position, Scroll-Position) auf dem Gerät des Nutzers. @@ -40,7 +40,7 @@ CleanMarkdown is a fully local desktop application. **No data is collected, stor - **No outgoing network connections:** CleanMarkdown itself does not establish any internet connections. There is no telemetry, no tracking, no cloud synchronization and no automatic update mechanism. When a Markdown document contains clickable links, clicking one opens the system's default browser — the app itself does not transmit any data. - **Local files:** All Markdown files (.md, .markdown, .txt) are read and written exclusively on the user's local device. -- **Settings:** User preferences (theme, language, scroll sync) are stored locally via QSettings. +- **Settings:** User preferences (theme, language, scroll sync) are stored locally in `%APPDATA%\CleanMarkdown\settings.json`. - **PDF export:** PDF export is handled locally via QPrinter. No external service is used. - **Session export:** The optional session format (`cleanmarkdown-session-v1.json`) stores only local metadata (file path, cursor position, scroll position) on the user's device. diff --git a/README.md b/README.md index 1b5fbde..58abba5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Platform: Windows | macOS | Linux](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) [![Zero-Egress](https://img.shields.io/badge/Privacy-100%25%20Offline%20%7C%20Zero--Egress-success.svg)](SECURITY.md) [![Security: Local-First](https://img.shields.io/badge/Security-Local--First%20%7C%20Non--Elevation-blueviolet.svg)](SECURITY.md) -[![Tests: 105 passed](https://img.shields.io/badge/Tests-105%20passed-brightgreen.svg)](tests) +[![Tests: 122 passed](https://img.shields.io/badge/Tests-122%20passed-brightgreen.svg)](tests) [![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-teal.svg)](CHANGELOG.md) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt) @@ -254,7 +254,7 @@ python -m pytest -q python main.py --self-test ``` -The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, headless print isolation, and automated metadata contract synchronization (105 passed tests). +The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, settings persistence, headless print isolation, and automated metadata contract synchronization (122 passed tests). For the mobile line in `flutter_port/`, verified scope includes local `.md`/`.markdown` file opening, live rendering, raw editing, and local storage flows. diff --git a/README_DE.md b/README_DE.md index 8a99522..2039df1 100644 --- a/README_DE.md +++ b/README_DE.md @@ -14,7 +14,7 @@ Deutsch · **[English](README.md)** [![Plattform: Windows | macOS | Linux](https://img.shields.io/badge/Plattform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) [![Zero-Egress](https://img.shields.io/badge/Datenschutz-100%25%20Offline%20%7C%20Zero--Egress-success.svg)](SECURITY.md) [![Sicherheit: Local-First](https://img.shields.io/badge/Sicherheit-Local--First%20%7C%20Non--Elevation-blueviolet.svg)](SECURITY.md) -[![Tests: 105 passed](https://img.shields.io/badge/Tests-105%20passed-brightgreen.svg)](tests) +[![Tests: 122 passed](https://img.shields.io/badge/Tests-122%20passed-brightgreen.svg)](tests) [![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-teal.svg)](CHANGELOG.md) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt) @@ -254,7 +254,7 @@ python -m pytest -q python main.py --self-test ``` -Die Testsuite validiert Rendering-Genauigkeit, Hyperlink-Erhalt bei Figuren, Mathe-Verarbeitung, Session-Serialisierung, Headless-Druckisolation und automatisierte Metadaten-Vertragssynchronisation (105 bestandene Tests). +Die Testsuite validiert Rendering-Genauigkeit, Hyperlink-Erhalt bei Figuren, Mathe-Verarbeitung, Session-Serialisierung, Einstellungspersistenz, Headless-Druckisolation und automatisierte Metadaten-Vertragssynchronisation (122 bestandene Tests). Für den mobilen Zweig in `flutter_port/` umfasst der geprüfte Umfang das Öffnen lokaler `.md`/`.markdown`-Dateien, Live-Rendering, Raw-Editing und lokale Speicher-Abläufe. diff --git a/SUPPORT.md b/SUPPORT.md index 8bb2891..74937ae 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -16,7 +16,7 @@ ### Häufige Fragen (FAQ) **Wo werden meine Einstellungen gespeichert?** -Einstellungen werden lokal über QSettings gespeichert. Unter Windows liegt die Konfiguration in der Registry unter `HKCU\Software\CleanMarkdown`. +Einstellungen werden lokal als JSON-Datei gespeichert. Unter Windows liegt die Konfiguration unter `%APPDATA%\CleanMarkdown\settings.json`. **Welche Dateiformate werden unterstützt?** `.md`, `.markdown` und `.txt` können geöffnet, bearbeitet und gespeichert werden. @@ -57,7 +57,7 @@ Die optionale Scroll-Sync gleicht die Scroll-Position zwischen Lese- und Editor- ### Frequently Asked Questions (FAQ) **Where are my settings stored?** -Settings are stored locally via QSettings. On Windows, the configuration is in the registry under `HKCU\Software\CleanMarkdown`. +Settings are stored locally as a JSON file. On Windows, the configuration is located at `%APPDATA%\CleanMarkdown\settings.json`. **Which file formats are supported?** `.md`, `.markdown` and `.txt` files can be opened, edited and saved. diff --git a/llms.txt b/llms.txt index 751bea0..433552f 100644 --- a/llms.txt +++ b/llms.txt @@ -35,7 +35,7 @@ python main.py --self-test - `main.py` — PySide6 desktop application entry point, UI workspace, and AST pipeline. - `translator.py` / `locales/translations.json` — 6-language i18n system (`de`, `en`, `es`, `zh`, `ja`, `ru`). - `flutter_port/` — Flutter Android/iOS mobile port. -- `tests/` — pytest suite (105 tests: rendering, session, file/export edge cases, image preview, metadata contracts). +- `tests/` — pytest suite (122 tests: rendering, settings persistence, session, file/export edge cases, image preview, metadata contracts). - `EXPORTFORMAT.md` — session and bundle format contract. ## Audience diff --git a/main.py b/main.py index f967d35..90a54cb 100644 --- a/main.py +++ b/main.py @@ -1889,11 +1889,11 @@ def run_smoke_test() -> int: def run_self_test() -> int: - app = QApplication(sys.argv) - configure_application(app) + app: QApplication | None = None results: list[tuple[str, bool]] = [] original_open = QFileDialog.getOpenFileName original_save = QFileDialog.getSaveFileName + original_appdata = os.environ.get("APPDATA") sample_markdown = r"""# Titel - [ ] Offen @@ -1941,7 +1941,13 @@ def fake_save(*args, **kwargs): QFileDialog.getOpenFileName = fake_open QFileDialog.getSaveFileName = fake_save + # MainWindow persists settings during closeEvent. Keep the self-test + # completely outside the user's real configuration so maintenance + # runs cannot replace preferences with temporary test values. + os.environ["APPDATA"] = str(tmp_path / "appdata") try: + app = QApplication(sys.argv) + configure_application(app) window = MainWindow() window.show() app.processEvents() @@ -2117,11 +2123,16 @@ def fake_save(*args, **kwargs): finally: QFileDialog.getOpenFileName = original_open QFileDialog.getSaveFileName = original_save + if app is not None: + app.quit() + if original_appdata is None: + os.environ.pop("APPDATA", None) + else: + os.environ["APPDATA"] = original_appdata failed = [name for name, ok in results if not ok] for name, ok in results: print(f"{name}: {'ok' if ok else 'failed'}") - app.quit() return 0 if not failed else 1 diff --git a/tests/test_file_handling.py b/tests/test_file_handling.py index f8c2051..035e6e7 100644 --- a/tests/test_file_handling.py +++ b/tests/test_file_handling.py @@ -1,7 +1,10 @@ from __future__ import annotations import json +import os from pathlib import Path +import subprocess +import sys from PySide6.QtCore import QUrl @@ -63,6 +66,76 @@ def test_settings_store_ignores_unknown_json_fields(main_module, tmp_path, monke assert settings.window_height == 777 +def test_settings_persist_across_new_store_and_window_instances(main_module, tmp_path, monkeypatch): + monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) + expected = main_module.AppSettings( + language="en", + theme="bright", + default_mode="editor", + autosave_enabled=False, + autosave_interval=47, + export_mode="dedicated", + export_confirm=False, + output_dir="C:/Persist/Übergabe", + file_toolbar_visible=True, + editor_toolbar_collapsed=True, + sync_scroll_positions=False, + window_width=1111, + window_height=777, + ) + + main_module.SettingsStore().save(expected) + + assert main_module.SettingsStore().load() == expected + _, restarted_window = _make_window(main_module) + assert restarted_window.settings == expected + restarted_window.is_modified = False + restarted_window.close() + + +def test_self_test_preserves_existing_user_settings_bytes_and_mtime(main_module, tmp_path, monkeypatch): + appdata = tmp_path / "appdata" + monkeypatch.setenv("APPDATA", str(appdata)) + expected = main_module.AppSettings( + language="en", + theme="bright", + default_mode="editor", + autosave_enabled=False, + autosave_interval=47, + export_mode="dedicated", + export_confirm=False, + output_dir="C:/Persist/Übergabe", + file_toolbar_visible=True, + editor_toolbar_collapsed=True, + sync_scroll_positions=False, + window_width=1111, + window_height=777, + ) + store = main_module.SettingsStore() + store.save(expected) + before_bytes = store.path.read_bytes() + before_mtime_ns = store.path.stat().st_mtime_ns + + env = os.environ.copy() + env["APPDATA"] = str(appdata) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONPATH"] = os.pathsep.join(path for path in sys.path if path) + result = subprocess.run( + [sys.executable, "-X", "utf8", str(Path(main_module.__file__)), "--self-test"], + cwd=Path(main_module.__file__).parent, + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert store.path.read_bytes() == before_bytes + assert store.path.stat().st_mtime_ns == before_mtime_ns + assert main_module.SettingsStore().load() == expected + + def test_main_window_sanitizes_corrupt_settings_types(main_module, tmp_path, monkeypatch): monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) store = main_module.SettingsStore() diff --git a/tests/test_store_materials.py b/tests/test_store_materials.py index d9a3ac2..5cf6791 100644 --- a/tests/test_store_materials.py +++ b/tests/test_store_materials.py @@ -50,6 +50,15 @@ def test_privacy_policy_mentions_local(): def test_support_md_exists(): path = PROJECT_ROOT / "SUPPORT.md" assert path.exists(), "SUPPORT.md fehlt" + content = path.read_text(encoding="utf-8") + assert "%APPDATA%\\CleanMarkdown\\settings.json" in content + assert "QSettings" not in content + + +def test_privacy_policy_names_actual_settings_store(): + content = (PROJECT_ROOT / "PRIVACY_POLICY.md").read_text(encoding="utf-8") + assert "%APPDATA%\\CleanMarkdown\\settings.json" in content + assert "QSettings" not in content def test_windows_store_prep_exists(): @@ -98,25 +107,35 @@ def test_generator_source_uses_native_platform_not_offscreen(): @pytest.mark.skipif(not _HAS_QT, reason="PySide6 nicht verfuegbar") -def test_font_probe_flags_tofu_under_offscreen(): - """Unter offscreen (conftest-Default) muss die Font-Probe Tofu melden.""" +def test_generator_guard_rejects_offscreen_even_when_fonts_render(monkeypatch): + """Offscreen bleibt gesperrt, auch wenn ein Runner echte Glyphen rendert.""" import generate_store_screenshots as gen app = QApplication.instance() or QApplication([]) assert QApplication.platformName() == "offscreen", ( "Test erwartet die offscreen-Plattform aus conftest.py" ) - assert gen.font_rendering_works(app) is False + monkeypatch.setattr(gen, "font_rendering_works", lambda _app: True) + + with pytest.raises(RuntimeError, match="offscreen"): + gen._assert_font_rendering(app) @pytest.mark.skipif(not _HAS_QT, reason="PySide6 nicht verfuegbar") -def test_generator_guard_raises_on_tofu(): - """Abnahmekriterium: Der Generator wirft, statt still Tofu zu speichern.""" +def test_generator_guard_raises_when_native_font_probe_fails(monkeypatch): + """Auf nativer Plattform blockiert ein negativer Glyphen-Probe-Befund.""" import generate_store_screenshots as gen - app = QApplication.instance() or QApplication([]) - with pytest.raises(RuntimeError): - gen._assert_font_rendering(app) + class NativeApplication: + @staticmethod + def platformName(): + return "xcb" + + monkeypatch.setattr(gen, "QApplication", NativeApplication) + monkeypatch.setattr(gen, "font_rendering_works", lambda _app: False) + + with pytest.raises(RuntimeError, match="Tofu-Verdacht"): + gen._assert_font_rendering(object()) def test_build_exe_bat_guarded_preflight(): @@ -126,4 +145,3 @@ def test_build_exe_bat_guarded_preflight(): content = bat_file.read_text(encoding="utf-8") assert "if exist \"%SCANNER%\"" in content, "Scanner-Guard fehlt in build_exe.bat" assert "SOFTWARE_ROOT" in content, "SOFTWARE_ROOT-Variable fehlt in build_exe.bat" -