mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
- 根因: 开关只存 localStorage 且默认开启; 桌面版 _find_free_port 换端口即换 origin, 旧值读不到 → 闪回默认开 (uiPrefs 基建正是为此而生, 该键此前漏接) - assistant.fab.enabled 入 uiPrefs SPECS + 后端 _UI_PREFS_KEYS 白名单 (assistant_fab_enabled), setFabVisible 走 setUiPref 本地+后端双写 - hydrateUiPrefs 改共享 Promise 的幂等实现: main.tsx 与后进的 assistant store 无论先后, await 到的都是同一次回填; 回填完成后刷新 store 内存态 - 依赖方向保持 扩展→核心, 核心零依赖扩展; 新增白名单回归测试锁住该键
100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
"""界面偏好 (ui_prefs) 跨端持久化 — 原先只存 localStorage 的设置改由后端保存副本。
|
|
|
|
背景: 浏览器按 origin (协议+主机+端口) 隔离 localStorage。桌面版启动时用
|
|
_find_free_port(3018) 取第一个空闲端口并以 http://127.0.0.1:{port} 加载界面,
|
|
端口一变就是另一个 origin —— 通知弹窗/声效/语音播报/外链模板/界面缩放这些
|
|
只存 localStorage 的设置全部读不到, 表现为「设置保存了重开又变回去」。
|
|
|
|
这里锁住后端契约: 白名单、标量类型护栏、null 删除、体积上限、GET 可见。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.services import preferences
|
|
|
|
|
|
@pytest.fixture()
|
|
def prefs_file(tmp_path, monkeypatch):
|
|
"""把 preferences.json 指到 tmp, 并清掉进程内缓存。
|
|
|
|
不动 app.config.settings 对象本身 (其他模块在 import 时读它的属性),
|
|
只替换 preferences._path 这一条路径计算。
|
|
"""
|
|
from pathlib import Path
|
|
|
|
target = Path(tmp_path) / "user_data" / "preferences.json"
|
|
target.parent.mkdir(parents=True)
|
|
monkeypatch.setattr(preferences, "_path", lambda: target)
|
|
preferences._invalidate_cache()
|
|
yield target
|
|
preferences._invalidate_cache()
|
|
|
|
|
|
def test_defaults_empty(prefs_file):
|
|
"""从未设置过 → 空对象 (前端用自己的默认值, 后端不伪造)。"""
|
|
assert preferences.get_ui_prefs() == {}
|
|
|
|
|
|
def test_set_and_get_roundtrip(prefs_file):
|
|
saved = preferences.set_ui_prefs({
|
|
"alert_toast_max": 5,
|
|
"voice_broadcast_enabled": True,
|
|
"page_zoom": 1.15,
|
|
"stock_external_template": "https://x/{code}",
|
|
})
|
|
assert saved["alert_toast_max"] == 5
|
|
assert saved["voice_broadcast_enabled"] is True
|
|
assert saved["page_zoom"] == 1.15
|
|
assert preferences.get_ui_prefs()["stock_external_template"] == "https://x/{code}"
|
|
|
|
|
|
def test_unknown_keys_ignored(prefs_file):
|
|
"""未知键不进白名单 — 该 blob 由前端自由读写, 不能成为写任意结构的入口。"""
|
|
saved = preferences.set_ui_prefs({"evil": {"a": 1}, "alert_toast_max": 4})
|
|
assert "evil" not in saved
|
|
assert saved == {"alert_toast_max": 4}
|
|
|
|
|
|
def test_non_scalar_rejected(prefs_file):
|
|
with pytest.raises(ValueError, match="只接受标量"):
|
|
preferences.set_ui_prefs({"alert_toast_max": [1, 2]})
|
|
|
|
|
|
def test_null_removes_item(prefs_file):
|
|
"""null = 删除该项, 回前端默认 (而不是存一个 null)。"""
|
|
preferences.set_ui_prefs({"alert_sound": "chime", "alert_toast_max": 3})
|
|
saved = preferences.set_ui_prefs({"alert_sound": None})
|
|
assert "alert_sound" not in saved
|
|
assert saved["alert_toast_max"] == 3
|
|
|
|
|
|
def test_merge_preserves_other_keys(prefs_file):
|
|
"""增量更新不清掉之前写入的其它项 (设置页各控件独立保存)。"""
|
|
preferences.set_ui_prefs({"alert_toast_enabled": False})
|
|
preferences.set_ui_prefs({"page_zoom": 0.9})
|
|
got = preferences.get_ui_prefs()
|
|
assert got == {"alert_toast_enabled": False, "page_zoom": 0.9}
|
|
|
|
|
|
def test_oversize_rejected(prefs_file):
|
|
with pytest.raises(ValueError, match="过大"):
|
|
preferences.set_ui_prefs({"stock_external_template": "x" * (17 * 1024)})
|
|
|
|
|
|
def test_get_preferences_exposes_ui_prefs(prefs_file):
|
|
"""GET /api/settings/preferences 必须带 ui_prefs — 前端启动回填依赖它。"""
|
|
from app.api.settings import get_preferences
|
|
|
|
preferences.set_ui_prefs({"alert_toast_max": 2})
|
|
payload = get_preferences()
|
|
assert payload["ui_prefs"] == {"alert_toast_max": 2}
|
|
|
|
|
|
def test_assistant_fab_enabled_in_whitelist(prefs_file):
|
|
"""AI 助手悬浮球显隐必须在白名单内 — 否则桌面版换端口 (换 origin) 后
|
|
localStorage 丢失, 开关闪回默认开启 (用户反馈「关了过一会又开」)。"""
|
|
saved = preferences.set_ui_prefs({"assistant_fab_enabled": False})
|
|
assert saved["assistant_fab_enabled"] is False
|
|
assert preferences.get_ui_prefs()["assistant_fab_enabled"] is False
|