File size: 8,802 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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
"""手套检查项 —— 每一项都必须**被证明能抓到失败**。

真实数据上 5 项全绿(6 集、左右手各 5-6 条流、零误报)。但"全绿"证明不了什么:
**检查项从来没触发过,和检查项根本跑不起来,报告长得一模一样。** 所以这里每条
都构造一份明确的坏数据,断言它被抓到;再造一份好数据,断言不误报。

阈值全部来自实测:
  * 阵列 16×16 = 256 维
  * 取值门限 500(0 到 500 之间没有值)
  * 设备更新 ~7.5Hz → 受压时静止**恰好 4 帧**(正常,不是卡死)
  * 整集最低有 11.4% 的帧有压力

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

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

from app.processing.cleaning.checks import (  # noqa: E402
    CheckContext, StreamEvidence, applicable_checks, get_check,
    PASS, WARN, FAIL,
)

SIZE = 256


# ── 构造 ────────────────────────────────────────────────────
def _frame(value: float = 0.0, size: int = SIZE) -> tuple:
    return tuple([value] * size)


def _context(series: dict, *, channel="tactile", key="left_glove",
             fps=30.0) -> CheckContext:
    stream = StreamEvidence(key=key, channel=channel, series=series)
    return CheckContext(batch_dir=Path("/nonexistent"), fps=fps, streams=(stream,))


def _run(slug: str, ctx: CheckContext, params: dict | None = None):
    ctx.params = params or {}
    findings = get_check(slug).run(ctx)
    assert findings, f"{slug} 一条结论都没产出 —— 报告会显示为『没问题』"
    return findings


def _only(slug, ctx, params=None):
    findings = _run(slug, ctx, params)
    statuses = {f.status for f in findings}
    assert len(statuses) == 1, f"期望单一结论,得到 {statuses}"
    return findings[0]


# ── 可达性:检查项必须真的会被选中 ───────────────────────────
def test_glove_checks_are_applicable():
    """★ 防"检查项静默不跑"—— 声明与闸门对不上时它们一条都不会执行。"""
    slugs = {c.slug for c in applicable_checks(
        channels={"tactile", "device_status", "time"},
        modalities={"glove_sensor"})}
    for expected in ("glove.array_shape", "glove.device_status",
                     "glove.frozen_array", "glove.gate_floor",
                     "glove.no_contact"):
        assert expected in slugs, f"{expected} 在手套数据上不可达"


def test_glove_checks_skip_other_devices():
    """UMI 夹爪上不该跑手套检查 —— 设备卡片闸门要生效。"""
    slugs = {c.slug for c in applicable_checks(
        channels={"tactile", "slam", "time"}, modalities={"gripper_device"})}
    assert not any(s.startswith("glove.") for s in slugs)


def test_device_status_check_needs_its_channel():
    """没有状态列(老批次)时不该跑 —— 跑起来只会报"没数据"。"""
    slugs = {c.slug for c in applicable_checks(
        channels={"tactile", "time"}, modalities={"glove_sensor"})}
    assert "glove.device_status" not in slugs
    assert "glove.frozen_array" in slugs


# ── 1. 连接状态 ─────────────────────────────────────────────
def test_status_constant_connected_passes():
    ctx = _context({"device_status": tuple(["connected"] * 100)}, channel="device_status")
    assert _only("glove.device_status", ctx).status is PASS


def test_status_change_mid_recording_fails():
    """★ 从"连着"变成别的 = 掉线,与状态词表无关,见到即拦。"""
    values = tuple(["connected"] * 50 + ["disconnected"] * 50)
    ctx = _context({"device_status": values}, channel="device_status")
    finding = _only("glove.device_status", ctx)
    assert finding.status is FAIL
    assert finding.metrics["first_change_frame"] == 50


def test_status_unknown_constant_only_warns():
    """词表只见过 connected;别的取值不拦批次(拦错了比不拦更糟)。"""
    ctx = _context({"device_status": tuple(["ok"] * 100)}, channel="device_status")
    assert _only("glove.device_status", ctx).status is WARN


def test_status_all_blank_warns():
    ctx = _context({"device_status": tuple([""] * 100)}, channel="device_status")
    assert _only("glove.device_status", ctx).status is WARN


# ── 2. 阵列完整性 ───────────────────────────────────────────
def test_shape_full_array_passes():
    ctx = _context({"tactile": tuple([_frame()] * 50)})
    assert _only("glove.array_shape", ctx).status is PASS


def test_shape_catches_truncation():
    """★ 正是那个真实踩过的 bug:256 维被静默截成 3 维。"""
    ctx = _context({"tactile": tuple([_frame(size=3)] * 500)})
    finding = _only("glove.array_shape", ctx)
    assert finding.status is FAIL
    assert finding.metrics["bad_ratio"] == 1.0
    assert finding.metrics["observed_sizes"] == [3]


def test_shape_single_null_frame_only_warns():
    """偶发一帧缺失不该拦住整批。"""
    values = [_frame(600.0)] * 999 + [()]
    ctx = _context({"tactile": tuple(values)})
    assert _only("glove.array_shape", ctx).status is WARN


# ── 3. 取值下限 ─────────────────────────────────────────────
def test_gate_floor_normal_values_pass():
    ctx = _context({"tactile": (_frame(0.0), _frame(508.09), _frame(1500.0))})
    assert _only("glove.gate_floor", ctx).status is PASS


def test_gate_floor_catches_encoding_change():
    """★ 固件改成直接输出原始 ADC 时,值会掉到门限以下 —— 而别的检查全绿。"""
    values = [_frame(v) for v in (5.0, 20.0, 137.0, 400.0)] + [_frame(900.0)]
    ctx = _context({"tactile": tuple(values)})
    finding = _only("glove.gate_floor", ctx)
    assert finding.status is FAIL
    assert finding.metrics["below_ratio"] == 0.8


def test_gate_floor_all_zero_is_not_its_business():
    """整集全零是 glove.no_contact 的事,不在这里重复报。"""
    findings = get_check("glove.gate_floor").run(_context({"tactile": (_frame(),)}))
    assert findings == []


# ── 4. 阵列卡死 ─────────────────────────────────────────────
def test_frozen_normal_device_rate_passes():
    """★ 设备本来就每 4 帧才更新一次 —— 静止 4 帧是**正常**,不是卡死。"""
    values = []
    for block in range(100):
        values.extend([_frame(600.0 + block)] * 4)
    ctx = _context({"tactile": tuple(values)})
    assert _only("glove.frozen_array", ctx).status is PASS


def test_frozen_detects_stall():
    values = [_frame(600.0)] * 500 + [_frame(900.0)] * 10
    ctx = _context({"tactile": tuple(values)})
    finding = _only("glove.frozen_array", ctx)
    assert finding.status is FAIL                      # 500 帧 ≥ fail_frames(300)
    assert finding.metrics["longest_run_frames"] == 500
    # 区间要能定位到哪儿卡了
    assert finding.ranges and finding.ranges[0].start_frame == 0


def test_frozen_ignores_all_zero_stretches():
    """★ 实测踩过的坑:全零段天然"完全相同",算进去会报出大片假区间。

    真实数据里一集有 88% 的帧全零,最长"静止"575 帧 —— 全部来自全零段。
    """
    values = [_frame(0.0)] * 800 + [_frame(600.0)] * 10
    ctx = _context({"tactile": tuple(values)})
    assert _only("glove.frozen_array", ctx).status is PASS


def test_frozen_warn_threshold_between_normal_and_stall():
    """40~300 帧是警告区(正常 4 帧,卡死 300+)。"""
    values = [_frame(600.0)] * 100 + [_frame(700.0)] * 10
    ctx = _context({"tactile": tuple(values)})
    assert _only("glove.frozen_array", ctx).status is WARN


# ── 5. 无接触 ───────────────────────────────────────────────
def test_no_contact_with_pressure_passes():
    ctx = _context({"tactile": (_frame(0.0), _frame(620.0))})
    assert _only("glove.no_contact", ctx).status is PASS


def test_no_contact_all_zero_warns():
    ctx = _context({"tactile": tuple([_frame(0.0)] * 500)})
    finding = _only("glove.no_contact", ctx)
    assert finding.status is WARN
    assert finding.metrics["active_frames"] == 0


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