fix: restore main runtime sources and cache consistency
This commit is contained in:
101
tests/test_data_db.py
Normal file
101
tests/test_data_db.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""data.db fresh-clone and transaction regression tests."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
import config
|
||||
import data.db as db
|
||||
from exam_pkg.local_db import ExamLocalDB
|
||||
|
||||
|
||||
class DataDbTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
test_root = os.path.join(project_root, ".data", "test-runs")
|
||||
os.makedirs(test_root, exist_ok=True)
|
||||
self.temp_dir = os.path.join(test_root, f"run-{uuid.uuid4().hex}")
|
||||
os.makedirs(self.temp_dir)
|
||||
self.original_paths = db.DB_PATHS
|
||||
self.original_is_dev = config.IS_DEV
|
||||
db.DB_PATHS = {
|
||||
"feedback": os.path.join(self.temp_dir, "prod", "feedback.db"),
|
||||
"knowledge": os.path.join(self.temp_dir, "prod", "knowledge.db"),
|
||||
"session": os.path.join(self.temp_dir, "dev", "session.db"),
|
||||
"exam": os.path.join(self.temp_dir, "dev", "exam.db"),
|
||||
}
|
||||
config.IS_DEV = True
|
||||
db._initialized = False
|
||||
|
||||
def tearDown(self):
|
||||
db._initialized = False
|
||||
db.DB_PATHS = self.original_paths
|
||||
config.IS_DEV = self.original_is_dev
|
||||
test_root = os.path.realpath(os.path.join(os.path.dirname(self.temp_dir)))
|
||||
target = os.path.realpath(self.temp_dir)
|
||||
if os.path.commonpath([test_root, target]) != test_root:
|
||||
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
def _table_names(self, db_name):
|
||||
with db.get_connection(db_name) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
def test_fresh_clone_initializes_all_dev_databases(self):
|
||||
db.init_databases()
|
||||
|
||||
for path in db.DB_PATHS.values():
|
||||
self.assertTrue(os.path.isfile(path), path)
|
||||
|
||||
self.assertTrue({"feedbacks", "faqs", "faq_suggestions"} <= self._table_names("feedback"))
|
||||
self.assertTrue({"document_hashes", "document_versions", "sync_status"} <= self._table_names("knowledge"))
|
||||
self.assertTrue({"sessions", "messages", "audit_logs"} <= self._table_names("session"))
|
||||
self.assertTrue({"questions", "exams", "exam_questions"} <= self._table_names("exam"))
|
||||
|
||||
with db.get_connection("knowledge") as conn:
|
||||
columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(document_versions)").fetchall()
|
||||
}
|
||||
self.assertTrue(
|
||||
{"content_hash", "expiry_date", "deprecated_date", "deprecated_by", "changed_sections"}
|
||||
<= columns
|
||||
)
|
||||
|
||||
# Initialization must remain safe when several services call it.
|
||||
db.init_databases()
|
||||
|
||||
def test_connection_commits_and_rolls_back(self):
|
||||
with db.get_connection("session") as conn:
|
||||
conn.execute("CREATE TABLE tx_test (value TEXT)")
|
||||
conn.execute("INSERT INTO tx_test VALUES ('committed')")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "rollback"):
|
||||
with db.get_connection("session") as conn:
|
||||
conn.execute("INSERT INTO tx_test VALUES ('rolled-back')")
|
||||
raise RuntimeError("rollback")
|
||||
|
||||
with db.get_connection("session") as conn:
|
||||
values = [row[0] for row in conn.execute("SELECT value FROM tx_test").fetchall()]
|
||||
self.assertEqual(["committed"], values)
|
||||
|
||||
def test_exam_creation_ignores_missing_question_ids(self):
|
||||
exam_db = ExamLocalDB()
|
||||
question_id = exam_db.add_question(
|
||||
{"content": "示例题", "answer": "答案", "score": 2},
|
||||
source_file="example.txt",
|
||||
source_collection="public",
|
||||
)
|
||||
|
||||
exam = exam_db.create_exam("回归测试", [question_id, "missing-question"])
|
||||
|
||||
self.assertEqual(1, exam["total_count"])
|
||||
self.assertEqual([question_id], exam["question_ids"])
|
||||
self.assertEqual(1, len(exam_db.get_exam(exam["id"])["questions"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
197
tests/test_shared_regressions.py
Normal file
197
tests/test_shared_regressions.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Regression tests for branch-shared RAG behavior."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.cache import RAGCacheManager
|
||||
from core.intent_analyzer import IntentAnalyzer
|
||||
from exam_pkg.generator import QuestionGenerator, analyze_document_for_exam
|
||||
from exam_pkg.grader import grade_fill_blank
|
||||
from knowledge.sync import ChangeType, DocumentChange, KnowledgeSyncService
|
||||
|
||||
|
||||
class CacheInvalidationTests(unittest.TestCase):
|
||||
def test_kb_version_change_clears_rerank_scores(self):
|
||||
cache = RAGCacheManager()
|
||||
cache.set_rerank_scores("问题", ["doc-1"], [0.91])
|
||||
self.assertEqual({"doc-1": 0.91}, cache.get_rerank_scores("问题", ["doc-1"]))
|
||||
|
||||
cache.increment_kb_version("public")
|
||||
|
||||
self.assertIsNone(cache.get_rerank_scores("问题", ["doc-1"]))
|
||||
|
||||
|
||||
class IntentCacheTests(unittest.TestCase):
|
||||
def test_unknown_semantic_cache_type_is_not_used_as_intent(self):
|
||||
class FakeEmbeddingModel:
|
||||
def encode(self, _text):
|
||||
return [1.0, 0.0]
|
||||
|
||||
class FakeCache:
|
||||
def __init__(self):
|
||||
self.saved = None
|
||||
|
||||
def get(self, _embedding):
|
||||
return {
|
||||
"cache_type": "future_cache_type",
|
||||
"rewritten_query": "错误的缓存结果",
|
||||
}
|
||||
|
||||
def set(self, _embedding, value):
|
||||
self.saved = value
|
||||
|
||||
def get_stats(self):
|
||||
return {}
|
||||
|
||||
analyzer = IntentAnalyzer(model="mimo-v2.5")
|
||||
analyzer._embedding_model = FakeEmbeddingModel()
|
||||
analyzer._cache = FakeCache()
|
||||
analyzer._client = object()
|
||||
|
||||
llm_result = json.dumps(
|
||||
{
|
||||
"rewritten_query": "来自 LLM 的新结果",
|
||||
"use_context": False,
|
||||
"need_retrieval": True,
|
||||
"reason": "新问题",
|
||||
"sub_queries": ["来自 LLM 的新结果"],
|
||||
"intent": "factual",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
with patch("core.llm_utils.call_llm", return_value=llm_result):
|
||||
result = analyzer.analyze("当前问题", [])
|
||||
|
||||
self.assertEqual("来自 LLM 的新结果", result.rewritten_query)
|
||||
self.assertEqual("intent_analysis", analyzer._cache.saved["cache_type"])
|
||||
|
||||
|
||||
class ExamRegressionTests(unittest.TestCase):
|
||||
def test_analysis_prompt_respects_requested_max_total(self):
|
||||
captured = {}
|
||||
|
||||
def fake_call(_self, prompt):
|
||||
captured["prompt"] = prompt
|
||||
return json.dumps(
|
||||
{
|
||||
"total_knowledge_points": 2,
|
||||
"suitable_types": ["single_choice"],
|
||||
"question_types": {
|
||||
"single_choice": 3,
|
||||
"multiple_choice": 0,
|
||||
"true_false": 0,
|
||||
"fill_blank": 0,
|
||||
"subjective": 0,
|
||||
},
|
||||
"reason": "测试",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
chunks = [{"content": "知识点" * 80, "section": "第一章"}]
|
||||
with patch.object(QuestionGenerator, "_call_llm", fake_call):
|
||||
result = analyze_document_for_exam(chunks, max_total=3)
|
||||
|
||||
self.assertIn("所有数量之和不要超过 3", captured["prompt"])
|
||||
self.assertLessEqual(sum(result["question_types"].values()), 3)
|
||||
|
||||
def test_fill_blank_rejects_non_list_student_answer(self):
|
||||
result = grade_fill_blank(
|
||||
{
|
||||
"question_id": "q1",
|
||||
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
|
||||
"student_answer": "正确答案",
|
||||
"max_score": 2,
|
||||
}
|
||||
)
|
||||
self.assertEqual("failed", result["grading_status"])
|
||||
self.assertEqual(0, result["score"])
|
||||
|
||||
def test_fill_blank_rejects_non_string_items(self):
|
||||
result = grade_fill_blank(
|
||||
{
|
||||
"question_id": "q1",
|
||||
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
|
||||
"student_answer": [{"text": "正确答案"}],
|
||||
"max_score": 2,
|
||||
}
|
||||
)
|
||||
self.assertEqual("failed", result["grading_status"])
|
||||
self.assertIn("第 1 项", result["details"]["error"])
|
||||
|
||||
|
||||
class SyncInvalidationTests(unittest.TestCase):
|
||||
def test_document_delete_clears_semantic_cache(self):
|
||||
class FakeDb:
|
||||
def delete_document_hash(self, _document_id):
|
||||
return None
|
||||
|
||||
class FakeKbManager:
|
||||
def delete_document(self, _kb_name, _document_name):
|
||||
return 1
|
||||
|
||||
class FakeSemanticCache:
|
||||
def __init__(self):
|
||||
self.cleared = False
|
||||
|
||||
def clear(self):
|
||||
self.cleared = True
|
||||
|
||||
service = object.__new__(KnowledgeSyncService)
|
||||
service.documents_path = "unused"
|
||||
service.db = FakeDb()
|
||||
semantic_cache = FakeSemanticCache()
|
||||
change = DocumentChange(
|
||||
document_id="public/example.pdf",
|
||||
document_name="example.pdf",
|
||||
change_type=ChangeType.DELETED,
|
||||
old_hash="old",
|
||||
new_hash=None,
|
||||
change_time=datetime.now(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("knowledge.manager.get_kb_manager", return_value=FakeKbManager()),
|
||||
patch("knowledge.sync.CACHE_AVAILABLE", False),
|
||||
patch("core.semantic_cache.get_semantic_cache", return_value=semantic_cache),
|
||||
):
|
||||
self.assertTrue(service.process_change(change))
|
||||
|
||||
self.assertTrue(semantic_cache.cleared)
|
||||
|
||||
|
||||
class ConfigTemplateTests(unittest.TestCase):
|
||||
def test_config_example_defines_all_direct_imports(self):
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
template_tree = ast.parse((root / "config.example.py").read_text(encoding="utf-8"))
|
||||
defined = {
|
||||
node.name
|
||||
for node in template_tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
for node in template_tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
defined.update(target.id for target in node.targets if isinstance(target, ast.Name))
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
defined.add(node.target.id)
|
||||
|
||||
imported = set()
|
||||
excluded = {"venv", ".git", ".data", ".recovery", "__pycache__"}
|
||||
for path in root.rglob("*.py"):
|
||||
if any(part in excluded for part in path.parts):
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "config":
|
||||
imported.update(alias.name for alias in node.names)
|
||||
|
||||
self.assertEqual([], sorted(imported - defined))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,12 +9,30 @@ Phase 3 验证测试:重复上传旧切片残留修复
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEST_TEMP_ROOT = os.path.join(PROJECT_ROOT, ".data", "test-runs")
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
def _make_test_dir():
|
||||
"""在工作区内创建测试目录,兼容 Codex 的 Windows 文件权限。"""
|
||||
os.makedirs(TEST_TEMP_ROOT, exist_ok=True)
|
||||
path = os.path.join(TEST_TEMP_ROOT, f"upload-{uuid.uuid4().hex}")
|
||||
os.makedirs(path)
|
||||
return path
|
||||
|
||||
|
||||
def _cleanup_test_dir(path):
|
||||
root = os.path.realpath(TEST_TEMP_ROOT)
|
||||
target = os.path.realpath(path)
|
||||
if os.path.commonpath([root, target]) != root:
|
||||
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
|
||||
def test_add_file_to_kb_dedup():
|
||||
@@ -143,7 +161,7 @@ def test_upload_document_overwrite():
|
||||
print("\n=== 测试 2:upload_document 同名文件覆盖 ===")
|
||||
|
||||
# 创建临时目录
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
@@ -184,14 +202,14 @@ def test_upload_document_overwrite():
|
||||
print(f" [PASS] replaced={replaced}")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_batch_upload_overwrite():
|
||||
"""测试批量上传中的同名文件覆盖逻辑"""
|
||||
print("\n=== 测试 3:批量上传同名文件覆盖 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
@@ -240,14 +258,14 @@ def test_batch_upload_overwrite():
|
||||
print(f" [PASS] 文件内容已更新")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_docstore_cleanup():
|
||||
"""测试 DocStore 文件清理逻辑"""
|
||||
print("\n=== 测试 4:DocStore 文件清理 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
docstore_dir = Path(temp_dir) / 'docstore'
|
||||
docstore_dir.mkdir()
|
||||
@@ -289,7 +307,7 @@ def test_docstore_cleanup():
|
||||
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_sync_hash_cleanup():
|
||||
|
||||
Reference in New Issue
Block a user