Files
tick-stock-panel/backend/tests/test_macos_data_dir.py
T
shy3130 5ff6e15995 fix(desktop): 迁移 skip 分支补捞 preferences.json
_migrate_legacy_data_dir 的 has_new_data skip 分支此前只捞 secrets.json
(0669204c), preferences.json 不捞 — 偏好丢失会让 onboarding_completed
回落默认 False, 升级后全屏引导重弹, 表现为「昨天同步过数据, 升级后
又被要求获取数据」(用户反馈链路)。函数泛化为 _rescue_legacy_user_data:
secrets.json + preferences.json 逐项捞回, 旧有新无才拷, 异常只告警。

验证: 新增 preferences 捞回/不覆盖 2 条用例; 原有 secrets 捞回 3 条
语义不变全绿。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-29 18:39:40 +08:00

246 lines
11 KiB
Python

"""macOS 数据目录移出 .app 包 — 路径解析与包内旧数据迁移。
背景: ≤0.3.6 的 frozen data 目录 = exe 同级, mac 上落在 TSP.app 包内,
覆盖安装替换 App 时旧包(含数据)整体进废纸篓, 用户数据全部"清空"。
修复: darwin frozen 默认 → ~/Library/Application Support/TSP (包外),
启动时自动把包内/兄弟目录的旧数据搬过来。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from app import config as config_mod
from app.config import _user_data_root
from app.tickflow.repository import DataStore
# ── 路径解析 ─────────────────────────────────────────────────────────
def test_darwin_frozen_uses_app_support_outside_bundle(tmp_path, monkeypatch):
home = tmp_path / "fakehome"
monkeypatch.setattr(config_mod, "_IS_FROZEN", True)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(Path, "home", lambda: home)
assert _user_data_root() == home / "Library" / "Application Support" / "TSP"
def test_windows_frozen_keeps_exe_sibling_data(tmp_path, monkeypatch):
exe = tmp_path / "TSP" / "TSP.exe"
monkeypatch.setattr(config_mod, "_IS_FROZEN", True)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(exe))
assert _user_data_root() == exe.parent / "data"
def test_dev_mode_uses_project_root(monkeypatch):
monkeypatch.setattr(config_mod, "_IS_FROZEN", False)
assert _user_data_root() == config_mod._PROJECT_ROOT / "data"
# ── 迁移 (直接构造最小实例调方法, 不触发完整 __init__) ────────────────
def _repo_with_data_dir(new_dir: Path) -> DataStore:
repo = DataStore.__new__(DataStore)
repo.data_dir = new_dir
return repo
def _make_real_data(root: Path) -> None:
(root / "kline_daily_enriched" / "date=2026-10-10").mkdir(parents=True)
(root / "kline_daily_enriched" / "date=2026-10-10" / "part.parquet").write_bytes(b"x")
(root / "user_data").mkdir(parents=True)
(root / "user_data" / "secrets.json").write_text("{}", encoding="utf-8")
def test_darwin_bundle_data_migrates_to_new_location(tmp_path, monkeypatch):
"""包内旧数据(用户从废纸篓恢复/从 dmg 运行过)首次启动自动搬出。"""
new_dir = tmp_path / "newdata"
new_dir.mkdir()
bundle_data = tmp_path / "TSP.app" / "Contents" / "MacOS" / "data"
_make_real_data(bundle_data)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").exists()
assert (new_dir / "kline_daily_enriched" / "date=2026-10-10" / "part.parquet").exists()
assert not bundle_data.exists() # 旧包内目录已清空
def test_migration_skips_when_new_location_has_data(tmp_path, monkeypatch):
"""新位置已有真实数据 → 绝不覆盖 (用户已在两套路径都跑过)。"""
new_dir = tmp_path / "newdata"
_make_real_data(new_dir)
(new_dir / "user_data" / "secrets.json").write_text('{"mine": 1}', encoding="utf-8")
bundle_data = tmp_path / "TSP.app" / "Contents" / "MacOS" / "data"
_make_real_data(bundle_data)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").read_text(encoding="utf-8") == '{"mine": 1}'
assert bundle_data.exists() # 旧数据原样保留
def test_migration_noop_without_legacy(tmp_path, monkeypatch):
new_dir = tmp_path / "newdata"
new_dir.mkdir()
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(tmp_path / "TSP.app" / "Contents" / "MacOS" / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert list(new_dir.iterdir()) == []
def test_windows_sibling_legacy_still_migrates(tmp_path, monkeypatch):
"""既有行为回归: Windows 旧版兄弟目录 TickFlowStockPanel_Data 照常搬迁。"""
new_dir = tmp_path / "TSP" / "data"
new_dir.mkdir(parents=True)
legacy = tmp_path / "TSP" / "TickFlowStockPanel_Data"
_make_real_data(legacy)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(tmp_path / "TSP" / "TSP.exe"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").exists()
assert not legacy.exists()
def test_migration_same_path_guard(tmp_path, monkeypatch):
"""防御: legacy 候选与当前 data_dir 同一路径时不动 (Windows 逻辑回归中的边界)。"""
data_dir = tmp_path / "TSP" / "data"
_make_real_data(data_dir)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(data_dir / "TSP.exe"))
_repo_with_data_dir(data_dir)._migrate_legacy_data_dir()
# 数据原样, 无自搬自
assert (data_dir / "user_data" / "secrets.json").exists()
def test_dev_mode_never_migrates(tmp_path, monkeypatch):
"""非 frozen (开发/Docker) 直接返回, 即使存在旧目录也不动。"""
new_dir = tmp_path / "newdata"
new_dir.mkdir()
legacy = new_dir.parent / "TickFlowStockPanel_Data"
_make_real_data(legacy)
monkeypatch.delattr(sys, "frozen", raising=False)
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert legacy.exists()
assert list(new_dir.iterdir()) == []
# ── 跳过整体迁移时单独捞回 secrets.json ───────────────────────────────
def _setup_skip_migration(tmp_path):
"""构造 has_new_data=True 场景: 新目录已有 parquet, 旧包数据还在。"""
new_dir = tmp_path / "newdata"
(new_dir / "kline_daily_enriched").mkdir(parents=True)
(new_dir / "kline_daily_enriched" / "part.parquet").write_bytes(b"x")
(new_dir / "user_data").mkdir()
bundle_data = tmp_path / "TSP.app" / "Contents" / "MacOS" / "data"
_make_real_data(bundle_data)
return new_dir, bundle_data
def test_migration_rescues_secrets_when_new_has_data(tmp_path, monkeypatch):
"""整体搬迁被跳过, 但新目录缺失的 secrets.json (密钥) 单独捞回。"""
new_dir, bundle_data = _setup_skip_migration(tmp_path)
(bundle_data / "user_data" / "secrets.json").write_text(
'{"ai_provider": "codex_cli", "ai_codex_model": "gpt-5.6-sol"}',
encoding="utf-8",
)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
rescued = new_dir / "user_data" / "secrets.json"
assert rescued.exists()
assert json.loads(rescued.read_text(encoding="utf-8"))["ai_provider"] == "codex_cli"
# 守卫语义不变: 旧目录整体保留, 新目录已有行情不被覆盖
assert (bundle_data / "user_data" / "secrets.json").exists()
assert (new_dir / "kline_daily_enriched" / "part.parquet").exists()
def test_migration_no_rescue_when_dest_secrets_exists(tmp_path, monkeypatch):
"""新目录已有 secrets.json (用户已重新配置) → 绝不覆盖。"""
new_dir, bundle_data = _setup_skip_migration(tmp_path)
(bundle_data / "user_data" / "secrets.json").write_text('{"legacy": 1}', encoding="utf-8")
(new_dir / "user_data" / "secrets.json").write_text('{"mine": 1}', encoding="utf-8")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").read_text(encoding="utf-8") == '{"mine": 1}'
def test_migration_rescue_skips_invalid_secrets(tmp_path, monkeypatch):
"""旧 secrets.json 非法/为空 → 跳过捞回, 不制造坏文件。"""
new_dir, bundle_data = _setup_skip_migration(tmp_path)
(bundle_data / "user_data" / "secrets.json").write_text("not-json{{", encoding="utf-8")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert not (new_dir / "user_data" / "secrets.json").exists()
def test_migration_rescues_preferences_when_new_has_data(tmp_path, monkeypatch):
"""skip 分支也要捞回 preferences.json: 偏好丢失会让 onboarding_completed
回落默认 False, 升级后全屏引导重弹 (「同步过数据却被要求获取数据」)。"""
new_dir, bundle_data = _setup_skip_migration(tmp_path)
(bundle_data / "user_data" / "preferences.json").write_text(
'{"onboarding_completed": true, "nav_hidden": ["a"]}',
encoding="utf-8",
)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
rescued = new_dir / "user_data" / "preferences.json"
assert rescued.exists()
prefs = json.loads(rescued.read_text(encoding="utf-8"))
assert prefs["onboarding_completed"] is True
# 守卫语义不变: 旧目录整体保留
assert (bundle_data / "user_data" / "preferences.json").exists()
def test_migration_no_rescue_when_dest_preferences_exists(tmp_path, monkeypatch):
"""新目录已有 preferences.json (新版已在使用) → 绝不覆盖。"""
new_dir, bundle_data = _setup_skip_migration(tmp_path)
(bundle_data / "user_data" / "preferences.json").write_text('{"legacy": true}', encoding="utf-8")
(new_dir / "user_data" / "preferences.json").write_text('{"mine": true}', encoding="utf-8")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "preferences.json").read_text(encoding="utf-8") == '{"mine": true}'