"""三池切分单元测试。

验证:
- 三池互斥（question_id 无重叠）
- test 池自然分布（correct_ratio=None）
- save/load 往返一致
- 旧格式拒绝（无 test 键 → ValueError）
- build_or_load_pools 冻结复用（pools.json 存在时不重切）
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest

from app.harness.pools import (
    GlobalPoolStrategy,
    PerCategoryPoolStrategy,
    build_pools,
    load_pools,
    save_pools,
)
from core.types import GeneratedQuestion, PoolConfig

if TYPE_CHECKING:
    from pathlib import Path


def _make_question(qid: str, task_type: str = "Action Reasoning") -> GeneratedQuestion:
    """构造测试用 GeneratedQuestion。

    参数:
        qid: 题目 ID。
        task_type: 题型。

    返回:
        GeneratedQuestion 实例。
    """
    return GeneratedQuestion(
        question_id=qid,
        video_id="video_001",
        task_type=task_type,
        question=f"Question {qid}?",
        options=("A. opt1", "B. opt2", "C. opt3", "D. opt4"),
        answer="A",
        source_nodes=("node_1",),
        difficulty="medium",
    )


def _make_question_set(
    n: int,
    task_types: list[str] | None = None,
) -> list[GeneratedQuestion]:
    """构造 n 道题，交替分配题型。

    参数:
        n: 题目数量。
        task_types: 可选题型列表，轮转分配；None 默认 2 类。

    返回:
        题目列表。
    """
    types = task_types or ["Action Reasoning", "Information Synopsis"]
    return [_make_question(f"q_{i:04d}", types[i % len(types)]) for i in range(n)]


def _make_correctness(
    questions: list[GeneratedQuestion],
    correct_ratio: float = 0.5,
) -> dict[str, bool]:
    """构造 correctness 字典，前 correct_ratio 比例标对。

    参数:
        questions: 题目列表。
        correct_ratio: 对题占比。

    返回:
        question_id -> bool。
    """
    n_correct = round(len(questions) * correct_ratio)
    return {q.question_id: (i < n_correct) for i, q in enumerate(questions)}


class TestBuildPoolsMutualExclusion:
    """三池 question_id 互斥验证。"""

    def test_build_pools_mutual_exclusion(self) -> None:
        """三池切分后，任意两池不共享 question_id。"""
        questions = _make_question_set(200)
        correctness = _make_correctness(questions, 0.5)

        pools = build_pools(
            questions,
            correctness,
            diag_cfg={
                "size": 30,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            val_cfg={
                "size": 30,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            test_cfg={"size": 30},
            baseline_run_id="run_baseline",
        )

        diag_ids = {q.question_id for q in pools.diagnosis}
        val_ids = {q.question_id for q in pools.validation}
        test_ids = {q.question_id for q in pools.test}

        assert diag_ids & val_ids == set(), "诊断池与验证池有重叠"
        assert diag_ids & test_ids == set(), "诊断池与测试池有重叠"
        assert val_ids & test_ids == set(), "验证池与测试池有重叠"

        assert len(diag_ids) == 30
        assert len(val_ids) == 30
        assert len(test_ids) == 30


class TestBuildPoolsTestNaturalDistribution:
    """test 池使用自然分布（correct_ratio=None）。"""

    def test_build_pools_test_natural_distribution(self) -> None:
        """test 池不强制对错比例，保留候选池的自然分布。

        构造 correctness 为 50% 对/50% 错，diag/val 用 correct_ratio=0.3
        强制裁剪，test 池走自然分布（correct_ratio=None）。验证 test 池
        不受 correct_ratio 约束。
        """
        questions = _make_question_set(300)
        correctness = _make_correctness(questions, 0.5)

        pools = build_pools(
            questions,
            correctness,
            diag_cfg={
                "size": 20,
                "correct_ratio": 0.3,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            val_cfg={
                "size": 20,
                "correct_ratio": 0.3,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            test_cfg={"size": 20},
            baseline_run_id="run_baseline",
        )

        # diag/val 被 correct_ratio=0.3 裁剪：round(20*0.3) = 6 对, 14 错
        diag_correct = sum(1 for q in pools.diagnosis if correctness[q.question_id])
        val_correct = sum(1 for q in pools.validation if correctness[q.question_id])
        assert diag_correct == 6, "诊断池应强制 30% 对题"
        assert val_correct == 6, "验证池应强制 30% 对题"

        # test 池自然分布：不受 correct_ratio 约束
        assert len(pools.test) == 20


class TestSaveLoadPoolsRoundtrip:
    """save/load 往返一致验证。"""

    def test_save_load_pools_roundtrip(self, tmp_path: Path) -> None:
        """save_pools → load_pools 后全字段一致。"""
        questions = _make_question_set(100)
        correctness = _make_correctness(questions, 0.5)

        original = build_pools(
            questions,
            correctness,
            diag_cfg={
                "size": 15,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            val_cfg={
                "size": 15,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            test_cfg={"size": 15},
            baseline_run_id="run_001",
        )

        pools_path = tmp_path / "pools.json"
        save_pools(original, pools_path)
        restored = load_pools(pools_path)

        # 标量字段
        assert restored.baseline_run_id == original.baseline_run_id
        assert restored.baseline_val_accuracy == pytest.approx(original.baseline_val_accuracy)
        assert restored.correctness == original.correctness

        # 三池逐题比对
        for pool_name in ("diagnosis", "validation", "test"):
            orig_list = getattr(original, pool_name)
            rest_list = getattr(restored, pool_name)
            assert len(rest_list) == len(orig_list), f"{pool_name} 长度不一致"
            for o, r in zip(orig_list, rest_list, strict=False):
                assert o.question_id == r.question_id
                assert o.video_id == r.video_id
                assert o.task_type == r.task_type
                assert o.question == r.question
                assert o.options == r.options
                assert o.answer == r.answer
                assert o.source_nodes == r.source_nodes
                assert o.difficulty == r.difficulty


class TestLoadPoolsOldFormatReject:
    """旧格式 pools.json（无 test 键）→ ValueError。"""

    def test_load_pools_old_format_reject(self, tmp_path: Path) -> None:
        """缺少 test 键的 pools.json 必须抛出 ValueError。"""
        old_format = {
            "baseline_run_id": "run_old",
            "baseline_val_accuracy": 0.5,
            "correctness": {},
            "diagnosis": [],
            "validation": [],
        }
        pools_path = tmp_path / "pools.json"
        pools_path.write_text(json.dumps(old_format), encoding="utf-8")

        with pytest.raises(ValueError, match="旧格式"):
            load_pools(pools_path)


class TestBuildOrLoadPoolsFrozen:
    """build_or_load_pools 冻结复用：pools.json 存在时原样加载不重切。"""

    def test_build_or_load_pools_frozen(self, tmp_path: Path) -> None:
        """pools.json 已存在时，build_or_load_pools 返回冻结内容。"""
        questions = _make_question_set(60)
        correctness = _make_correctness(questions, 0.5)

        frozen = build_pools(
            questions,
            correctness,
            diag_cfg={
                "size": 10,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            val_cfg={
                "size": 10,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            test_cfg={"size": 10},
            baseline_run_id="run_frozen",
        )

        pools_path = tmp_path / "pools.json"
        save_pools(frozen, pools_path)

        # build_or_load_pools 中 pools.json 存在 → 直接 load_pools
        # 此处直接测试 load_pools 行为等价
        loaded = load_pools(pools_path)

        assert loaded.baseline_run_id == frozen.baseline_run_id
        assert loaded.baseline_val_accuracy == pytest.approx(frozen.baseline_val_accuracy)
        assert len(loaded.test) == len(frozen.test)
        assert len(loaded.validation) == len(frozen.validation)
        assert len(loaded.diagnosis) == len(frozen.diagnosis)

        # question_id 完全一致
        for pool_name in ("diagnosis", "validation", "test"):
            orig_ids = [q.question_id for q in getattr(frozen, pool_name)]
            load_ids = [q.question_id for q in getattr(loaded, pool_name)]
            assert orig_ids == load_ids, f"{pool_name} 冻结后 ID 顺序不一致"


class TestGlobalPoolStrategy:
    """GlobalPoolStrategy 封装现有全局三分逻辑。"""

    def test_global_strategy_builds_three_pools(self) -> None:
        """GlobalPoolStrategy.build 产出三个互斥池。"""
        questions = _make_question_set(200)
        correctness = _make_correctness(questions, 0.5)
        config = PoolConfig(
            task_types=None,
            seed=42,
            baseline_run_id="run_baseline",
            diag_size=30,
            diag_correct_ratio=0.5,
            val_size=30,
            val_correct_ratio=0.5,
            test_size=30,
            eval_min_per_class=1,
            train_ratio=0.667,
            test_questions_dir=None,
        )
        strategy = GlobalPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        diag_ids = {q.question_id for q in pools.diagnosis}
        val_ids = {q.question_id for q in pools.validation}
        test_ids = {q.question_id for q in pools.test}
        assert diag_ids & val_ids == set()
        assert diag_ids & test_ids == set()
        assert val_ids & test_ids == set()
        assert len(pools.diagnosis) == 30
        assert len(pools.validation) == 30
        assert len(pools.test) == 30

    def test_global_strategy_build_incremental_raises(self) -> None:
        """GlobalPoolStrategy 不支持增量。"""
        strategy = GlobalPoolStrategy()
        config = PoolConfig(
            task_types=None,
            seed=0,
            baseline_run_id="r",
            diag_size=10,
            diag_correct_ratio=0.5,
            val_size=10,
            val_correct_ratio=0.5,
            test_size=10,
            eval_min_per_class=1,
            train_ratio=0.667,
            test_questions_dir=None,
        )
        with pytest.raises(NotImplementedError):
            strategy.build_incremental(["Action Reasoning"], [], {}, config)


def _make_per_category_questions():
    """构造 12 类各 30 题，共 360 题。"""
    task_types = [
        "Action Recognition",
        "Action Reasoning",
        "Attribute Perception",
        "Counting Problem",
        "Information Synopsis",
        "Object Recognition",
        "Object Reasoning",
        "OCR Problems",
        "Spatial Perception",
        "Spatial Reasoning",
        "Temporal Perception",
        "Temporal Reasoning",
    ]
    questions = []
    for tt in task_types:
        for i in range(30):
            questions.append(_make_question(f"{tt}_{i:03d}", tt))
    return questions


class TestPerCategoryPoolStrategy:
    """PerCategoryPoolStrategy per-category 2:1 分层划分。"""

    def test_per_category_split_20_10(self):
        """每类 30 题按 correctness 2:1 分层 -> 20 train + 10 val。"""
        questions = _make_per_category_questions()
        correctness = {}
        for q in questions:
            idx = int(q.question_id.split("_")[-1])
            correctness[q.question_id] = idx < 18

        config = PoolConfig(
            task_types=None,
            seed=42,
            baseline_run_id="baseline_v2",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        assert len(pools.diagnosis) == 240
        assert len(pools.validation) == 120

        from collections import Counter

        diag_counts = Counter(q.task_type for q in pools.diagnosis)
        val_counts = Counter(q.task_type for q in pools.validation)
        for tt in diag_counts:
            assert diag_counts[tt] == 20
            assert val_counts[tt] == 10

        diag_ids = {q.question_id for q in pools.diagnosis}
        val_ids = {q.question_id for q in pools.validation}
        assert diag_ids & val_ids == set()

    def test_per_category_correctness_ratio_aligned(self):
        """train 和 val 的 correctness 比例应对齐。"""
        questions = _make_per_category_questions()
        correctness = {}
        for q in questions:
            idx = int(q.question_id.split("_")[-1])
            correctness[q.question_id] = idx < 18

        config = PoolConfig(
            task_types=("Action Reasoning",),
            seed=42,
            baseline_run_id="baseline_v2",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        assert len(pools.diagnosis) == 20
        assert len(pools.validation) == 10
        diag_correct = sum(1 for q in pools.diagnosis if correctness[q.question_id])
        val_correct = sum(1 for q in pools.validation if correctness[q.question_id])
        assert diag_correct == 12
        assert val_correct == 6

    def test_per_category_all_correct_degrades(self):
        """某类全部 correct -> 退化为非分层 random 20/10。"""
        questions = [_make_question(f"q_{i:03d}", "Object Recognition") for i in range(30)]
        correctness = {q.question_id: True for q in questions}
        config = PoolConfig(
            task_types=None,
            seed=42,
            baseline_run_id="r",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        assert len(pools.diagnosis) == 20
        assert len(pools.validation) == 10

    def test_per_category_missing_correctness_fails(self):
        """correctness 不完整时 fail-fast。"""
        questions = [_make_question(f"q_{i:03d}", "Object Recognition") for i in range(30)]
        correctness = {q.question_id: True for q in questions[:25]}
        config = PoolConfig(
            task_types=None,
            seed=42,
            baseline_run_id="r",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        with pytest.raises(ValueError, match="correctness 缺失"):
            strategy.build(questions, correctness, config)

    def test_per_category_task_types_filter(self):
        """task_types 过滤只处理指定类别。"""
        questions = _make_per_category_questions()
        correctness = {q.question_id: True for q in questions}
        config = PoolConfig(
            task_types=("Action Reasoning", "Information Synopsis"),
            seed=42,
            baseline_run_id="r",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        assert len(pools.diagnosis) == 40
        assert len(pools.validation) == 20
        types_in_diag = {q.task_type for q in pools.diagnosis}
        assert types_in_diag == {"Action Reasoning", "Information Synopsis"}

    def test_per_category_test_pool_filtered_by_task_types(self, tmp_path: Path) -> None:
        """test_questions_dir 加载的 test 池应按 task_types 过滤。"""

        test_dir = tmp_path / "test_questions"
        test_dir.mkdir()

        task_types_all = [
            "Action Recognition",
            "Action Reasoning",
            "Temporal Perception",
        ]
        for tt in task_types_all:
            items = []
            for i in range(10):
                items.append(
                    {
                        "question_id": f"{tt}_{i:03d}",
                        "video_id": "v1",
                        "task_type": tt,
                        "question": f"Q {tt} {i}?",
                        "options": ["A. a", "B. b", "C. c", "D. d"],
                        "answer": "A",
                    }
                )
            slug = tt.lower().replace(" ", "_")
            (test_dir / f"{slug}.json").write_text(json.dumps(items, ensure_ascii=False))

        # train/val 用的题目（与 test 独立）
        questions = _make_per_category_questions()
        correctness = {q.question_id: True for q in questions}

        config = PoolConfig(
            task_types=("Action Recognition",),
            seed=42,
            baseline_run_id="baseline_v2",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=test_dir,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)

        assert len(pools.test) == 10, (
            f"test 池应仅含 Action Recognition 的 10 题，实际 {len(pools.test)}"
        )
        test_types = {q.task_type for q in pools.test}
        assert test_types == {"Action Recognition"}, (
            f"test 池应仅含 Action Recognition，实际含 {test_types}"
        )

    def test_per_category_test_pool_no_filter_when_task_types_none(self, tmp_path: Path) -> None:
        """task_types=None 时，test 池不过滤，加载全部题目。"""

        test_dir = tmp_path / "test_questions"
        test_dir.mkdir()

        task_types_all = [
            "Action Recognition",
            "Action Reasoning",
            "Temporal Perception",
        ]
        total_expected = 0
        for tt in task_types_all:
            items = []
            for i in range(10):
                items.append(
                    {
                        "question_id": f"{tt}_{i:03d}",
                        "video_id": "v1",
                        "task_type": tt,
                        "question": f"Q {tt} {i}?",
                        "options": ["A. a", "B. b", "C. c", "D. d"],
                        "answer": "A",
                    }
                )
            slug = tt.lower().replace(" ", "_")
            (test_dir / f"{slug}.json").write_text(json.dumps(items, ensure_ascii=False))
            total_expected += len(items)

        questions = _make_per_category_questions()
        correctness = {q.question_id: True for q in questions}

        config = PoolConfig(
            task_types=None,
            seed=42,
            baseline_run_id="baseline_v2",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=test_dir,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)

        assert len(pools.test) == total_expected, (
            f"task_types=None 时应加载全部 {total_expected} 题，实际 {len(pools.test)}"
        )


class TestPerCategorySaveLoad:
    """per_category 格式的 pools.json 冻结/加载。"""

    def test_save_load_per_category_roundtrip(self, tmp_path: Path) -> None:
        """per_category 模式 save -> load 往返一致。"""
        questions = _make_per_category_questions()
        correctness = {q.question_id: True for q in questions}
        config = PoolConfig(
            task_types=("Action Reasoning",),
            seed=42,
            baseline_run_id="baseline_v2",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        pools_path = tmp_path / "pools.json"
        save_pools(pools, pools_path, split_mode="per_category", config=config)
        loaded = load_pools(pools_path)
        assert loaded.baseline_run_id == pools.baseline_run_id
        assert len(loaded.diagnosis) == len(pools.diagnosis)
        assert len(loaded.validation) == len(pools.validation)

        # 验证 per_category 格式内容
        data = json.loads(pools_path.read_text())
        assert data["split_mode"] == "per_category"
        assert "categories" in data
        assert data["seed"] == 42
        assert data["train_ratio"] == pytest.approx(20 / 30)
        assert data["test_source"] is None

        # categories 内容校验
        cats = data["categories"]
        assert "Action Reasoning" in cats
        assert len(cats["Action Reasoning"]["train"]) == 20
        assert len(cats["Action Reasoning"]["val"]) == 10

    def test_save_per_category_without_config_raises(self, tmp_path: Path) -> None:
        """per_category 模式未提供 config 时报 ValueError。"""
        from app.harness.pools import Pools

        pools = Pools(
            diagnosis=[],
            validation=[],
            test=[],
            baseline_run_id="r",
            baseline_val_accuracy=0.0,
        )
        with pytest.raises(ValueError, match="per_category 模式下.*必须提供 config"):
            save_pools(pools, tmp_path / "pools.json", split_mode="per_category")

    def test_save_global_mode_has_split_mode_field(self, tmp_path: Path) -> None:
        """global 模式 save 也写入 split_mode 字段。"""
        questions = _make_question_set(60)
        correctness = _make_correctness(questions, 0.5)
        original = build_pools(
            questions,
            correctness,
            diag_cfg={
                "size": 10,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            val_cfg={
                "size": 10,
                "correct_ratio": 0.5,
                "task_types": None,
                "seed": 42,
                "min_per_class": None,
            },
            test_cfg={"size": 10},
            baseline_run_id="run_001",
        )
        pools_path = tmp_path / "pools.json"
        save_pools(original, pools_path, split_mode="global")
        data = json.loads(pools_path.read_text())
        assert data["split_mode"] == "global"
        assert "categories" not in data

        # 仍能正常 load
        loaded = load_pools(pools_path)
        assert loaded.baseline_run_id == "run_001"
        assert len(loaded.diagnosis) == 10

    def test_load_legacy_format_without_split_mode(self, tmp_path: Path) -> None:
        """旧格式（无 split_mode 字段）仍可加载。"""
        legacy = {
            "baseline_run_id": "run_legacy",
            "baseline_val_accuracy": 0.75,
            "correctness": {"q1": True},
            "diagnosis": [
                {
                    "question_id": "q1",
                    "video_id": "v1",
                    "task_type": "AR",
                    "question": "Q?",
                    "options": ["A", "B", "C", "D"],
                    "answer": "A",
                    "source_nodes": [],
                    "difficulty": "medium",
                    "skill_target": None,
                    "difficulty_steps": None,
                }
            ],
            "validation": [],
            "test": [],
        }
        pools_path = tmp_path / "pools.json"
        pools_path.write_text(json.dumps(legacy), encoding="utf-8")
        loaded = load_pools(pools_path)
        assert loaded.baseline_run_id == "run_legacy"
        assert len(loaded.diagnosis) == 1

    def test_per_category_categories_multi_type(self, tmp_path: Path) -> None:
        """多类别 per_category save 后 categories 包含所有类别。"""
        questions = _make_per_category_questions()
        correctness = {q.question_id: True for q in questions}
        config = PoolConfig(
            task_types=("Action Reasoning", "Information Synopsis"),
            seed=0,
            baseline_run_id="b",
            diag_size=0,
            diag_correct_ratio=0.0,
            val_size=0,
            val_correct_ratio=0.0,
            test_size=0,
            eval_min_per_class=0,
            train_ratio=20 / 30,
            test_questions_dir=None,
        )
        strategy = PerCategoryPoolStrategy()
        pools = strategy.build(questions, correctness, config)
        pools_path = tmp_path / "pools.json"
        save_pools(pools, pools_path, split_mode="per_category", config=config)

        data = json.loads(pools_path.read_text())
        assert set(data["categories"].keys()) == {
            "Action Reasoning",
            "Information Synopsis",
        }
        for tt in data["categories"]:
            cat = data["categories"][tt]
            assert len(cat["train"]) == 20
            assert len(cat["val"]) == 10
            # train + val 的 qid 互斥
            assert set(cat["train"]) & set(cat["val"]) == set()


class TestRunHoldoutEvalConfig:
    """run_holdout_eval 字段校验。"""

    def test_default_true(self):
        """run_holdout_eval 默认值为 True。"""
        from pathlib import Path

        from app.harness.config import RunConfig

        config = RunConfig(
            workspace_dir=Path("/tmp/ws"),
            store_dir=Path("/tmp/store"),
            mode="train",
            concurrency=4,
            max_steps=10,
            skill_mode="auto",
            n_samples=0,
            questions="benchmarks/Video-MME",
            skills_version="v1",
            prompts_version="v1",
            epochs=1,
            diag_size=100,
            diag_correct_ratio=0.5,
            val_size=30,
            val_correct_ratio=0.5,
            edit_budget_start=5,
            edit_budget_end=2,
            batch_size=15,
            min_class_per_batch=2,
            eval_min_per_class=2,
            early_stop_patience=4,
            test_size=30,
            use_slow_momentum=True,
            gate_e_confirm=20.0,
            gate_e_provisional=3.0,
            gate_w_net_min=2,
            gate_delta_min=0.02,
            gate_lambda_dir=-0.642,
            gate_e_rollback=10.0,
            gate_block=8,
            gate_n_max=40,
            gate_p_low=0.05,
            gate_p_high=0.95,
            gate_probe_quota=0.2,
            gate_gamma_decay=0.9,
            gate_cooldown_steps=2,
            gate_guard_err=0.10,
            skill_update_mode="patch",
            appendix_consolidate_threshold=6,
            run_id="test_run",
        )
        assert config.run_holdout_eval is True

    def test_explicit_false(self):
        """run_holdout_eval 可设为 False。"""
        from pathlib import Path

        from app.harness.config import RunConfig

        config = RunConfig(
            workspace_dir=Path("/tmp/ws"),
            store_dir=Path("/tmp/store"),
            mode="train",
            concurrency=4,
            max_steps=10,
            skill_mode="auto",
            n_samples=0,
            questions="benchmarks/Video-MME",
            skills_version="v1",
            prompts_version="v1",
            epochs=1,
            diag_size=100,
            diag_correct_ratio=0.5,
            val_size=30,
            val_correct_ratio=0.5,
            edit_budget_start=5,
            edit_budget_end=2,
            batch_size=15,
            min_class_per_batch=2,
            eval_min_per_class=2,
            early_stop_patience=4,
            test_size=30,
            use_slow_momentum=True,
            gate_e_confirm=20.0,
            gate_e_provisional=3.0,
            gate_w_net_min=2,
            gate_delta_min=0.02,
            gate_lambda_dir=-0.642,
            gate_e_rollback=10.0,
            gate_block=8,
            gate_n_max=40,
            gate_p_low=0.05,
            gate_p_high=0.95,
            gate_probe_quota=0.2,
            gate_gamma_decay=0.9,
            gate_cooldown_steps=2,
            gate_guard_err=0.10,
            skill_update_mode="patch",
            appendix_consolidate_threshold=6,
            run_id="test_run",
            run_holdout_eval=False,
        )
        assert config.run_holdout_eval is False
