183 lines
7.2 KiB
Python
183 lines
7.2 KiB
Python
"""Tests for cloud/memory.py (MemoryStore) and cloud/kb.py (KBStore).
|
||
|
||
Run from ~/workspace/codex-app-server:
|
||
python3 -m unittest discover -s cloud/tests -p "test_memory_kb.py" -v
|
||
|
||
No network, no real model turns, no secrets. Each test uses a fresh
|
||
temporary SQLite file.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
|
||
sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")))
|
||
|
||
from kb import KBStore # noqa: E402
|
||
from memory import MemoryStore # noqa: E402
|
||
|
||
|
||
def _tmp_db():
|
||
fd, path = tempfile.mkstemp(suffix=".db")
|
||
os.close(fd)
|
||
os.unlink(path) # let sqlite create it fresh
|
||
return path
|
||
|
||
|
||
class MemoryStoreTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = _tmp_db()
|
||
self.store = MemoryStore(self.db)
|
||
|
||
def tearDown(self):
|
||
try:
|
||
self.store.conn.close()
|
||
finally:
|
||
if os.path.exists(self.db):
|
||
os.unlink(self.db)
|
||
|
||
def test_add_then_list(self):
|
||
sid = self.store.add_summary(
|
||
"proj-a", "thread-1", "add login page", "login page done",
|
||
files_changed=["app.py"], open_issues=["tests pending"],
|
||
)
|
||
self.assertIsInstance(sid, int)
|
||
rows = self.store.list_summaries("proj-a")
|
||
self.assertEqual(len(rows), 1)
|
||
self.assertEqual(rows[0]["thread_id"], "thread-1")
|
||
self.assertEqual(rows[0]["goal"], "add login page")
|
||
self.assertEqual(rows[0]["files_changed"], ["app.py"])
|
||
self.assertEqual(rows[0]["open_issues"], ["tests pending"])
|
||
self.assertEqual(rows[0]["status"], "active")
|
||
|
||
def test_deprecate_hidden_by_default_visible_with_flag(self):
|
||
sid = self.store.add_summary("proj-a", "t1", "g1", "c1")
|
||
self.store.add_summary("proj-a", "t2", "g2", "c2")
|
||
self.store.deprecate(sid)
|
||
default_rows = self.store.list_summaries("proj-a")
|
||
self.assertEqual([r["thread_id"] for r in default_rows], ["t2"])
|
||
all_rows = self.store.list_summaries("proj-a", include_deprecated=True)
|
||
self.assertEqual(len(all_rows), 2)
|
||
deprecated = [r for r in all_rows if r["id"] == sid]
|
||
self.assertEqual(len(deprecated), 1)
|
||
self.assertEqual(deprecated[0]["status"], "deprecated")
|
||
|
||
def test_delete_removes(self):
|
||
sid = self.store.add_summary("proj-a", "t1", "g1", "c1")
|
||
self.store.delete(sid)
|
||
self.assertEqual(self.store.list_summaries("proj-a"), [])
|
||
self.assertEqual(
|
||
self.store.list_summaries("proj-a", include_deprecated=True), []
|
||
)
|
||
|
||
def test_build_prompt_context_top_k_and_excludes_deprecated(self):
|
||
# 7 active summaries; only the newest 5 may appear.
|
||
for i in range(7):
|
||
self.store.add_summary("proj-a", "t%d" % i, "goal-%d" % i, "done-%d" % i)
|
||
ctx = self.store.build_prompt_context("proj-a", top_k=5)
|
||
self.assertEqual(ctx.count("\n") + 1, 5)
|
||
for i in range(2, 7):
|
||
self.assertIn("goal-%d" % i, ctx)
|
||
for i in range(2):
|
||
self.assertNotIn("goal-%d" % i, ctx)
|
||
# Format spot-check on one line.
|
||
self.assertIn("- [t6] 目标:goal-6;结论:done-6;", ctx)
|
||
# A deprecated newest summary must not appear even within top_k.
|
||
sid = self.store.add_summary("proj-a", "t-new", "goal-new", "done-new")
|
||
self.store.deprecate(sid)
|
||
ctx2 = self.store.build_prompt_context("proj-a", top_k=5)
|
||
self.assertNotIn("goal-new", ctx2)
|
||
self.assertIn("goal-6", ctx2)
|
||
|
||
def test_build_prompt_context_empty(self):
|
||
self.assertEqual(self.store.build_prompt_context("proj-empty"), "")
|
||
|
||
|
||
class KBStoreTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.db = _tmp_db()
|
||
self.store = KBStore(self.db)
|
||
|
||
def tearDown(self):
|
||
try:
|
||
self.store.conn.close()
|
||
finally:
|
||
if os.path.exists(self.db):
|
||
os.unlink(self.db)
|
||
|
||
def test_search_finds_file_by_keyword(self):
|
||
self.store.index_file(
|
||
"proj-a", "docs/auth.md",
|
||
"authentication flow uses token refresh and session cookies",
|
||
kind="doc",
|
||
)
|
||
self.store.index_file(
|
||
"proj-a", "docs/other.md", "unrelated gardening notes", kind="doc"
|
||
)
|
||
results = self.store.search("proj-a", "authentication")
|
||
self.assertEqual(len(results), 1)
|
||
self.assertEqual(results[0]["path"], "docs/auth.md")
|
||
self.assertEqual(results[0]["kind"], "doc")
|
||
self.assertIn("authentication", results[0]["snippet"])
|
||
self.assertLessEqual(len(results[0]["snippet"]), 200)
|
||
|
||
def test_search_project_isolation(self):
|
||
self.store.index_file(
|
||
"proj-a", "docs/secret.md", "zebra secret keyword", kind="doc"
|
||
)
|
||
self.store.index_file(
|
||
"proj-b", "docs/plain.md", "plain notes with no keyword", kind="doc"
|
||
)
|
||
# A's doc must never leak into B's search, and vice versa.
|
||
self.assertEqual(self.store.search("proj-b", "zebra"), [])
|
||
self.assertEqual(self.store.search("proj-a", "zebra")[0]["path"], "docs/secret.md")
|
||
|
||
def test_reindex_same_path_no_duplicates(self):
|
||
self.store.index_file("proj-a", "README.md", "version one content", kind="readme")
|
||
self.store.index_file("proj-a", "README.md", "version two content", kind="readme")
|
||
docs = self.store.list_docs("proj-a")
|
||
self.assertEqual(len(docs), 1)
|
||
self.assertEqual(docs[0]["path"], "README.md")
|
||
self.assertEqual(docs[0]["content"], "version two content")
|
||
# Search must also see only the new version, once.
|
||
self.assertEqual(len(self.store.search("proj-a", "version")), 1)
|
||
self.assertEqual(self.store.search("proj-a", "version")[0]["snippet"],
|
||
"version two content")
|
||
self.assertEqual(self.store.search("proj-a", "one"), [])
|
||
|
||
def test_index_task_and_list_docs(self):
|
||
tid = self.store.index_task(
|
||
"proj-a", "task-42", "fix the login bug", "fixed by resetting tokens"
|
||
)
|
||
self.assertIsInstance(tid, int)
|
||
docs = self.store.list_docs("proj-a")
|
||
self.assertEqual(len(docs), 1)
|
||
self.assertEqual(docs[0]["path"], "task:task-42")
|
||
self.assertEqual(docs[0]["kind"], "task")
|
||
results = self.store.search("proj-a", "login")
|
||
self.assertEqual(len(results), 1)
|
||
self.assertEqual(results[0]["path"], "task:task-42")
|
||
|
||
def test_search_chinese_keyword(self):
|
||
# FTS5 unicode61 does not tokenize CJK words, so a verbatim
|
||
# Chinese keyword must still be found via the LIKE fallback.
|
||
self.store.index_file(
|
||
"proj-a", "docs/login.md", "用户登录流程使用令牌刷新", kind="doc"
|
||
)
|
||
results = self.store.search("proj-a", "登录")
|
||
self.assertEqual(len(results), 1)
|
||
self.assertEqual(results[0]["path"], "docs/login.md")
|
||
|
||
def test_search_top_k_and_empty_query(self):
|
||
for i in range(4):
|
||
self.store.index_file(
|
||
"proj-a", "doc%d.md" % i, "shared keyword doc %d" % i, kind="doc"
|
||
)
|
||
self.assertEqual(len(self.store.search("proj-a", "shared", top_k=2)), 2)
|
||
self.assertEqual(self.store.search("proj-a", ""), [])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|