File size: 7,723 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""「数据不对 → 卡在人工审核」的行为锁。

盯住三件事,每一件坏掉都是**静默**的(不报错、流程照常走完):

  1. 判 FAIL/ERROR 的批次被推回 ``to_review``,且清掉 ``approved_at``
  2. WARN 只记录、不拦 —— 否则警告阈值一响,整批数据全卡住
  3. **自动批准路径主动让路** —— AI 标注 / 视频门禁各看各的报告,不看质检
     结论;不查那个粘性标记的话,它们会在质检推回 to_review 之后又把状态
     置成 reviewed,坏数据照样被放行

全部在临时存储根上跑(monkeypatch ``localstore`` 的路径常量),不碰真实数据。

    pytest tests/test_cleaning_gate.py
    python3 tests/test_cleaning_gate.py
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))  # noqa: E402

import pytest  # noqa: E402

from app import localstore  # noqa: E402
from app.processing.cleaning import store  # noqa: E402
from app.processing.cleaning import contract as C  # noqa: E402


@pytest.fixture
def sandbox(tmp_path, monkeypatch):
    """把 episode state 落到临时目录 —— 别碰 ~/.cache/egodata-data。"""
    states = tmp_path / "state" / "episode_states"
    states.mkdir(parents=True, exist_ok=True)
    monkeypatch.setattr(localstore, "STATE_ROOT", tmp_path / "state")
    monkeypatch.setattr(localstore, "EPISODE_STATES_DIR", states)
    localstore._episode_state_cache.clear()
    yield states
    localstore._episode_state_cache.clear()


def _report(status, *, ranges=0):
    """造一份最小报告 —— 只关心 ``episode.status``,那是拦截判据。"""
    return {
        "schema_version": 2,
        "episode_id": "E1",
        "computed_at": "2026-09-24T00:00:00+00:00",
        "passed": status == C.PASS,
        "streams": [],
        "ranges": [{}] * ranges,
        "episode": {
            "status": status,
            "status_label": C.SEVERITY_LABELS.get(status, status),
            "failed_streams": [], "warn_streams": [], "pending_streams": [],
            "summary": "test",
        },
        "findings": [],
    }


def _seed(states, status="to_review", approved_at=None):
    import json
    eid = "E1"
    (states / f"{eid}.json").write_text(json.dumps({
        "id": eid, "name": eid, "project": "P", "status": status,
        "approved_at": approved_at,
    }, ensure_ascii=False), encoding="utf-8")
    localstore._episode_state_cache.pop(eid, None)
    return eid


# ── 拦截 ────────────────────────────────────────────────────
@pytest.mark.parametrize("status", [C.FAIL, C.ERROR])
def test_failure_pushes_back_to_review(sandbox, status):
    eid = _seed(sandbox, status="reviewed", approved_at="2026-01-01T00:00:00Z")
    assert store.save_report(eid, _report(status)) is True

    state = localstore.read_episode_state(eid)
    assert state["status"] == "to_review"          # 自动批准被撤销
    assert state["approved_at"] is None            # 批准时间一起清掉
    assert store.is_approval_blocked(state) is True


@pytest.mark.parametrize("status", [C.WARN, C.PASS, C.PENDING])
def test_non_failure_does_not_block(sandbox, status):
    """WARN/PENDING 只是警告 —— 拦的话整批数据都会卡住。"""
    eid = _seed(sandbox, status="reviewed")
    store.save_report(eid, _report(status))

    state = localstore.read_episode_state(eid)
    assert state["status"] == "reviewed"           # 状态没被动过
    assert store.is_approval_blocked(state) is False


def test_does_not_resurrect_deleted_episode(sandbox):
    """质检不能把用户删掉/否决过的批次复活。"""
    for status in ("deleted", "rejected"):
        eid = _seed(sandbox, status=status)
        store.save_report(eid, _report(C.FAIL))
        assert localstore.read_episode_state(eid)["status"] == status


def test_report_still_written_when_not_blocking(sandbox):
    """不拦也得把报告写进去 —— 拦截不是写报告的前提。"""
    eid = _seed(sandbox)
    store.save_report(eid, _report(C.WARN, ranges=3))
    summary = store.load_summary(eid)
    assert summary["status"] == C.WARN and summary["ranges"] == 3


# ── 自动批准让路 ────────────────────────────────────────────
def _quality_gate(state):
    """转调 ai_annotation 的真实写入路径,避免测试复制一份逻辑。"""
    from app.ai_annotation import _set_ai_quality_state
    return _set_ai_quality_state


def test_ai_gate_refuses_to_auto_approve_when_blocked(sandbox):
    """★ 核心用例:质检判 FAIL 后,AI 标注门禁即使自己通过也不得自动批准。"""
    eid = _seed(sandbox, status="to_review")
    store.save_report(eid, _report(C.FAIL))

    _quality_gate(None)(eid, {"passed": True}, auto_approve=True)

    state = localstore.read_episode_state(eid)
    assert state["status"] == "to_review", "坏数据被 AI 门禁放行了"
    assert state["ai_quality_status"] == "passed"   # 它自己的结论照常记录


def test_ai_gate_still_auto_approves_when_not_blocked(sandbox):
    """别把拦截修成"自动批准彻底失效" —— 干净数据该自动过。"""
    eid = _seed(sandbox, status="to_review")
    store.save_report(eid, _report(C.PASS))

    _quality_gate(None)(eid, {"passed": True}, auto_approve=True)

    assert localstore.read_episode_state(eid)["status"] == "reviewed"


def test_video_gate_refuses_to_auto_approve_when_blocked(sandbox):
    """视频干净 ≠ 数据干净 —— 视频门禁同样要让路。"""
    from app.video_quality import _apply_video_quality_result

    eid = _seed(sandbox, status="to_review")
    store.save_report(eid, _report(C.FAIL))

    _apply_video_quality_result(eid, {"passed": True})

    state = localstore.read_episode_state(eid)
    assert state["status"] == "to_review", "坏数据被视频门禁放行了"
    assert state["video_quality_status"] == "passed"


# ── 标记的粘性 ──────────────────────────────────────────────
def test_block_clears_once_data_is_fixed(sandbox):
    """数据修好后重跑要能自动批准 —— 否则卡住就再也出不来了。

    注意 ``save_report`` **只拦不批**:它解封后不会自己把状态改回 reviewed,
    批准是 AI/视频门禁或人工的事。所以这里走一遍真实的"修好 → 重跑"顺序:
    worker 先置 to_review,质检复核(清除标记),随后门禁自动批准。
    """
    eid = _seed(sandbox, status="to_review")

    store.save_report(eid, _report(C.FAIL))
    assert store.is_approval_blocked(localstore.read_episode_state(eid)) is True

    # —— 重跑 ——
    localstore.set_episode_status(eid, "to_review")   # worker 完成回调做的
    store.save_report(eid, _report(C.PASS))           # 质检复核:数据已修好
    state = localstore.read_episode_state(eid)
    assert store.is_approval_blocked(state) is False

    _quality_gate(None)(eid, {"passed": True}, auto_approve=True)
    assert localstore.read_episode_state(eid)["status"] == "reviewed"


def test_save_report_never_approves(sandbox):
    """质检**只拦不批** —— 它不该越过门禁自己把批次标成通过。"""
    eid = _seed(sandbox, status="to_review")
    store.save_report(eid, _report(C.PASS))
    assert localstore.read_episode_state(eid)["status"] == "to_review"


if __name__ == "__main__":
    raise SystemExit(pytest.main([__file__, "-q"]))