Files
QMDSearch/tests/test_runtime_settings.py
kplam 2ab8b56a01 feat: 完成全量功能开发,包括前端管理后台与后端服务优化
此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
2026-07-31 12:05:25 +08:00

255 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}