"""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()