File size: 7,844 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
"""质检配置解析单测(engine.resolve_config / applicable_checks 的用法 +
trigger 的工作流接线判定)。

**这个文件盯的是一类 bug:检查项静默不跑。** 报告照常生成、``passed`` 照常
写,只是有一条本该跑的检查根本没执行 —— 没有任何异常、没有任何日志。项目里
已经因此踩过好几次,所以这里每条都用"跑了几项"来断言,而不是断言没抛异常。

全是纯函数 + 一次针对临时目录的引擎调用,不需要真视频、不需要 cv2。

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

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

from app.processing.cleaning import engine  # noqa: E402
from app.processing.cleaning.collect import _detect_modalities  # noqa: E402
from app.processing.cleaning.trigger import data_quality_target  # noqa: E402


# ── 模态推断 ────────────────────────────────────────────────
def test_infers_from_channels_when_no_hint():
    assert _detect_modalities({"rgb"}, None) == ("mono_rgb",)
    assert _detect_modalities({"tactile"}, None) == ("glove_sensor",)
    assert _detect_modalities({"slam", "rgb"}, None) == ("gripper_device",)


def test_alias_node_types_normalize():
    # 工作流节点类型用的是 camera 后缀,要映射到 canonical 设备模态
    assert _detect_modalities({"rgb"}, "mono_camera") == ("mono_rgb",)
    assert _detect_modalities({"depth"}, "rgbd_camera") == ("rgbd_camera",)


def test_hint_wins_for_modalities_inference_cannot_produce():
    """★ ``stereo_rgb`` 推断不出来 —— "立体"不是列名里看得见的特征。

    没有这条,双目工作流在设置面板里配的阈值会被静默忽略(查不到那个 tab)。
    """
    assert "stereo_rgb" in _detect_modalities({"rgb"}, ["stereo_rgb"])
    assert "stereo_rgbd_camera" in _detect_modalities({"depth"}, ["stereo_rgbd_camera"])


def test_hint_and_inference_are_unioned():
    """两个来源各有盲区,取并集 —— 缺一个都会有检查项不跑。"""
    got = _detect_modalities({"rgb", "depth"}, ["stereo_rgbd_camera"])
    assert "stereo_rgbd_camera" in got          # 来自 hint
    assert "rgbd_camera" in got                 # 来自数据推断


def test_multiple_hints_keep_order():
    """多设备时工作流连线的顺序 = 用户预期顺序(resolve_config 先出现的优先)。"""
    assert _detect_modalities({"rgb", "slam"}, ["gripper_device", "mono_rgb"]) == \
        ("gripper_device", "mono_rgb")


# ── 工作流接线判定 ──────────────────────────────────────────
def _graph(nodes, edges):
    return {
        "nodes": [{"id": i, "data": {"nodeType": t}} for i, t in nodes],
        "edges": [{"source": s, "target": t} for s, t in edges],
    }


def test_target_requires_incoming_edge():
    """孤零零摆一张卡片只是配置,不该触发 —— 与 video_quality_gate 同语义。"""
    assert data_quality_target(_graph([("q", "data_quality")], []), {}) is None


def test_target_accepts_gripper_device():
    """★ ``gripper_device`` 不在 video_quality_gate_config 的源类型白名单里。

    那条白名单漏一个设备类型,卡片就静默失效一次。这里用"有入边"当判据,
    所以 UMI 工作流能正常触发 —— 这是本项目的主用例。
    """
    got = data_quality_target(
        _graph([("a", "gripper_device"), ("q", "data_quality")], [("a", "q")]), {})
    assert got is not None and got[1] == ("gripper_device",)


def test_target_walks_through_process_nodes():
    """相机 → 处理节点 → 质检:直接上游是处理节点,要往回找到设备卡片。"""
    got = data_quality_target(
        _graph([("a", "mono_camera"), ("p", "rgbd_to_3d_bare_hand"), ("q", "data_quality")],
               [("a", "p"), ("p", "q")]), {})
    assert got is not None and got[1] == ("mono_rgb",)


def test_target_handles_legacy_slug():
    """合并前的旧 slug 要能识别(已保存的工作流打开时自动迁移)。"""
    for legacy in ("ai_quality_review", "data_cleaning"):
        got = data_quality_target(
            _graph([("a", "gripper_device"), ("q", legacy)], [("a", "q")]), {})
        assert got is not None, legacy


def test_target_merges_node_configs_over_card():
    """``node_configs`` 覆盖卡片里的 ``data.config``(与既有的卡片配置读取一致)。"""
    graph = _graph([("a", "gripper_device"), ("q", "data_quality")], [("a", "q")])
    graph["nodes"][1]["data"]["config"] = {"devices": {"stale": {}}}
    cfg, _ = data_quality_target(graph, {"q": {"devices": {"gripper_device": {}}}})
    assert "gripper_device" in cfg["devices"]


# ── ★ 配置是黑名单,不是白名单 ──────────────────────────────
#
# 前端设置面板只在用户**碰过**的检查项上写 entry,未触碰的不进 config。
# 曾把"显式启用的那些"当白名单传进 applicable_checks(enabled=...),
# 后果是「在弹窗里改一个阈值 → 其余检查项全部静默停跑」:
# 实测只配 umi.gripper_range 时 9 项变 1 项,不报任何错。
#
# 下面用真实引擎跑一个最小批次(只有 parquet,无视频),断言项数。
def _minimal_batch(tmp: Path) -> Path:
    """造一个只有 UMI 标量列的批次 —— 够触发若干检查项,又不依赖 cv2。"""
    import numpy as np
    import pandas as pd

    import json

    # 目录层级不能省:find_episode_parquets 只认 data/chunk-*/episode_*.parquet
    (tmp / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
    (tmp / "meta").mkdir(exist_ok=True)
    (tmp / "meta" / "info.json").write_text(
        json.dumps({"fps": 30.0}), encoding="utf-8")

    rows = 30
    pd.DataFrame({
        # 列名必须与 _COLUMN_CHANNELS 逐字一致 —— 对不上不会有任何报错,
        # 只是信道识别不出来、检查项静默不跑(正是本文件要盯住的那类 bug)
        "timestamp": np.arange(rows, dtype=float) / 30.0,
        "observation.gripper_state": np.linspace(0.0, 1.0, rows),
        "observation.gripper_left_force": np.linspace(0.0, 5.0, rows),
        "action": np.tile(np.arange(7, dtype=float), (rows, 1)).tolist(),
    }).to_parquet(tmp / "data" / "chunk-000" / "episode_000000.parquet")
    return tmp


def _checks_run(config: dict) -> list[str]:
    with tempfile.TemporaryDirectory() as tmp:
        batch = _minimal_batch(Path(tmp) / "batch")
        report = engine.run_checks(batch, episode_id="t", config=config)
        return sorted(report["modalities"]["checks_run"])


def test_config_only_touched_entry_does_not_disable_the_rest():
    """配了一项阈值 ≠ 只跑这一项。"""
    baseline = _checks_run({})
    assert baseline, "夹具没触发任何检查项,测试无意义"

    only_one = _checks_run({"devices": {"gripper_device": {"checks": {
        "umi.gripper_range": {"enabled": True, "params": {"max_force_drift": 500}},
    }}}})
    assert only_one == baseline


def test_explicit_disable_still_works():
    """黑名单那半边必须仍然生效 —— 别把白名单 bug 修成"配置完全无效"。"""
    baseline = _checks_run({})
    disabled = _checks_run({"devices": {"gripper_device": {"checks": {
        "umi.gripper_range": {"enabled": False},
    }}}})
    assert "umi.gripper_range" not in disabled
    assert len(disabled) == len(baseline) - 1


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