# coding=utf-8 """paths.py / process_utils.py 跨平台抽象层测试. 本套件的第一职责是**防止迁移期行为漂移**:macOS 上新抽象必须解析出 与旧硬编码**逐字相同**的路径。历史上采集器 PID 文件、心跳文件的位置 被 launchd plist、Swift 端探测逻辑、运行中进程三处同时引用, 任何一处变化都会表现为"采集器在跑、App 却报未运行"。 第二职责是覆盖 Windows 分支的路径语义(不依赖真的跑在 Windows 上, 用 monkeypatch 切 ``IS_WINDOWS`` / ``sys.platform``)。 """ import os import sys from pathlib import Path import pytest _bin_dir = os.path.join(os.path.dirname(__file__), "..") if _bin_dir not in sys.path: sys.path.insert(0, _bin_dir) import paths # noqa: E402 import process_utils # noqa: E402 # ══════════════════════════════════════════════════════════ # paths —— macOS 等价性(回归保护,最关键) # ══════════════════════════════════════════════════════════ @pytest.mark.skipif(sys.platform != "darwin", reason="仅在 macOS 上校验等价性") class TestMacOSEquivalence: """断言与迁移前硬编码完全一致。改这些断言前先想清楚兼容性。""" def test_pid_file_matches_legacy_literal(self): # 旧写法:PID_FILE = "/tmp/findatahub-newsflash.pid" assert paths.pid_file("newsflash") == "/tmp/findatahub-newsflash.pid" def test_backend_pid_file(self): # 旧写法:"/tmp/findatahub-newsflash-backend.pid" assert paths.pid_file("newsflash-backend") == "/tmp/findatahub-newsflash-backend.pid" def test_heartbeat_file(self): # 旧写法:"/tmp/findatahub-newsflash.heartbeat" assert paths.heartbeat_file("newsflash") == "/tmp/findatahub-newsflash.heartbeat" def test_temp_dir_is_tmp_not_gettempdir(self): """必须返回 /tmp,而不是 $TMPDIR。 tempfile.gettempdir() 在 macOS 返回 /var/folders/xx/.../T, 与现网 PID 文件所在 /tmp 不是同一个目录——混用即事故。 """ assert paths.temp_dir() == Path("/tmp") import tempfile # 明确记录两者不同,避免后人"顺手"改回 gettempdir assert Path(tempfile.gettempdir()) != paths.temp_dir() def test_log_root(self): assert paths.log_root() == Path.home() / "Library/Logs/FinDataHub" def test_log_dir_with_sub(self): assert paths.log_dir("newsflash") == ( Path.home() / "Library/Logs/FinDataHub/newsflash" ) def test_app_support_dir(self): assert paths.app_support_dir() == ( Path.home() / "Library/Application Support/FinDataHub" ) def test_backups_dir(self): assert paths.backups_dir() == ( Path.home() / "Library/Application Support/FinDataHub/backups" ) def test_user_defaults_plist(self): assert paths.user_defaults_plist() == ( Path.home() / "Library/Preferences/com.findatahub.app.plist" ) def test_config_dir(self): assert paths.config_dir() == Path.home() / ".config/findatahub" def test_env_file(self): assert paths.env_file() == Path.home() / ".config/findatahub/env" # ══════════════════════════════════════════════════════════ # paths —— Windows 分支 # ══════════════════════════════════════════════════════════ class TestWindowsPaths: @pytest.fixture def win(self, monkeypatch): monkeypatch.setattr(paths, "IS_WINDOWS", True) monkeypatch.setattr(paths, "IS_MACOS", False) monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\tester\AppData\Local") monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming") return paths def test_log_root_under_localappdata(self, win): assert win.log_root() == Path(r"C:\Users\tester\AppData\Local/FinDataHub/logs") def test_app_support_under_localappdata(self, win): assert win.app_support_dir() == Path(r"C:\Users\tester\AppData\Local/FinDataHub") def test_config_under_appdata(self, win): assert win.config_dir() == Path(r"C:\Users\tester\AppData\Roaming/FinDataHub") def test_no_plist_on_windows(self, win): """Windows 无 plist——调用方必须处理 None,不能假设路径存在.""" assert win.user_defaults_plist() is None def test_pid_file_uses_system_temp(self, win): p = win.pid_file("newsflash") assert p.endswith("findatahub-newsflash.pid") assert "/tmp/" not in p # ══════════════════════════════════════════════════════════ # process_utils # ══════════════════════════════════════════════════════════ class TestDetach: def test_posix_uses_start_new_session(self): if process_utils.IS_WINDOWS: pytest.skip("仅 POSIX") assert process_utils.detach_kwargs() == {"start_new_session": True} def test_windows_uses_creationflags(self, monkeypatch): monkeypatch.setattr(process_utils, "IS_WINDOWS", True) kw = process_utils.detach_kwargs() assert "creationflags" in kw assert "start_new_session" not in kw # CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS assert kw["creationflags"] & 0x00000200 assert kw["creationflags"] & 0x00000008 def test_hidden_window_noop_on_posix(self): if process_utils.IS_WINDOWS: pytest.skip("仅 POSIX") assert process_utils.hidden_window_kwargs() == {} class TestIsRunning: def test_current_process_alive(self): assert process_utils.is_running(os.getpid()) is True def test_nonexistent_pid(self): # 取一个几乎不可能存在的 PID assert process_utils.is_running(999_999) is False @pytest.mark.parametrize("bad", [0, -1, -999]) def test_invalid_pid(self, bad): """非法 PID 直接判死,不得交给 os.kill 触发异常或误杀进程组.""" assert process_utils.is_running(bad) is False class TestTerminate: def test_invalid_pid_noop(self): assert process_utils.terminate_pid(0) is False assert process_utils.terminate_pid(-1) is False def test_terminate_nonexistent_returns_false(self): assert process_utils.terminate_pid(999_999) is False class TestSignals: def test_posix_has_core_signals(self): if process_utils.IS_WINDOWS: pytest.skip("仅 POSIX") sigs = process_utils.available_signals() assert "SIGTERM" in sigs assert "SIGINT" in sigs # SIGHUP 在 Windows 上不存在,POSIX 上应存在 assert "SIGHUP" in sigs def test_register_skips_missing_signals(self): """平台不存在的信号必须跳过而不是 AttributeError 崩溃.""" called = [] ok = process_utils.register_signal_handler( lambda s, f: called.append(s), names=("SIGTERM", "SIG_THIS_DOES_NOT_EXIST"), ) assert "SIGTERM" in ok assert "SIG_THIS_DOES_NOT_EXIST" not in ok def test_register_returns_registered_names(self): original = process_utils.signal.getsignal(process_utils.signal.SIGINT) try: names = process_utils.register_signal_handler( lambda s, f: None, names=("SIGINT",) ) assert names == ["SIGINT"] finally: process_utils.signal.signal(process_utils.signal.SIGINT, original) class TestUtf8Env: def test_sets_utf8_vars(self): env = process_utils.utf8_subprocess_env({"PATH": "/usr/bin"}) assert env["PYTHONUTF8"] == "1" assert env["PYTHONIOENCODING"] == "utf-8" assert env["PATH"] == "/usr/bin" def test_does_not_mutate_input(self): base = {"PATH": "/usr/bin"} process_utils.utf8_subprocess_env(base) assert "PYTHONUTF8" not in base, "必须返回副本,否则污染调用方环境" def test_defaults_to_os_environ_copy(self): env = process_utils.utf8_subprocess_env() assert env is not os.environ assert env["PYTHONUTF8"] == "1" class TestServiceLayout: """bundle / 源码树两种布局的资源定位。 背景:``backfill_stock_daily.py`` 曾把 ``/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src`` 写死在 ``sys.path.insert`` 里——macOS 现网能跑,但换平台或换安装位置即 ImportError。 ``service_dir()`` 必须同时认两种布局。 """ def _fake_layout(self, tmp_path, monkeypatch, *, bundle: bool, tree: bool): """构造一个假的 bin 目录,并把 paths.__file__ 指过去.""" root = tmp_path / ("Resources" if bundle else "repo") bin_dir = root / "bin" bin_dir.mkdir(parents=True) if bundle: (root / "akshare_fetcher" / "src").mkdir(parents=True) if tree: (root / "Services" / "akshare_fetcher" / "src").mkdir(parents=True) monkeypatch.setattr(paths, "__file__", str(bin_dir / "paths.py")) return root def test_bundle_layout(self, tmp_path, monkeypatch): root = self._fake_layout(tmp_path, monkeypatch, bundle=True, tree=False) assert paths.service_dir("akshare_fetcher") == root / "akshare_fetcher" def test_source_tree_layout(self, tmp_path, monkeypatch): root = self._fake_layout(tmp_path, monkeypatch, bundle=False, tree=True) assert paths.service_dir("akshare_fetcher") == root / "Services" / "akshare_fetcher" def test_bundle_wins_when_both_exist(self, tmp_path, monkeypatch): """两种布局同时存在时优先 bundle——部署位才是生产真相.""" root = self._fake_layout(tmp_path, monkeypatch, bundle=True, tree=True) assert paths.service_dir("akshare_fetcher") == root / "akshare_fetcher" def test_missing_returns_bundle_shape(self, tmp_path, monkeypatch): """都不存在时返回 bundle 形态,错误信息才能指向期望位置.""" root = self._fake_layout(tmp_path, monkeypatch, bundle=False, tree=False) assert paths.service_dir("no_such_svc") == root / "no_such_svc" def test_src_dir_appends_src(self, tmp_path, monkeypatch): root = self._fake_layout(tmp_path, monkeypatch, bundle=True, tree=False) assert paths.service_src_dir("akshare_fetcher") == root / "akshare_fetcher" / "src" assert paths.service_src_dir("akshare_fetcher").is_dir() def test_real_repo_layout_resolves(self): """真实仓库下必须解析到 Services/akshare_fetcher 且 src 存在. 这条是「部署脚本漏拷服务目录」的第一道信号:源码树都解析不出来, bundle 里更不会有。 """ svc = paths.service_dir("akshare_fetcher") assert (svc / "src").is_dir(), f"源码树未解析到 src 目录: {svc}" assert svc.name == "akshare_fetcher"