"""RuntimeSettings 单元测试:默认值 / 加载 / 保存 / 部分更新 / 重置 / 单例 每个测试通过 monkeypatch 把 RUNTIME_SETTINGS_PATH 指向独立临时文件,并在前后 重置模块级 `_runtime_settings` 单例,避免跨测试串扰。 """ import json from pathlib import Path import pytest from app.core import runtime_settings as rs @pytest.fixture def isolated_settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """每个测试独立持久化路径,并在前后清空模块级单例""" path = tmp_path / "runtime_settings.json" monkeypatch.setenv("RUNTIME_SETTINGS_PATH", str(path)) # 重置单例,强制下次 get_runtime_settings 重新加载 rs._runtime_settings = None yield path rs._runtime_settings = None # ---------------------------------------------------------------------------- # # 默认值 # ---------------------------------------------------------------------------- # class TestDefaults: def test_default_models_provider_is_ollama(self): cfg = rs.RuntimeSettings() assert cfg.models.summarize.provider == "ollama" assert cfg.models.query.provider == "ollama" assert cfg.models.classify.provider == "ollama" def test_default_parsers_plugins(self): cfg = rs.RuntimeSettings() assert cfg.parsers.ocr.plugin == "rapidocr" assert cfg.parsers.pdf.plugin == "pypdf" assert cfg.parsers.docx.plugin == "python_docx" def test_default_dedup(self): cfg = rs.RuntimeSettings() assert cfg.dedup.strategy == "sha256" assert cfg.dedup.simhash_threshold == 3 assert cfg.dedup.ttl_seconds == 86400 def test_default_with_env_fallback_uses_ollama_env(self): """无持久化文件时,base_url/model 取自 settings.ollama_*""" cfg = rs._default_with_env_fallback() from app.config import settings assert cfg.models.summarize.base_url == settings.ollama_base_url assert cfg.models.summarize.model == settings.ollama_model assert cfg.models.query.base_url == settings.ollama_base_url assert cfg.models.classify.model == settings.ollama_model # ---------------------------------------------------------------------------- # # 加载 # ---------------------------------------------------------------------------- # class TestLoad: def test_missing_file_returns_env_fallback(self, isolated_settings_path: Path): """文件不存在时回退到带 env 兜底的默认值(不抛异常)""" assert not isolated_settings_path.exists() cfg = rs.load_runtime_settings() # base_url 应来自 env fallback from app.config import settings assert cfg.models.summarize.base_url == settings.ollama_base_url def test_valid_file_parsed(self, isolated_settings_path: Path): isolated_settings_path.write_text( json.dumps( { "models": { "summarize": {"provider": "openai_compatible", "model": "gpt-4o-mini", "api_key": "k"} }, "dedup": {"strategy": "simhash", "simhash_threshold": 5}, } ), encoding="utf-8", ) cfg = rs.load_runtime_settings() assert cfg.models.summarize.provider == "openai_compatible" assert cfg.models.summarize.model == "gpt-4o-mini" assert cfg.models.summarize.api_key == "k" assert cfg.dedup.strategy == "simhash" assert cfg.dedup.simhash_threshold == 5 # 未指定的字段保留默认 assert cfg.dedup.ttl_seconds == 86400 assert cfg.models.query.provider == "ollama" def test_corrupted_file_falls_back(self, isolated_settings_path: Path): isolated_settings_path.write_text("not-json{", encoding="utf-8") cfg = rs.load_runtime_settings() # 回退到默认(ollama provider) assert cfg.models.summarize.provider == "ollama" def test_invalid_values_falls_back(self, isolated_settings_path: Path): """字段值非法(如未知 provider)时整体回退默认""" isolated_settings_path.write_text( json.dumps({"models": {"summarize": {"provider": "unknown_provider"}}}), encoding="utf-8", ) cfg = rs.load_runtime_settings() assert cfg.models.summarize.provider == "ollama" # 回退默认 # ---------------------------------------------------------------------------- # # 保存 # ---------------------------------------------------------------------------- # class TestSave: def test_save_writes_valid_json(self, isolated_settings_path: Path): cfg = rs.RuntimeSettings() cfg.dedup.strategy = "simhash" rs.save_runtime_settings(cfg) assert isolated_settings_path.exists() data = json.loads(isolated_settings_path.read_text(encoding="utf-8")) assert data["dedup"]["strategy"] == "simhash" def test_save_creates_parent_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): path = tmp_path / "nested" / "deep" / "runtime_settings.json" monkeypatch.setenv("RUNTIME_SETTINGS_PATH", str(path)) rs.save_runtime_settings(rs.RuntimeSettings()) assert path.exists() def test_save_atomic_no_tmp_left(self, isolated_settings_path: Path): """保存后同目录无残留 .tmp 临时文件""" rs.save_runtime_settings(rs.RuntimeSettings()) tmps = list(isolated_settings_path.parent.glob(".runtime_settings.*.tmp")) assert tmps == [] # ---------------------------------------------------------------------------- # # 单例 + reload # ---------------------------------------------------------------------------- # class TestSingleton: def test_get_returns_singleton(self, isolated_settings_path: Path): cfg1 = rs.get_runtime_settings() cfg2 = rs.get_runtime_settings() assert cfg1 is cfg2 def test_reload_rereads_disk(self, isolated_settings_path: Path): """reload 强制重新读盘,单例替换为新对象""" cfg1 = rs.get_runtime_settings() # 直接改盘上文件 isolated_settings_path.write_text( json.dumps({"dedup": {"strategy": "none"}}), encoding="utf-8" ) cfg2 = rs.reload_runtime_settings() assert cfg2 is not cfg1 assert cfg2.dedup.strategy == "none" # ---------------------------------------------------------------------------- # # 部分更新(深合并) # ---------------------------------------------------------------------------- # class TestUpdate: def test_partial_update_models_summarize(self, isolated_settings_path: Path): rs.get_runtime_settings() # 初始化单例 new_cfg = rs.update_runtime_settings( {"models": {"summarize": {"model": "qwen2.5:3b"}}} ) assert new_cfg.models.summarize.model == "qwen2.5:3b" # 其他字段保留 assert new_cfg.models.summarize.provider == "ollama" assert new_cfg.models.query.provider == "ollama" def test_partial_update_dedup(self, isolated_settings_path: Path): rs.get_runtime_settings() new_cfg = rs.update_runtime_settings( {"dedup": {"strategy": "simhash", "simhash_threshold": 5}} ) assert new_cfg.dedup.strategy == "simhash" assert new_cfg.dedup.simhash_threshold == 5 # ttl 未在 patch 中,保留默认 assert new_cfg.dedup.ttl_seconds == 86400 def test_update_persists_to_disk(self, isolated_settings_path: Path): rs.get_runtime_settings() rs.update_runtime_settings({"dedup": {"strategy": "none"}}) data = json.loads(isolated_settings_path.read_text(encoding="utf-8")) assert data["dedup"]["strategy"] == "none" def test_update_replaces_singleton(self, isolated_settings_path: Path): old = rs.get_runtime_settings() new = rs.update_runtime_settings({"dedup": {"strategy": "none"}}) assert new is not old # 后续 get 拿到的是新单例 assert rs.get_runtime_settings() is new def test_update_empty_patch_keeps_all(self, isolated_settings_path: Path): """空 patch 不改变任何字段""" rs.get_runtime_settings() new_cfg = rs.update_runtime_settings({}) assert new_cfg.dedup.strategy == "sha256" # ---------------------------------------------------------------------------- # # 重置 # ---------------------------------------------------------------------------- # class TestReset: def test_reset_returns_defaults(self, isolated_settings_path: Path): # 先污染 rs.get_runtime_settings() rs.update_runtime_settings({"dedup": {"strategy": "none"}}) assert rs.get_runtime_settings().dedup.strategy == "none" # 重置 cfg = rs.reset_runtime_settings() assert cfg.dedup.strategy == "sha256" assert cfg.dedup.simhash_threshold == 3 assert cfg.parsers.ocr.plugin == "rapidocr" def test_reset_persists_to_disk(self, isolated_settings_path: Path): rs.get_runtime_settings() rs.update_runtime_settings({"dedup": {"strategy": "none"}}) rs.reset_runtime_settings() data = json.loads(isolated_settings_path.read_text(encoding="utf-8")) assert data["dedup"]["strategy"] == "sha256" # ---------------------------------------------------------------------------- # # 深合并工具函数 # ---------------------------------------------------------------------------- # class TestDeepMerge: def test_nested_dict_merged(self): target = {"a": {"b": 1, "c": 2}, "d": 3} rs._deep_merge(target, {"a": {"b": 10}}) assert target == {"a": {"b": 10, "c": 2}, "d": 3} def test_non_dict_overrides(self): target = {"a": {"b": 1}} rs._deep_merge(target, {"a": 99}) assert target == {"a": 99} def test_new_key_added(self): target = {"a": 1} rs._deep_merge(target, {"b": 2}) assert target == {"a": 1, "b": 2}