chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.) - Add claude hooks (pre-commit-guard, post-edit-quality) - Add research templates (experiment plan, research brief, etc.) - Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa) - Add TRM4 reference implementation as algorithm fidelity baseline - Add research-wiki content (plans, index, graph, query_pack) - Update .gitignore to exclude .graphify_version runtime state
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
配置管理模块单元测试
|
||||
====================
|
||||
覆盖: YAML 加载、.env 覆盖、CLI 覆盖、缺字段报错、优先级验证、embed_dim 一致性。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from video_tree_trm.config import Config, TreeConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FULL_YAML = {
|
||||
"tree": {
|
||||
"max_paragraphs_per_l2": 5,
|
||||
"l1_segment_duration": 600.0,
|
||||
"l2_clip_duration": 20.0,
|
||||
"l3_fps": 1.0,
|
||||
"l2_representative_frames": 3,
|
||||
"cache_dir": "cache/trees",
|
||||
},
|
||||
"embed": {
|
||||
"backend": "local",
|
||||
"model_name": "test-model",
|
||||
"embed_dim": 2560,
|
||||
"device": "cpu",
|
||||
"api_key": "",
|
||||
"api_url": "",
|
||||
},
|
||||
"llm": {
|
||||
"backend": "qwen",
|
||||
"api_key": "yaml-llm-key",
|
||||
"model": "qwen-plus",
|
||||
"api_url": "https://example.com/llm",
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.1,
|
||||
},
|
||||
"vlm": {
|
||||
"backend": "qwen",
|
||||
"api_key": "yaml-vlm-key",
|
||||
"model": "qwen-vl-plus",
|
||||
"api_url": "https://example.com/vlm",
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.1,
|
||||
},
|
||||
"retriever": {
|
||||
"embed_dim": 2560,
|
||||
"num_heads": 4,
|
||||
"L_layers": 2,
|
||||
"L_cycles": 4,
|
||||
"max_rounds": 5,
|
||||
"ffn_expansion": 2.0,
|
||||
"checkpoint": None,
|
||||
},
|
||||
"train": {
|
||||
"lr": 1e-4,
|
||||
"weight_decay": 1e-5,
|
||||
"batch_size": 1,
|
||||
"max_epochs_phase1": 30,
|
||||
"max_epochs_phase2": 20,
|
||||
"nav_loss_weight": 1.0,
|
||||
"act_loss_weight": 0.1,
|
||||
"act_lambda_step": 0.1,
|
||||
"act_gamma": 0.9,
|
||||
"eval_interval": 5,
|
||||
"save_dir": "checkpoints",
|
||||
"dataset": "longbench",
|
||||
"dataset_path": "data/longbench",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def yaml_path(tmp_path: Path) -> Path:
|
||||
"""创建完整配置的临时 YAML 文件。"""
|
||||
p = tmp_path / "config" / "default.yaml"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
yaml.dump(_FULL_YAML, f, allow_unicode=True)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env_path(tmp_path: Path) -> Path:
|
||||
"""创建临时 .env 文件。"""
|
||||
p = tmp_path / ".env"
|
||||
p.write_text(
|
||||
"LLM_API_KEY=env-llm-key\n"
|
||||
"LLM_MODEL=env-llm-model\n"
|
||||
"LLM_API_URL=https://env.example.com/llm\n"
|
||||
"VLM_API_KEY=env-vlm-key\n"
|
||||
"VLM_MODEL=env-vlm-model\n"
|
||||
"VLM_API_URL=https://env.example.com/vlm\n"
|
||||
"EMBED_BACKEND=remote\n"
|
||||
"EMBED_MODEL=env-embed-model\n"
|
||||
"EMBED_API_KEY=env-embed-key\n"
|
||||
"EMBED_API_URL=https://env.example.com/embed\n"
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: YAML 加载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestYAMLLoad:
|
||||
"""YAML 基础加载测试。"""
|
||||
|
||||
def test_load_full_yaml(self, yaml_path: Path) -> None:
|
||||
"""完整 YAML 应成功加载所有字段。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path), env_path=str(yaml_path.parent / ".env.nonexist")
|
||||
)
|
||||
assert isinstance(cfg.tree, TreeConfig)
|
||||
assert cfg.tree.max_paragraphs_per_l2 == 5
|
||||
assert cfg.tree.l1_segment_duration == 600.0
|
||||
assert cfg.embed.embed_dim == 2560
|
||||
assert cfg.retriever.checkpoint is None
|
||||
assert cfg.train.dataset == "longbench"
|
||||
|
||||
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""不存在的 YAML 应抛出 FileNotFoundError。"""
|
||||
with pytest.raises(FileNotFoundError, match="配置文件不存在"):
|
||||
Config.load(str(tmp_path / "nonexist.yaml"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 缺字段报错
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingField:
|
||||
"""缺少必需字段时应抛出 TypeError。"""
|
||||
|
||||
def test_missing_tree_field(self, tmp_path: Path) -> None:
|
||||
"""tree 节缺少字段应报 TypeError。"""
|
||||
bad_yaml = _FULL_YAML.copy()
|
||||
bad_yaml = {
|
||||
k: (v.copy() if isinstance(v, dict) else v) for k, v in _FULL_YAML.items()
|
||||
}
|
||||
del bad_yaml["tree"]["cache_dir"]
|
||||
|
||||
p = tmp_path / "bad.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
|
||||
def test_missing_section(self, tmp_path: Path) -> None:
|
||||
"""缺少整个配置节应报 TypeError。"""
|
||||
bad_yaml = {k: v for k, v in _FULL_YAML.items() if k != "train"}
|
||||
|
||||
p = tmp_path / "bad2.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: .env 覆盖
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvOverride:
|
||||
""".env 文件应覆盖 YAML 中的 api_key。"""
|
||||
|
||||
def test_env_overrides_api_keys(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""api_key/model/api_url 应优先使用 .env 中的值。"""
|
||||
cfg = Config.load(str(yaml_path), env_path=str(env_path))
|
||||
assert cfg.llm.api_key == "env-llm-key"
|
||||
assert cfg.llm.model == "env-llm-model"
|
||||
assert cfg.llm.api_url == "https://env.example.com/llm"
|
||||
assert cfg.vlm.api_key == "env-vlm-key"
|
||||
assert cfg.vlm.model == "env-vlm-model"
|
||||
assert cfg.vlm.api_url == "https://env.example.com/vlm"
|
||||
|
||||
def test_env_overrides_embed(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""embed 相关字段应优先使用 .env 中的值。"""
|
||||
cfg = Config.load(str(yaml_path), env_path=str(env_path))
|
||||
assert cfg.embed.backend == "remote"
|
||||
assert cfg.embed.model_name == "env-embed-model"
|
||||
assert cfg.embed.api_key == "env-embed-key"
|
||||
assert cfg.embed.api_url == "https://env.example.com/embed"
|
||||
|
||||
def test_yaml_fallback_when_no_env(self, yaml_path: Path) -> None:
|
||||
"""无 .env 时应使用 YAML 中的值。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path), env_path=str(yaml_path.parent / ".env.nonexist")
|
||||
)
|
||||
assert cfg.llm.api_key == "yaml-llm-key"
|
||||
assert cfg.vlm.api_key == "yaml-vlm-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: CLI 覆盖
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCLIOverride:
|
||||
"""CLI args 应覆盖 YAML 和 .env 的值。"""
|
||||
|
||||
def test_cli_overrides_yaml(self, yaml_path: Path) -> None:
|
||||
"""CLI 点路径覆盖应生效。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"retriever.num_heads": 8, "train.lr": 0.001},
|
||||
env_path=str(yaml_path.parent / ".env.nonexist"),
|
||||
)
|
||||
assert cfg.retriever.num_heads == 8
|
||||
assert cfg.train.lr == 0.001
|
||||
|
||||
def test_cli_overrides_env(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""CLI 应覆盖 .env 中的 api_key。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"llm.api_key": "cli-key"},
|
||||
env_path=str(env_path),
|
||||
)
|
||||
assert cfg.llm.api_key == "cli-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 优先级
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriority:
|
||||
"""三层优先级: CLI > .env > YAML。"""
|
||||
|
||||
def test_full_priority_chain(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""CLI > .env > YAML 的完整优先级链。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"llm.api_key": "cli-key"},
|
||||
env_path=str(env_path),
|
||||
)
|
||||
# CLI 覆盖 .env
|
||||
assert cfg.llm.api_key == "cli-key"
|
||||
# .env 覆盖 YAML(vlm 未被 CLI 覆盖)
|
||||
assert cfg.vlm.api_key == "env-vlm-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: embed_dim 一致性校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmbedDimConsistency:
|
||||
"""embed.embed_dim 与 retriever.embed_dim 必须一致。"""
|
||||
|
||||
def test_inconsistent_embed_dim(self, tmp_path: Path) -> None:
|
||||
"""embed_dim 不一致应抛出 ValueError。"""
|
||||
bad_yaml = {
|
||||
k: (v.copy() if isinstance(v, dict) else v) for k, v in _FULL_YAML.items()
|
||||
}
|
||||
bad_yaml["retriever"] = bad_yaml["retriever"].copy()
|
||||
bad_yaml["retriever"]["embed_dim"] = 512 # 与 embed.embed_dim=768 不一致
|
||||
|
||||
p = tmp_path / "bad_dim.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(ValueError, match="不一致"):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
Reference in New Issue
Block a user