"""Regression checks for Issue #1003: Appearance settings autosave. Focus: - Theme/Skin/Font size should autosave immediately and only show inline status. - Appearance changes should not participate in the global unsaved-changes guard. - Full-save flow should still include font_size. - /api/settings should accept appearance-only payloads and preserve untouched fields. """ import json import re import urllib.error import urllib.request from pathlib import Path from tests._pytest_port import BASE BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8") PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8") INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8") def _function_block(src: str, name: str) -> str: marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src) assert marker is not None, f"{name}() not found" start = marker.start() next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1 :]) if next_marker: end = start + 1 + next_marker.start() else: end = len(src) return src[start:end] def _post(path, body): data = json.dumps(body).encode("utf-8") req = urllib.request.Request( BASE + path, data=data, headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()), r.status except urllib.error.HTTPError as e: payload = e.read() return json.loads(payload or b"{}"), e.code def _get(path): with urllib.request.urlopen(BASE + path, timeout=10) as r: return json.loads(r.read()), r.status def test_appearance_pickers_schedule_autosave_and_do_not_mark_dirty(): for fn in ("_pickTheme", "_pickSkin", "_pickFontSize"): block = _function_block(BOOT_JS, fn) assert "_scheduleAppearanceAutosave()" in block, ( f"{fn}() should invoke _scheduleAppearanceAutosave()" ) assert "_markSettingsDirty()" not in block, ( f"{fn}() should not call _markSettingsDirty()" ) def test_appearance_revert_preview_no_longer_rolls_back_theme_skin_font(): block = _function_block(PANELS_JS, "_revertSettingsPreview") assert "hermes-theme" not in block assert "hermes-skin" not in block assert "hermes-font-size" not in block assert "_markSettingsDirty()" not in block def test_appearance_autosave_payload_is_theme_skin_font_only(): block = _function_block(PANELS_JS, "_appearancePayloadFromUi") assert "theme:" in block assert "skin:" in block assert "font_size:" in block assert "language:" not in block assert "workspace" not in block assert "show_token_usage" not in block status_block = _function_block(PANELS_JS, "_setAppearanceAutosaveStatus") assert "settings_autosave_saving" in status_block assert "settings_autosave_saved" in status_block assert "settings_autosave_failed" in status_block assert "settings_autosave_retry" in status_block def test_appearance_autosave_status_line_and_i18n_keys_exist(): assert 'id="settingsAppearanceAutosaveStatus"' in INDEX_HTML required_keys = [ "settings_autosave_saving", "settings_autosave_saved", "settings_autosave_failed", "settings_autosave_retry", ] for key in required_keys: assert I18N_JS.count(f"{key}:") >= 8, ( f"{key} must be defined in all LOCALES blocks (found {I18N_JS.count(f'{key}:')})" ) def test_full_save_settings_still_includes_font_size(): block = _function_block(PANELS_JS, "saveSettings") compact = block.replace(" ", "") assert "body.theme=theme;" in compact assert "body.skin=skin;" in compact assert "body.font_size=fontSize;" in compact def test_settings_api_accepts_appearance_only_payload_without_overwriting_other_fields(): original, status = _get("/api/settings") assert status == 200 snapshot = { "theme": original.get("theme"), "skin": original.get("skin"), "font_size": original.get("font_size", "default"), "show_token_usage": original.get("show_token_usage"), "show_cli_sessions": original.get("show_cli_sessions"), "check_for_updates": original.get("check_for_updates"), } try: d, status = _post("/api/settings", {"theme": "system", "skin": "charizard", "font_size": "large"}) assert status == 200 assert d.get("theme") == "system" assert d.get("skin") == "charizard" assert d.get("font_size") == "large" reloaded, _ = _get("/api/settings") assert reloaded.get("show_token_usage") == snapshot["show_token_usage"] assert reloaded.get("show_cli_sessions") == snapshot["show_cli_sessions"] assert reloaded.get("check_for_updates") == snapshot["check_for_updates"] finally: _post("/api/settings", { "theme": snapshot["theme"], "skin": snapshot["skin"], "font_size": snapshot["font_size"], })