diff --git a/src/services/file_service.py b/src/services/file_service.py index 54395c3..ad7d122 100644 --- a/src/services/file_service.py +++ b/src/services/file_service.py @@ -22,9 +22,9 @@ def is_supported_image(path): def get_app_dir(): """Return the program root directory (read-only program files) as a Path. - PyInstaller onedir: directory that contains the .exe. In the versioned - install layout (docs/10-lan-update.md) this is versions//, which is - replaced wholesale on update. + PyInstaller onedir: directory that contains the .exe. In the launcher + layout (docs/10-lan-update.md) this is the app\\ folder, replaced wholesale + on update. Development: project root (three levels above this file: src/services/file_service.py -> src/services -> src -> project root). @@ -39,17 +39,20 @@ def get_data_dir(): """Return the writable data root (config, templates, logs, output) as a Path. Kept separate from the program root so that replacing the program on update - never touches user data (docs/10-lan-update.md §5). + never touches user data (docs/10-lan-update.md §5). Resolution order: - Resolution order: - 1. CMBOT_DATA_DIR environment variable, when set. The launcher points this - at the install-wide data/ folder in the versioned layout. - 2. Fallback to get_app_dir() — the flat layout used in development and in - the current onedir release, where data sits next to the program. + 1. CMBOT_DATA_DIR environment variable, when set — explicit override for + tests or special deployments. + 2. Packaged (sys.frozen) → ~/.cmbot (%USERPROFILE%\\.cmbot): always writable, + per-user, independent of where the program is installed, so it works even + when launched directly rather than through Launcher.exe. + 3. Development → project root, so dev runs don't pollute the home directory. """ env = os.environ.get("CMBOT_DATA_DIR", "").strip() if env: return Path(env) + if getattr(sys, "frozen", False): + return Path.home() / ".cmbot" return get_app_dir() diff --git a/tasks.md b/tasks.md index 44957a7..e8719f2 100644 --- a/tasks.md +++ b/tasks.md @@ -932,7 +932,7 @@ 任务: - [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16) -- [ ] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;更新单测 +- [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测 - [ ] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot` - [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\` - [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1` diff --git a/tests/test_file_service.py b/tests/test_file_service.py new file mode 100644 index 0000000..f88164c --- /dev/null +++ b/tests/test_file_service.py @@ -0,0 +1,56 @@ +"""Tests for services.file_service path resolution — no GUI dependency.""" +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import services.file_service as fs + + +class TestGetDataDir(unittest.TestCase): + """get_data_dir() three-tier fallback (docs/10-lan-update.md §5).""" + + def setUp(self): + self._env = os.environ.get("CMBOT_DATA_DIR") + self._frozen = getattr(sys, "frozen", None) + os.environ.pop("CMBOT_DATA_DIR", None) + if hasattr(sys, "frozen"): + del sys.frozen + + def tearDown(self): + if self._env is None: + os.environ.pop("CMBOT_DATA_DIR", None) + else: + os.environ["CMBOT_DATA_DIR"] = self._env + if self._frozen is None: + if hasattr(sys, "frozen"): + del sys.frozen + else: + sys.frozen = self._frozen + + def test_env_override_wins(self): + os.environ["CMBOT_DATA_DIR"] = r"X:\custom\data" + self.assertEqual(fs.get_data_dir(), Path(r"X:\custom\data")) + + def test_env_override_wins_even_when_frozen(self): + sys.frozen = True + os.environ["CMBOT_DATA_DIR"] = r"X:\custom\data" + self.assertEqual(fs.get_data_dir(), Path(r"X:\custom\data")) + + def test_packaged_uses_home_dotcmbot(self): + sys.frozen = True + self.assertEqual(fs.get_data_dir(), Path.home() / ".cmbot") + + def test_dev_uses_project_root(self): + # not frozen, no env -> same as program/app dir (project root in dev) + self.assertEqual(fs.get_data_dir(), fs.get_app_dir()) + + def test_blank_env_is_ignored(self): + os.environ["CMBOT_DATA_DIR"] = " " + self.assertEqual(fs.get_data_dir(), fs.get_app_dir()) + + +if __name__ == "__main__": + unittest.main()