feat(harness): store.py — Store 版本操作 + Seed 管理
从 TRM4 core/workspace.py 拆出 Store + Seed 相关函数: - _parse_version / list_versions / next_version / advance_version - _write_meta / init_store - init_seed / list_seeds / read_seed - extract_run_db(保留原始 CREATE 语句重建主键约束) - promote_to_seed(强校验版本一致 + 非 NULL + finally 清理) 26 个测试全部通过,radon 复杂度 A (2.83)。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
"""Store 版本操作 + Seed 管理的单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from app.harness.store import (
|
||||
_parse_version,
|
||||
_write_meta,
|
||||
advance_version,
|
||||
extract_run_db,
|
||||
init_seed,
|
||||
init_store,
|
||||
list_seeds,
|
||||
list_versions,
|
||||
next_version,
|
||||
promote_to_seed,
|
||||
read_seed,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseVersion:
|
||||
"""_parse_version 解析 v\\d+ 格式版本号。"""
|
||||
|
||||
def test_parse_version_normal(self) -> None:
|
||||
assert _parse_version("v1") == 1
|
||||
assert _parse_version("v10") == 10
|
||||
assert _parse_version("v999") == 999
|
||||
|
||||
def test_parse_version_invalid(self) -> None:
|
||||
with pytest.raises(ValueError, match="无效版本号"):
|
||||
_parse_version("abc")
|
||||
with pytest.raises(ValueError, match="无效版本号"):
|
||||
_parse_version("v")
|
||||
with pytest.raises(ValueError, match="无效版本号"):
|
||||
_parse_version("v1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_versions — 数字排序
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListVersions:
|
||||
"""list_versions 按数字排序,v10 排在 v2 后。"""
|
||||
|
||||
def test_list_versions_numeric_sort(self, tmp_path: "Path") -> None:
|
||||
"""v10 必须排在 v2 后面(非字典序)。"""
|
||||
store = tmp_path / "store"
|
||||
resource = store / "skills"
|
||||
resource.mkdir(parents=True)
|
||||
for v in ("v1", "v10", "v2", "v20", "v3"):
|
||||
(resource / v).mkdir()
|
||||
result = list_versions(store, "skills")
|
||||
assert result == ["v1", "v2", "v3", "v10", "v20"]
|
||||
|
||||
def test_list_versions_empty(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
assert list_versions(store, "skills") == []
|
||||
|
||||
def test_list_versions_ignores_non_version_dirs(self, tmp_path: "Path") -> None:
|
||||
"""非 v\\d+ 格式的目录被忽略。"""
|
||||
store = tmp_path / "store"
|
||||
resource = store / "skills"
|
||||
resource.mkdir(parents=True)
|
||||
(resource / "v1").mkdir()
|
||||
(resource / "backup").mkdir()
|
||||
(resource / ".hidden").mkdir()
|
||||
assert list_versions(store, "skills") == ["v1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# next_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNextVersion:
|
||||
"""next_version 返回下一个可用版本号。"""
|
||||
|
||||
def test_next_version_empty(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
assert next_version(store, "skills") == "v1"
|
||||
|
||||
def test_next_version_after_existing(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
resource = store / "skills"
|
||||
resource.mkdir(parents=True)
|
||||
(resource / "v1").mkdir()
|
||||
(resource / "v2").mkdir()
|
||||
assert next_version(store, "skills") == "v3"
|
||||
|
||||
def test_next_version_with_gap(self, tmp_path: "Path") -> None:
|
||||
"""v1 和 v10 之间有 gap,next 应为 v11。"""
|
||||
store = tmp_path / "store"
|
||||
resource = store / "skills"
|
||||
resource.mkdir(parents=True)
|
||||
(resource / "v1").mkdir()
|
||||
(resource / "v10").mkdir()
|
||||
assert next_version(store, "skills") == "v11"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# advance_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdvanceVersion:
|
||||
"""advance_version copytree + _write_meta。"""
|
||||
|
||||
def test_advance_version(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
resource = store / "skills"
|
||||
resource.mkdir(parents=True)
|
||||
(resource / "v1").mkdir()
|
||||
|
||||
source = tmp_path / "new_skills"
|
||||
source.mkdir()
|
||||
(source / "skill_a.md").write_text("内容A")
|
||||
|
||||
version = advance_version(
|
||||
store,
|
||||
"skills",
|
||||
source,
|
||||
{"source": "evolution", "description": "进化产出"},
|
||||
)
|
||||
assert version == "v2"
|
||||
assert (resource / "v2" / "skill_a.md").read_text() == "内容A"
|
||||
|
||||
meta = json.loads((resource / "v2" / "meta.json").read_text())
|
||||
assert meta["version"] == "v2"
|
||||
assert meta["source"] == "evolution"
|
||||
assert meta["description"] == "进化产出"
|
||||
assert "created_at" in meta
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# init_store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitStore:
|
||||
"""init_store 初始化 Store 目录结构。"""
|
||||
|
||||
def test_init_store(self, tmp_path: "Path") -> None:
|
||||
videos = tmp_path / "videos_src"
|
||||
videos.mkdir()
|
||||
(videos / "v001").mkdir()
|
||||
(videos / "v001" / "tree.json").write_text("{}")
|
||||
|
||||
skills = tmp_path / "skills_src"
|
||||
skills.mkdir()
|
||||
(skills / "search.md").write_text("skill")
|
||||
|
||||
prompts = tmp_path / "prompts_src"
|
||||
prompts.mkdir()
|
||||
(prompts / "system.md").write_text("prompt")
|
||||
|
||||
store = tmp_path / "store"
|
||||
init_store(store, videos, skills, prompts)
|
||||
|
||||
assert (store / "videos" / "v001" / "tree.json").exists()
|
||||
assert (store / "questions" / "benchmarks").is_dir()
|
||||
assert (store / "questions" / "generated").is_dir()
|
||||
assert (store / "skills" / "v1" / "search.md").read_text() == "skill"
|
||||
assert (store / "prompts" / "v1" / "system.md").read_text() == "prompt"
|
||||
|
||||
skills_meta = json.loads(
|
||||
(store / "skills" / "v1" / "meta.json").read_text()
|
||||
)
|
||||
assert skills_meta["version"] == "v1"
|
||||
assert skills_meta["source"] == "manual"
|
||||
|
||||
def test_init_store_exists_raises(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
store.mkdir()
|
||||
with pytest.raises(FileExistsError, match="Store 已存在"):
|
||||
init_store(store, tmp_path, tmp_path, tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed 相关
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_seed_fixtures(tmp_path):
|
||||
"""创建 seed 测试所需的公共 fixture。"""
|
||||
store = tmp_path / "store"
|
||||
store.mkdir(parents=True)
|
||||
|
||||
skills_dir = tmp_path / "sk"
|
||||
skills_dir.mkdir()
|
||||
(skills_dir / "search.md").write_text("skill")
|
||||
|
||||
prompts_dir = tmp_path / "pr"
|
||||
prompts_dir.mkdir()
|
||||
(prompts_dir / "system.md").write_text("prompt")
|
||||
|
||||
baseline_db = tmp_path / "base.db"
|
||||
conn = sqlite3.connect(baseline_db)
|
||||
conn.execute(
|
||||
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
|
||||
)
|
||||
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
|
||||
conn.execute(
|
||||
"CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)"
|
||||
)
|
||||
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return store, skills_dir, prompts_dir, baseline_db
|
||||
|
||||
|
||||
class TestInitSeed:
|
||||
"""init_seed 创建种子目录。"""
|
||||
|
||||
def test_init_seed(self, tmp_path: "Path") -> None:
|
||||
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
|
||||
seed_dir = init_seed(
|
||||
store,
|
||||
"initial",
|
||||
skills_dir,
|
||||
prompts_dir,
|
||||
baseline_db,
|
||||
"r1",
|
||||
None,
|
||||
"初始种子",
|
||||
)
|
||||
assert seed_dir == store / "seeds" / "initial"
|
||||
assert (seed_dir / "skills" / "search.md").exists()
|
||||
assert (seed_dir / "prompts" / "system.md").exists()
|
||||
assert (seed_dir / "baseline.db").exists()
|
||||
|
||||
meta = json.loads((seed_dir / "seed.json").read_text())
|
||||
assert meta["baseline_run_id"] == "r1"
|
||||
assert meta["parent"] is None
|
||||
assert meta["description"] == "初始种子"
|
||||
assert "created_at" in meta
|
||||
|
||||
def test_init_seed_exists_raises(self, tmp_path: "Path") -> None:
|
||||
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
|
||||
init_seed(store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "first")
|
||||
with pytest.raises(FileExistsError, match="种子已存在"):
|
||||
init_seed(
|
||||
store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "second"
|
||||
)
|
||||
|
||||
|
||||
class TestListSeeds:
|
||||
"""list_seeds 列出所有种子。"""
|
||||
|
||||
def test_list_seeds(self, tmp_path: "Path") -> None:
|
||||
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
|
||||
init_seed(store, "beta", skills_dir, prompts_dir, baseline_db, "r1", None, "b")
|
||||
init_seed(store, "alpha", skills_dir, prompts_dir, baseline_db, "r1", None, "a")
|
||||
assert list_seeds(store) == ["alpha", "beta"]
|
||||
|
||||
def test_list_seeds_empty(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
assert list_seeds(store) == []
|
||||
|
||||
|
||||
class TestReadSeed:
|
||||
"""read_seed 读取 seed.json。"""
|
||||
|
||||
def test_read_seed(self, tmp_path: "Path") -> None:
|
||||
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
|
||||
init_seed(store, "s1", skills_dir, prompts_dir, baseline_db, "r1", None, "desc")
|
||||
meta = read_seed(store, "s1")
|
||||
assert meta["baseline_run_id"] == "r1"
|
||||
assert meta["description"] == "desc"
|
||||
|
||||
def test_read_seed_not_found(self, tmp_path: "Path") -> None:
|
||||
store = tmp_path / "store"
|
||||
store.mkdir()
|
||||
with pytest.raises(FileNotFoundError, match="种子不存在"):
|
||||
read_seed(store, "no_such")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_run_db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractRunDb:
|
||||
"""extract_run_db 抽取指定 run 的行并保留 PK。"""
|
||||
|
||||
def _make_src_db(self, path):
|
||||
"""创建带 _runs + predictions 表的源 db。"""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
|
||||
)
|
||||
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
|
||||
conn.execute("INSERT INTO _runs VALUES ('r2', 'done')")
|
||||
conn.execute(
|
||||
"CREATE TABLE predictions "
|
||||
"(run_id TEXT, question_id TEXT, answer TEXT)"
|
||||
)
|
||||
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
|
||||
conn.execute("INSERT INTO predictions VALUES ('r1', 'q2', 'B')")
|
||||
conn.execute("INSERT INTO predictions VALUES ('r2', 'q1', 'C')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def test_extract_run_db_preserves_pk(self, tmp_path: "Path") -> None:
|
||||
"""原始 CREATE 保留主键约束。"""
|
||||
src = tmp_path / "src.db"
|
||||
dst = tmp_path / "dst.db"
|
||||
self._make_src_db(src)
|
||||
extract_run_db(src, dst, "r1")
|
||||
|
||||
conn = sqlite3.connect(dst)
|
||||
# 验证 _runs 表有 PK
|
||||
create_sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='_runs'"
|
||||
).fetchone()[0]
|
||||
assert "PRIMARY KEY" in create_sql
|
||||
|
||||
# 验证只有 r1 的行
|
||||
runs = conn.execute("SELECT * FROM _runs").fetchall()
|
||||
assert len(runs) == 1
|
||||
assert runs[0][0] == "r1"
|
||||
|
||||
preds = conn.execute("SELECT * FROM predictions").fetchall()
|
||||
assert len(preds) == 2
|
||||
conn.close()
|
||||
|
||||
def test_extract_run_db_missing_table(self, tmp_path: "Path") -> None:
|
||||
"""源 db 无目标表时报错。"""
|
||||
src = tmp_path / "src.db"
|
||||
dst = tmp_path / "dst.db"
|
||||
conn = sqlite3.connect(src)
|
||||
conn.execute("CREATE TABLE other (id TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with pytest.raises(RuntimeError, match="源 db 无表"):
|
||||
extract_run_db(src, dst, "r1")
|
||||
|
||||
def test_extract_run_db_no_rows(self, tmp_path: "Path") -> None:
|
||||
"""目标 run_id 不存在时报错。"""
|
||||
src = tmp_path / "src.db"
|
||||
dst = tmp_path / "dst.db"
|
||||
self._make_src_db(src)
|
||||
with pytest.raises(RuntimeError, match="无 run_id="):
|
||||
extract_run_db(src, dst, "nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# promote_to_seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_promote_fixtures(tmp_path):
|
||||
"""创建 promote_to_seed 测试所需的 workspace + store。"""
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
store = tmp_path / "store"
|
||||
store.mkdir()
|
||||
|
||||
# workspace 内的 skills/prompts 版本目录
|
||||
(ws / "skills" / "v2").mkdir(parents=True)
|
||||
(ws / "skills" / "v2" / "skill.md").write_text("evolved")
|
||||
(ws / "prompts" / "v2").mkdir(parents=True)
|
||||
(ws / "prompts" / "v2" / "system.md").write_text("prompt v2")
|
||||
|
||||
# workspace harness.db
|
||||
db_path = ws / "harness.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE _runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
skills_version TEXT,
|
||||
prompts_version TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE predictions (
|
||||
run_id TEXT, question_id TEXT, answer TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("INSERT INTO predictions VALUES ('eval_001', 'q1', 'A')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return ws, store
|
||||
|
||||
|
||||
class TestPromoteToSeed:
|
||||
"""promote_to_seed 固化 workspace 版本为种子。"""
|
||||
|
||||
def test_promote_to_seed_success(self, tmp_path: "Path") -> None:
|
||||
ws, store = _make_promote_fixtures(tmp_path)
|
||||
seed_dir = promote_to_seed(ws, store, "v2", "eval_001", "evolved-seed", "good")
|
||||
assert seed_dir == store / "seeds" / "evolved-seed"
|
||||
assert (seed_dir / "skills" / "skill.md").exists()
|
||||
assert (seed_dir / "prompts" / "system.md").exists()
|
||||
assert (seed_dir / "baseline.db").exists()
|
||||
meta = json.loads((seed_dir / "seed.json").read_text())
|
||||
assert meta["baseline_run_id"] == "eval_001"
|
||||
assert meta["parent"] == "ws:v2"
|
||||
|
||||
def test_promote_to_seed_version_mismatch(self, tmp_path: "Path") -> None:
|
||||
"""eval run 的 skills_version 与 --version 不符时报错。"""
|
||||
ws, store = _make_promote_fixtures(tmp_path)
|
||||
with pytest.raises(ValueError, match="版本.*不符"):
|
||||
promote_to_seed(ws, store, "v3", "eval_001", "bad", "mismatch")
|
||||
|
||||
def test_promote_to_seed_null_version(self, tmp_path: "Path") -> None:
|
||||
"""eval run 的版本为 NULL 时报错。"""
|
||||
ws = tmp_path / "ws2"
|
||||
ws.mkdir()
|
||||
store = tmp_path / "store2"
|
||||
store.mkdir()
|
||||
db_path = ws / "harness.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE _runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
skills_version TEXT,
|
||||
prompts_version TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("INSERT INTO _runs VALUES ('eval_null', NULL, NULL)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with pytest.raises(ValueError, match="NULL"):
|
||||
promote_to_seed(ws, store, "v1", "eval_null", "bad", "null ver")
|
||||
|
||||
def test_promote_to_seed_run_not_found(self, tmp_path: "Path") -> None:
|
||||
"""eval run 不存在时报错。"""
|
||||
ws, store = _make_promote_fixtures(tmp_path)
|
||||
with pytest.raises(ValueError, match="eval run 不存在"):
|
||||
promote_to_seed(ws, store, "v1", "nonexistent", "bad", "no run")
|
||||
|
||||
def test_promote_cleanup_tmp_db(self, tmp_path: "Path") -> None:
|
||||
"""finally 清理临时 db 文件。"""
|
||||
ws, store = _make_promote_fixtures(tmp_path)
|
||||
promote_to_seed(ws, store, "v2", "eval_001", "clean-test", "cleanup")
|
||||
# 临时 db 应已清理
|
||||
assert not (ws / "_promote_tmp.db").exists()
|
||||
|
||||
def test_promote_cleanup_tmp_db_on_error(self, tmp_path: "Path") -> None:
|
||||
"""即使 init_seed 失败(同名种子),临时 db 也应被清理。"""
|
||||
ws, store = _make_promote_fixtures(tmp_path)
|
||||
promote_to_seed(ws, store, "v2", "eval_001", "first", "first time")
|
||||
with pytest.raises(FileExistsError):
|
||||
promote_to_seed(ws, store, "v2", "eval_001", "first", "second time")
|
||||
assert not (ws / "_promote_tmp.db").exists()
|
||||
Reference in New Issue
Block a user