File size: 12,561 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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""清洗契约与检查项注册表单测。

覆盖:设备模态与 device_naming 的口径一致性(漂移守卫)、别名映射、
信道推导、严重度合成、检查项筛选。

    pytest tests/test_cleaning_contract.py
    python3 tests/test_cleaning_contract.py     # 无 pytest 也能跑
"""
import sys
from pathlib import Path

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

from app.device_naming import DEVICE_LABELS  # noqa: E402
from app.processing.cleaning import contract as C  # noqa: E402
from app.processing.cleaning import checks as K  # noqa: E402


# ── 漂移守卫 ────────────────────────────────────────────────
def test_device_modalities_match_device_naming():
    """两个文件声明的设备模态必须逐字一致。

    这是本套设计里唯一靠"人工保持同步"的地方 —— 所以用测试锁死。
    任何人在 device_naming 加了新设备类型而忘了同步这里,这个测试立刻红。
    """
    assert set(C.DEVICE_MODALITIES) == set(DEVICE_LABELS)


def test_every_channel_of_every_modality_is_declared():
    """MODALITY_CHANNELS 用到的信道都必须在 CHANNELS 里声明。"""
    declared = set(C.CHANNELS)
    for modality in C.DEVICE_MODALITIES:
        assert modality in C.MODALITY_CHANNELS, f"{modality} 没声明信道"
        unknown = set(C.MODALITY_CHANNELS[modality]) - declared
        assert not unknown, f"{modality} 用了未声明的信道: {unknown}"


def test_aliases_map_into_canonical_set():
    """别名表的值必须是合法的设备模态。"""
    for alias, target in C._ALIASES.items():
        assert target in C.DEVICE_MODALITIES, f"{alias} → {target} 不是合法模态"


# ── 别名映射 ────────────────────────────────────────────────
def test_normalize_accepts_canonical_and_alias():
    # canonical 值原样返回
    assert C.normalize_device_modality("mono_rgb") == "mono_rgb"
    assert C.normalize_device_modality("gripper_device") == "gripper_device"
    # api/projects 与处理模块 slug 用的 camera 后缀写法
    assert C.normalize_device_modality("mono_camera") == "mono_rgb"
    assert C.normalize_device_modality("stereo_camera") == "stereo_rgb"
    # 历史节点类型
    assert C.normalize_device_modality("fisheye_camera") == "mono_rgb"


def test_normalize_rejects_unknown_without_raising():
    """认不出来要返回 None 而不是抛异常 —— 调用方据此跳过。"""
    assert C.normalize_device_modality("") is None
    assert C.normalize_device_modality(None) is None
    assert C.normalize_device_modality("some_future_sensor") is None


def test_normalize_accepts_whitespace():
    assert C.normalize_device_modality("  mono_rgb  ") == "mono_rgb"


# ── 信道推导 ────────────────────────────────────────────────
def test_channels_for_unions_and_dedupes():
    channels = C.channels_for(["gripper_device", "mono_rgb"])
    assert channels[0] == "rgb"          # 保序:gripper_device 先出现
    assert "time" in channels
    assert len(channels) == len(set(channels)), "有重复信道"


def test_channels_for_unknown_modality_is_empty():
    assert C.channels_for(["not_a_modality"]) == ()
    assert C.channels_for([]) == ()


# ── 严重度 ──────────────────────────────────────────────────
def test_worst_picks_most_severe():
    assert C.worst([C.PASS, C.WARN]) == C.WARN
    assert C.worst([C.WARN, C.FAIL]) == C.FAIL
    assert C.worst([C.FAIL, C.ERROR]) == C.ERROR
    assert C.worst([C.ERROR, C.PENDING, C.WARN]) == C.ERROR


def test_worst_of_empty_is_pass():
    assert C.worst([]) == C.PASS


def test_worst_ignores_unknown_status_names():
    """未知状态名按"最轻"处理,不崩溃 —— 新旧报告混读时不炸。"""
    assert C.worst([C.PASS, "some_new_status"]) == C.PASS


def test_is_blocking_only_for_fail_and_error():
    assert C.is_blocking(C.FAIL) is True
    assert C.is_blocking(C.ERROR) is True
    assert C.is_blocking(C.WARN) is False
    assert C.is_blocking(C.PENDING) is False
    assert C.is_blocking(C.PASS) is False


# ── 检查项筛选 ──────────────────────────────────────────────
class _FakeVideoCheck(K.CleaningCheck):
    slug = "_test.video"
    label = "fake video"
    requires_channels = ("rgb",)
    # 多张卡片共享 —— video.* 就是这样,只要命中其一就跑
    device_cards = ("mono_rgb", "stereo_rgb", "rgbd_camera", "gripper_device")


class _FakeUmiCheck(K.CleaningCheck):
    slug = "_test.umi"
    label = "fake umi"
    requires_channels = ("slam",)
    requires_modality = ("gripper_device",)
    device_cards = ("gripper_device",)


class _FakeCrossCheck(K.CleaningCheck):
    slug = "_test.cross"
    label = "fake cross"
    cross_modal = True


def _with_checks(*classes, fn):
    """临时注册一批检查项,跑完清理 —— 不污染全局注册表。"""
    registered = []
    try:
        for cls in classes:
            K._registry[cls.slug] = cls()
            registered.append(cls.slug)
        return fn()
    finally:
        for slug in registered:
            K._registry.pop(slug, None)


def test_applicable_filters_by_channel():
    def run():
        got = {c.slug for c in K.applicable_checks({"rgb"}, {"mono_rgb"})}
        assert "_test.video" in got
        assert "_test.umi" not in got          # 缺 slam 信道
    _with_checks(_FakeVideoCheck, _FakeUmiCheck, fn=run)


def test_applicable_filters_by_modality():
    def run():
        # 有 slam 信道但设备不是 gripper_device → umi 检查不跑
        got = {c.slug for c in K.applicable_checks({"slam"}, {"mono_rgb"})}
        assert "_test.umi" not in got
        got = {c.slug for c in K.applicable_checks({"slam"}, {"gripper_device"})}
        assert "_test.umi" in got
    _with_checks(_FakeUmiCheck, fn=run)


def test_applicable_filters_by_device_cards():
    """设备卡片闸门 —— 光按信道筛不够。

    真实踩过的坑:手套项目(rgbd_camera)的批次里同样有一列 ``action``
    (采集端写的全零占位),于是 ``umi.action_present`` 满足
    ``requires_channels=("action",)`` 就跑了起来,给手套项目报了个
    "action 全零"的失败 —— 而对方根本不训练动作。

    声明了 device_cards 的检查项,必须只在该设备上跑。
    """
    def run():
        channels = {"action", "slam"}   # 信道全够,只剩设备这一关
        # 设备是手套相机 → UMI 检查不跑,尽管信道满足了
        got = {c.slug for c in K.applicable_checks(channels, {"rgbd_camera"})}
        assert "_test.umi" not in got
        # 设备是 UMI 夹爪 → 跑
        got = {c.slug for c in K.applicable_checks(channels, {"gripper_device"})}
        assert "_test.umi" in got
    _with_checks(_FakeUmiCheck, fn=run)


def test_device_cards_gate_is_skipped_for_shared_checks():
    """声明了多张卡片的检查项,只要命中其一就跑(video.* 就是这种)。"""
    def run():
        got = {c.slug for c in K.applicable_checks({"rgb"}, {"stereo_rgb"})}
        assert "_test.video" in got
    _with_checks(_FakeVideoCheck, fn=run)


def test_cross_modal_requires_two_modalities():
    def run():
        # 单模态 → 跨模态检查不跑
        got = {c.slug for c in K.applicable_checks(set(), {"mono_rgb"})}
        assert "_test.cross" not in got
        # 显式声明跨模态成立 → 跑
        got = {c.slug for c in K.applicable_checks(
            set(), {"mono_rgb", "glove_sensor"}, cross_modal_active=True)}
        assert "_test.cross" in got
    _with_checks(_FakeCrossCheck, fn=run)


def test_applicable_respects_enabled_and_disabled():
    def run():
        got = {c.slug for c in K.applicable_checks(
            {"rgb"}, {"mono_rgb"}, disabled={"_test.video"})}
        assert "_test.video" not in got
        got = {c.slug for c in K.applicable_checks(
            {"rgb"}, {"mono_rgb"}, enabled={"_test.video"})}
        assert got == {"_test.video"}
    _with_checks(_FakeVideoCheck, fn=run)


def test_all_checks_is_sorted_by_slug():
    """排序稳定 —— ruleset_revision 是对它取哈希,顺序变了指纹就变。"""
    slugs = [c.slug for c in K.all_checks()]
    assert slugs == sorted(slugs)


def test_register_rejects_duplicate_slug():
    class First(K.CleaningCheck):
        slug = "_test.dupe"

    class Second(K.CleaningCheck):
        slug = "_test.dupe"

    try:
        K.register_check(First)
        try:
            K.register_check(Second)
        except ValueError as exc:
            assert "_test.dupe" in str(exc)
        else:
            raise AssertionError("重复 slug 应当抛 ValueError")
    finally:
        K._registry.pop("_test.dupe", None)


def test_register_rejects_empty_slug():
    class NoSlug(K.CleaningCheck):
        slug = ""

    try:
        K.register_check(NoSlug)
    except ValueError:
        pass
    else:
        raise AssertionError("空 slug 应当抛 ValueError")


# ── 设备卡片分组(设置面板的 tab)────────────────────────────
def test_every_check_declares_device_cards():
    """除跨设备检查外,每项都必须声明属于哪些设备卡片。

    否则它在设置面板里【无处可去】—— 用户看不到、也改不了它的阈值。
    跨设备检查(cross_modal=True)约定不绑单卡片,只在"跨设备" tab 出现。
    """
    for check in K.all_checks():
        if check.cross_modal:
            continue
        assert check.device_cards, f"{check.slug} 没有声明 device_cards"


def test_device_cards_are_known_modalities():
    """device_cards 的取值必须来自 contract 的设备模态表 —— 防止拼错字符串。

    拼错不会报错,只会让检查项在面板上凭空消失,很难发现。
    """
    known = set(C.DEVICE_MODALITIES)
    for check in K.all_checks():
        for card in check.device_cards:
            assert card in known, f"{check.slug} 声明了未知设备卡片 {card!r}"


def test_checks_for_device_cards_only_returns_requested():
    """未连接的设备不该出现在分组结果里。"""
    grouped = K.checks_for_device_cards(["gripper_device"])
    assert set(grouped) == {"gripper_device"}
    assert all(c.device_cards.count("gripper_device") for c in grouped["gripper_device"])


def test_checks_for_device_cards_places_multi_card_checks():
    """一个检查项可以同时属于多张卡片(video.* 对每种带摄像头的设备都成立)。"""
    grouped = K.checks_for_device_cards(["rgbd_camera", "gripper_device"])
    in_a = {c.slug for c in grouped["rgbd_camera"]}
    in_b = {c.slug for c in grouped["gripper_device"]}
    assert "video.frame_drop" in in_a and "video.frame_drop" in in_b
    # UMI 专有的只出现在 gripper_device 下
    assert "umi.slam_continuity" in in_b and "umi.slam_continuity" not in in_a


def test_video_checks_cover_gripper_device():
    """UMI 夹爪自带鱼眼相机,视频检查对它同样成立。

    历史坑:video.* 早期只声明了四种摄像机模态,漏了 gripper_device ——
    结果 UMI 项目的设置面板里视频检查整组消失。
    """
    grouped = K.checks_for_device_cards(["gripper_device"])
    slugs = {c.slug for c in grouped["gripper_device"]}
    assert {"video.frame_drop", "video.black_screen",
            "video.freeze", "video.decode_error"} <= slugs


if __name__ == "__main__":
    _failures = 0
    for _name, _fn in sorted(globals().items()):
        if not _name.startswith("test_") or not callable(_fn):
            continue
        import inspect
        try:
            if "tmp_path" in inspect.signature(_fn).parameters:
                import tempfile
                with tempfile.TemporaryDirectory() as _tmp:
                    _fn(Path(_tmp))
            else:
                _fn()
            print(f"PASS {_name}")
        except Exception as _exc:  # noqa: BLE001
            _failures += 1
            print(f"FAIL {_name}: {_exc}")
    raise SystemExit(1 if _failures else 0)