File size: 6,881 Bytes
edae745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""采集端任务进度账本单测(app/device_progress.py)。

覆盖:幂等重放、多设备聚合、增量水位、参数校验、未知任务拒绝(400 而非
404 —— 采集端把 404 当"后端未实现"永久降级)、持久化与坏文件容错。

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

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

from app import device_progress as dp  # noqa: E402

TASK = "task-aaa"
OTHER = "task-bbb"
KNOWN = {TASK, OTHER}


def _report(tmp, increment, session_id, task=TASK, device="EGO_001"):
    return dp.apply_report(
        {"task_id": task, "device_name": device,
         "session_id": session_id, "increment": increment},
        KNOWN, path=tmp)


def test_first_report_sets_total(tmp_path):
    f = tmp_path / "p.json"
    out = _report(f, 1, "EGO_001:t:0")
    assert out["applied"] is True
    assert out["completed_count"] == 1
    assert dp.reported_total(TASK, path=f) == 1


def test_replayed_watermark_is_idempotent(tmp_path):
    """采集端崩溃重启后会重发同一水位 —— 不能重复计数。"""
    f = tmp_path / "p.json"
    first = _report(f, 12, "EGO_001:t:0")
    again = _report(f, 12, "EGO_001:t:0")
    assert first["completed_count"] == 12
    assert again["applied"] is False
    assert again["completed_count"] == 12
    # 重放仍要回当前聚合数(采集端拿它当后端权威数)
    assert isinstance(again["completed_count"], int)
    assert not isinstance(again["completed_count"], bool)


def test_incremental_watermarks_accumulate(tmp_path):
    f = tmp_path / "p.json"
    _report(f, 12, "EGO_001:t:0")
    out = _report(f, 1, "EGO_001:t:12")
    assert out["completed_count"] == 13


def test_multiple_devices_aggregate(tmp_path):
    f = tmp_path / "p.json"
    _report(f, 3, "EGO_001:t:0", device="EGO_001")
    _report(f, 2, "EGO_002:t:0", device="EGO_002")
    out = _report(f, 1, "EGO_003:t:0", device="EGO_003")
    assert out["completed_count"] == 6
    # 同一水位键跨设备不冲突
    assert dp.reported_total(TASK, path=f) == 6


def test_zero_increment_is_recorded_without_growth(tmp_path):
    f = tmp_path / "p.json"
    _report(f, 5, "EGO_001:t:0")
    out = _report(f, 0, "EGO_001:t:5")
    assert out["applied"] is True and out["completed_count"] == 5


def test_totals_only_contain_reported_tasks(tmp_path):
    """没上报过的项目不能出现在这里 —— 调用方据此回退 session 计数。"""
    f = tmp_path / "p.json"
    _report(f, 1, "EGO_001:t:0")
    totals = dp.reported_totals(path=f)
    assert totals == {TASK: 1}
    assert dp.reported_total(OTHER, path=f) is None


def test_validation_errors(tmp_path):
    f = tmp_path / "p.json"
    bad = [
        ({}, "task_id"),                                            # 空 body
        ({"task_id": TASK, "session_id": "s"}, "device_name"),
        ({"task_id": TASK, "device_name": "d"}, "session_id"),
        ({"task_id": TASK, "device_name": "d", "session_id": "s",
          "increment": -1}, "increment"),
        ({"task_id": TASK, "device_name": "d", "session_id": "s",
          "increment": "x"}, "increment"),
        ({"task_id": TASK, "device_name": "d", "session_id": "s",
          "increment": True}, "increment"),
        ({"task_id": TASK, "device_name": "d", "session_id": "s",
          "increment": dp.MAX_INCREMENT + 1}, "increment"),
    ]
    for body, needle in bad:
        try:
            dp.apply_report(body, KNOWN, path=f)
        except ValueError as exc:
            assert needle in str(exc), (body, str(exc))
        else:
            raise AssertionError(f"应当拒绝: {body}")
    assert dp.reported_totals(path=f) == {}  # 拒绝的请求不落账


def test_unknown_task_rejected_not_silently_accepted(tmp_path):
    """未知任务要报错(路由转 400)。绝不能 404:采集端会永久降级上报。"""
    f = tmp_path / "p.json"
    try:
        _report(f, 1, "EGO_001:t:0", task="no-such-task")
    except ValueError as exc:
        assert "unknown task_id" in str(exc)
    else:
        raise AssertionError("未知任务应当被拒绝")
    # known_task_ids=None(不校验)时照常落账 —— 路由必须传已知集合
    out = dp.apply_report({"task_id": "no-such-task", "device_name": "d",
                           "session_id": "s", "increment": 1}, None, path=f)
    assert out["completed_count"] == 1


def test_count_prefers_report_over_sessions(tmp_path):
    """核心口径:有上报就用录制数 —— 之后再上传多少条都不动它。"""
    f = tmp_path / "p.json"
    proj = {"id": TASK, "name": "UMIGripper_AI"}
    _report(f, 12, "EGO_001:t:0")
    reported = dp.reported_totals(path=f)
    assert dp.resolve_count(proj, reported, sessions=16) == (12, "reported")
    # 又上传了 3 条(项目下 session 变 19)→ 进度不变
    assert dp.resolve_count(proj, reported, sessions=19) == (12, "reported")
    # 录了一条 → 上报后 +1
    _report(f, 1, "EGO_001:t:12")
    assert dp.resolve_count(proj, dp.reported_totals(path=f),
                            sessions=19) == (13, "reported")


def test_count_falls_back_to_sessions_before_any_report(tmp_path):
    """升级前就有数据的历史项目不归零:没有上报时仍按 session 数。"""
    proj = {"id": TASK, "name": "UMIGripper_AI"}
    assert dp.resolve_count(proj, {}, sessions=16) == (16, "sessions")
    assert dp.needs_session_fallback([proj], {}) is True
    assert dp.needs_session_fallback([proj], {TASK: 12}) is False


def test_persisted_across_reload(tmp_path):
    f = tmp_path / "p.json"
    _report(f, 2, "EGO_001:t:0")
    raw = json.loads(f.read_text(encoding="utf-8"))
    assert raw["tasks"][TASK]["devices"]["EGO_001"]["count"] == 2
    # 重新读文件(模拟进程重启)后水位仍生效
    again = _report(f, 2, "EGO_001:t:0")
    assert again["applied"] is False and again["completed_count"] == 2


def test_corrupt_file_degrades_to_empty(tmp_path):
    f = tmp_path / "p.json"
    f.write_text("{ not json", encoding="utf-8")
    assert dp.reported_totals(path=f) == {}
    out = _report(f, 1, "EGO_001:t:0")
    assert out["completed_count"] == 1


if __name__ == "__main__":
    import tempfile
    import traceback

    failed = 0
    for name, fn in sorted(globals().items()):
        if not name.startswith("test_") or not callable(fn):
            continue
        with tempfile.TemporaryDirectory() as d:
            try:
                fn(Path(d))
                print(f"  PASS: {name}")
            except Exception:
                failed += 1
                print(f"  FAIL: {name}")
                traceback.print_exc()
    print("FAIL" if failed else "PASS: device_task_progress 全部通过")
    sys.exit(1 if failed else 0)