File size: 53,115 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
"""Contract Analyzer đa cú (lát A1, 13/08/2026) — fakeredis + file tạm,
KHÔNG cần Docker/Redis/torch/ffmpeg (pipeline CV thật nghiệm thu ở gate
A1.2 trên VOD thật).

Nếp test_analyze: worker giả chạy ĐÚNG code path transport của worker thật
(`serve_one` trên SEGMENT_JOBS_KEY), handler ghi manifest canned. Hợp đồng
khoá ở đây:

1. POST /api/analyzer/videos: validate + lưu video + status TRƯỚC enqueue.
2. GET /api/analyzer/shots: ghép FILE (nguồn sự thật) → Redis (tiến độ) →
   "unknown" — danh sách hiện DẦN, cú lỗi NẰM TRONG danh sách kèm lý do.
3. Transport worker: kết quả per cú ghi FILE ngay khi có (nếp BG29b — kết
   quả từng MẤT THẬT khi chỉ nằm Redis TTL 1h), lỗi cũng ra file.
4. DB ghi danh sách best-effort, không chặn luồng khi DB tắt.
"""

from __future__ import annotations

import json
import threading

import pytest
from fastapi.testclient import TestClient

from app import analyzer_store as astore
from app import db, jobqueue
from app import main as app_main

VIDEO_BYTES = b"\x00\x00\x00 ftypisomfake-mp4-bytes"
CORNERS = "[[100, 80], [1800, 90], [1700, 900], [200, 880]]"

RESULT_SAMPLE = {
    "metrics": {"v0_mps": 2.64, "phi_deg": 199.3, "motion_start_s": 0.77,
                "n_collisions": 1, "coverage": 0.97, "max_gap_s": 0.067,
                "n_frames": 61, "n_covered": 61, "n_dup_frames": 9,
                "duration_s": 2.03, "table_w_m": 1.27, "table_l_m": 2.54,
                "elapsed_s": 41.7},
    "collisions": [{"t_s": 1.0, "x_m": 0.62, "y_m": 1.31,
                    "kind": "kink+speed_drop", "dtheta_deg": 54.5,
                    "drop_frac": 0.77, "contact": "ball"}],
    "spin_class": "follow",
    "spin_confidence": "low",
    "shotnet": {"model": "shotnet_20260812_c4b", "v0_cue_mps": 2.31,
                "phi_deg": 200.8, "a": -0.12, "b": 0.05,
                "spin_vert": "follow", "spin_side": "side-R",
                "identifiable_prob": 0.91, "confidence": "medium",
                "confidence_raw": "high", "det_density": 1.8,
                "inference_ms": 18.4, "n_target_slots": 7},
    "track": [{"t_s": 0.0, "x_m": 0.99, "y_m": 0.586}],
    "warnings": ["Track chỉ phủ 75% thời lượng."],
    "balls_init": [],
}


@pytest.fixture(autouse=True)
def _reset_jobqueue():
    yield
    jobqueue.teardown()


@pytest.fixture(autouse=True)
def _pin_ffprobe(monkeypatch):
    monkeypatch.setattr(app_main, "_probe_duration_s", lambda p: 300.0)


@pytest.fixture(autouse=True)
def _tmp_analyzer_dir(monkeypatch, tmp_path):
    """Thư mục kết quả analyzer rơi vào tmp_path — route lẫn helper store
    cùng trỏ một chỗ (app + worker cùng máy là giao ước của tính năng)."""
    base = tmp_path / "analyzer"
    monkeypatch.setattr(app_main, "_analyzer_video_dir",
                        lambda vid: base / vid)
    return base


@pytest.fixture
def client():
    return TestClient(app_main.app)


@pytest.fixture
def fq():
    from test_queue import FakeQueue

    q = FakeQueue()
    jobqueue.setup(client=q.api)
    return q


@pytest.fixture
def db_mem():
    db.setup("sqlite:///:memory:")
    db.Base.metadata.create_all(db.get_engine())
    yield db
    db.teardown()


def beat_cv(fq):
    jobqueue.beat(client=fq.worker, key=jobqueue.SCAN_HEARTBEAT_KEY)


def post_video(client, video=VIDEO_BYTES, corners=CORNERS, name="rack.mp4"):
    return client.post("/api/analyzer/videos",
                       files={"video": (name, video, "video/mp4")},
                       data={"corners": corners})


def canned_manifest(video_id, base, n_ok=2, n_err=1):
    """Manifest như segment_once viết: n_ok cú lành (queued) + n_err cú
    segmentation kết án (mất cảnh)."""
    shots = []
    for i in range(1, n_ok + 1):
        shots.append({"idx": i, "t_start_s": 10.0 * i, "t_end_s": 10.0 * i + 5,
                      "t_onset_s": 10.0 * i + 0.5,
                      "t_settle_s": 10.0 * i + 4.5,
                      "status": "queued", "reason": None,
                      "analyze_id": astore.shot_analyze_id(video_id, i),
                      "clip": f"clips/shot_{i:02d}.mp4",
                      "thumb": f"thumb_{i:02d}.jpg"})
    for j in range(n_ok + 1, n_ok + n_err + 1):
        shots.append({"idx": j, "t_start_s": 10.0 * j, "t_end_s": 10.0 * j + 3,
                      "t_onset_s": 10.0 * j + 0.5, "t_settle_s": None,
                      "status": "error",
                      "reason": "mất cảnh giữa cú — không thấy bi nào trên "
                                "bàn quá 1s (video đổi góc/replay)",
                      "analyze_id": astore.shot_analyze_id(video_id, j),
                      "thumb": f"thumb_{j:02d}.jpg"})
    return {"status": "done", "video_id": video_id, "filename": "rack.mp4",
            "fps_nominal": 30.0, "t_first_s": 0.0, "t_last_s": 300.0,
            "duration_s": 300.0, "n_frames": 9000, "n_dup_frames": 1500,
            "elapsed_scan_s": 111.0, "warnings": [], "shots": shots}


def start_segment_worker(fq, base, n_ok=2, n_err=1, seen=None):
    """Worker segment giả — serve_one trên SEGMENT_JOBS_KEY như worker
    thật; handler làm đúng các bước TRANSPORT của segment_once: manifest ra
    FILE trước, rồi status queued per cú, rồi enqueue N job analyze."""
    beat_cv(fq)

    def handler(payload):
        if seen is not None:
            seen.update(payload)
        vid = payload["video_id"]
        man = canned_manifest(vid, base, n_ok=n_ok, n_err=n_err)
        vdir = base / vid
        astore.write_json(astore.manifest_path(vdir), man)
        for s in man["shots"]:
            if s["status"] != "queued":
                continue
            jobqueue.set_analyze_status(
                s["analyze_id"], {"status": "queued", "progress": 0.0},
                client=fq.worker)
            c = fq.worker
            c.lpush(jobqueue.ANALYZE_JOBS_KEY, json.dumps(
                {"job_id": s["analyze_id"],
                 "payload": {"analyze_id": s["analyze_id"]}}))
        jobqueue.set_video_status(vid, {"status": "done", "progress": 1.0,
                                        "n_shots": len(man["shots"])},
                                  client=fq.worker)
        return {"ok": True, "result": {"video_id": vid}}

    def run():
        tries = 0
        while tries < 50:
            tries += 1
            if jobqueue.serve_one(handler, timeout_s=0.2, client=fq.worker,
                                  jobs_key=jobqueue.SEGMENT_JOBS_KEY):
                return

    t = threading.Thread(target=run, daemon=True)
    t.start()
    return t


def wait_manifest(base, vid, timeout=5.0):
    import time

    deadline = time.perf_counter() + timeout
    p = astore.manifest_path(base / vid)
    while time.perf_counter() < deadline:
        if p.exists():
            return
        time.sleep(0.05)
    raise AssertionError("worker giả không ghi manifest")


# ------------------------------------------------------------ degraded

def test_khong_redis_url_503(client):
    jobqueue.teardown()
    assert post_video(client).status_code == 503
    r = client.get("/api/analyzer/shots", params={"video": "a" * 32})
    assert r.status_code == 503


def test_redis_chet_503_khong_dong_file(client, fq, _tmp_analyzer_dir):
    fq.server.connected = False
    r = post_video(client)
    assert r.status_code == 503
    assert "Redis" in r.json()["detail"]
    # video bản quyền không được đọng lại sau request thất bại
    assert not _tmp_analyzer_dir.exists() \
        or not any(_tmp_analyzer_dir.rglob("*"))


def test_vang_heartbeat_503_khong_enqueue(client, fq, _tmp_analyzer_dir):
    r = post_video(client)
    assert r.status_code == 503
    assert "CV worker không chạy" in r.json()["detail"]
    assert fq.api.llen(jobqueue.SEGMENT_JOBS_KEY) == 0
    assert not _tmp_analyzer_dir.exists() \
        or not any(_tmp_analyzer_dir.rglob("*"))


# ------------------------------------------------------- validate input

@pytest.mark.parametrize("bad", [
    "khong-json", "[[1,2],[3,4]]", '[[1,2],[3,4],[5,6],["x",8]]'])
def test_corners_hong_422(client, fq, bad):
    r = post_video(client, corners=bad)
    assert r.status_code == 422
    assert "corners" in r.json()["detail"]


def test_duoi_la_422(client, fq):
    r = post_video(client, name="tran.avi")
    assert r.status_code == 422


def test_video_rong_422(client, fq, _tmp_analyzer_dir):
    r = post_video(client, video=b"")
    assert r.status_code == 422
    assert "rỗng" in r.json()["detail"]


def test_video_qua_dai_422(client, fq, monkeypatch, _tmp_analyzer_dir):
    monkeypatch.setattr(app_main, "_probe_duration_s", lambda p: 25 * 60.0)
    r = post_video(client)
    assert r.status_code == 422
    assert "phút" in r.json()["detail"]
    assert not any(_tmp_analyzer_dir.rglob("*.mp4"))


def test_video_id_rac_422_khong_cham_file(client, fq):
    """Chặn id lạ trước khi ghép đường dẫn — '../' không được đi lạc."""
    for vid in ["../../etc", "..%2F..", "A" * 32, "xyz"]:
        r = client.get("/api/analyzer/shots", params={"video": vid})
        assert r.status_code == 422
    r = client.get("/api/analyzer/videos/../shots/1/result")
    assert r.status_code in (404, 422)


# ------------------------------------------------------------ happy path

def test_post_luu_video_status_truoc_enqueue(client, fq, _tmp_analyzer_dir):
    beat_cv(fq)
    r = post_video(client)
    assert r.status_code == 200
    data = r.json()
    assert data["status"] == "queued"
    vid = data["id"]
    assert len(vid) == 32

    assert fq.api.llen(jobqueue.SEGMENT_JOBS_KEY) == 1
    job = json.loads(fq.api.lindex(jobqueue.SEGMENT_JOBS_KEY, 0))
    assert job["job_id"] == vid
    p = job["payload"]
    assert p["video_id"] == vid
    assert p["corners"] == [[100, 80], [1800, 90], [1700, 900], [200, 880]]
    assert p["out_dir"] == str(_tmp_analyzer_dir / vid)
    saved = _tmp_analyzer_dir / vid / "video.mp4"
    assert p["video_path"] == str(saved)
    assert saved.read_bytes() == VIDEO_BYTES
    # status queued đã nằm sẵn TRƯỚC khi worker kịp đụng job (nếp BG24)
    st = jobqueue.get_video_status(vid)
    assert st == {"status": "queued", "progress": 0.0}

    g = client.get("/api/analyzer/shots", params={"video": vid})
    assert g.status_code == 200
    body = g.json()
    assert body["video"]["status"] == "queued"
    assert body["shots"] == [] and body["n_total"] == 0


def test_dang_scan_tra_tien_do(client, fq):
    beat_cv(fq)
    vid = post_video(client).json()["id"]
    jobqueue.set_video_status(vid, {"status": "running", "progress": 0.37,
                                    "stage": "detect"}, client=fq.worker)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert body["video"] == {"id": vid, "status": "running",
                             "progress": 0.37, "stage": "detect"}


def test_video_la_404(client, fq):
    r = client.get("/api/analyzer/shots", params={"video": "b" * 32})
    assert r.status_code == 404


def test_worker_gia_ra_danh_sach_cu_loi_nam_trong_danh_sach(
        client, fq, _tmp_analyzer_dir):
    seen = {}
    start_segment_worker(fq, _tmp_analyzer_dir, seen=seen)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)

    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert seen["video_id"] == vid
    assert body["video"]["status"] == "done"
    assert body["video"]["n_shots"] == 3
    assert body["n_total"] == 3 and body["n_done"] == 0
    s1, s2, s3 = body["shots"]
    # cú lành: đang xếp hàng phân tích, có link kết quả để FE click sau
    assert s1["status"] == "queued"
    assert s1["result_url"].endswith(f"/videos/{vid}/shots/1/result")
    assert s1["thumb_url"].endswith(f"/videos/{vid}/shots/1/thumb")
    # cú segmentation kết án: NẰM TRONG danh sách kèm lý do (gate A1.4)
    assert s3["status"] == "error"
    assert "mất cảnh" in s3["reason"]
    assert "result_url" not in s3


def test_tien_do_per_cu_di_qua(client, fq, _tmp_analyzer_dir):
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    jobqueue.set_analyze_status(astore.shot_analyze_id(vid, 1),
                                {"status": "running", "progress": 0.55,
                                 "stage": "detect"}, client=fq.worker)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s1 = body["shots"][0]
    assert s1["status"] == "running"
    assert s1["progress"] == 0.55 and s1["stage"] == "detect"


def test_ket_qua_file_thanh_hang_done_hien_dan(client, fq,
                                               _tmp_analyzer_dir):
    """JSON per cú trên ĐĨA là nguồn sự thật: có file → hàng done kèm tóm
    tắt tham số; cú kia chưa có → vẫn queued. Danh sách hiện DẦN đúng nghĩa
    (n_done tăng theo file, không theo Redis)."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)

    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert body["n_done"] == 1
    s1 = body["shots"][0]
    assert s1["status"] == "done"
    assert s1["v0_mps"] == 2.64 and s1["phi_deg"] == 199.3
    assert s1["spin_class"] == "follow"
    assert s1["n_collisions"] == 1 and s1["n_warnings"] == 1
    assert s1["shotnet"]["v0_cue_mps"] == 2.31
    assert s1["shotnet"]["a"] == -0.12 and s1["shotnet"]["b"] == 0.05
    assert s1["shotnet"]["confidence"] == "medium"
    # rmse chừa chỗ cho resim A2 — A1 không được bịa số (exclude_none)
    assert "rmse_mm" not in s1
    assert body["shots"][1]["status"] == "queued"


def test_cu_loi_file_ra_hang_error(client, fq, _tmp_analyzer_dir):
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 2),
                      {"error": "validation",
                       "message": "Không thấy lúc cue ball bắt đầu chạy — "
                                  "track đứt giữa cú."})
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s2 = body["shots"][1]
    assert s2["status"] == "error"
    assert "track đứt" in s2["reason"]


def test_mat_dau_vet_ra_unknown_noi_thang(client, fq, _tmp_analyzer_dir):
    """Không file, hết TTL Redis (worker chết giữa chừng) — nói thẳng
    unknown, không đoán mò thành queued vĩnh viễn."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    fq.worker.delete(jobqueue.ANALYZE_STATUS_KEY.format(
        analyze_id=astore.shot_analyze_id(vid, 1)))
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s1 = body["shots"][0]
    assert s1["status"] == "unknown"
    assert "dấu vết" in s1["reason"]


def test_segment_loi_manifest_error_ben_vung(client, fq, _tmp_analyzer_dir):
    """Manifest error trên ĐĨA sống lâu hơn TTL Redis — video hỏng vẫn
    biết vì sao sau 1h."""
    beat_cv(fq)
    vid = post_video(client).json()["id"]
    astore.write_json(astore.manifest_path(_tmp_analyzer_dir / vid),
                      {"status": "error", "video_id": vid,
                       "message": "Video dài 33.4 phút — giới hạn 20 phút."})
    fq.worker.delete(jobqueue.VIDEO_STATUS_KEY.format(video_id=vid))
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert body["video"]["status"] == "error"
    assert "giới hạn 20" in body["video"]["message"]


def test_result_endpoint_tra_json_tho(client, fq, _tmp_analyzer_dir):
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
    r = client.get(f"/api/analyzer/videos/{vid}/shots/1/result")
    assert r.status_code == 200
    assert r.json() == RESULT_SAMPLE          # thô nguyên vẹn, không gọt
    assert client.get(
        f"/api/analyzer/videos/{vid}/shots/2/result").status_code == 404


def test_thumb_endpoint(client, fq, _tmp_analyzer_dir):
    beat_cv(fq)
    vid = post_video(client).json()["id"]
    p = astore.thumb_path(_tmp_analyzer_dir / vid, 1)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(b"\xff\xd8\xff\xe0fakejpg")
    r = client.get(f"/api/analyzer/videos/{vid}/shots/1/thumb")
    assert r.status_code == 200
    assert r.headers["content-type"] == "image/jpeg"
    assert client.get(
        f"/api/analyzer/videos/{vid}/shots/9/thumb").status_code == 404


# ------------------------------------------------------- DB ghi danh sách

def test_db_ghi_video_va_sync_danh_sach(client, fq, db_mem,
                                        _tmp_analyzer_dir):
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    with db.session() as s:
        v = s.get(db.AnalyzerVideo, vid)
        assert v is not None and v.filename == "rack.mp4"

    client.get("/api/analyzer/shots", params={"video": vid})
    with db.session() as s:
        rows = (s.query(db.AnalyzerShot).filter_by(video_id=vid)
                .order_by(db.AnalyzerShot.shot_idx).all())
        assert [r.shot_idx for r in rows] == [1, 2, 3]
        assert rows[2].status == "error"
        v = s.get(db.AnalyzerVideo, vid)
        assert v.status == "done" and v.n_shots == 3

    # cú 1 xong → GET sync đổi status, KHÔNG đẻ row trùng
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
    client.get("/api/analyzer/shots", params={"video": vid})
    client.get("/api/analyzer/shots", params={"video": vid})
    with db.session() as s:
        rows = s.query(db.AnalyzerShot).filter_by(video_id=vid).all()
        assert len(rows) == 3
        r1 = next(r for r in rows if r.shot_idx == 1)
        assert r1.status == "done"
        assert r1.result["v0_mps"] == 2.64


def test_khong_db_van_chay_nguyen(client, fq, _tmp_analyzer_dir):
    db.teardown()
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert body["video"]["status"] == "done"


# ---------------------------------------- probe nguồn FE (nếp #scan-go)

def _fe_src(name):
    from pathlib import Path

    return (Path(__file__).resolve().parents[1] / "app" / "static"
            / name).read_text(encoding="utf-8")


def test_fe_nut_video_da_cu_khong_muon_btn_go():
    """Nút luồng đa cú phải là .an-cta, KHÔNG .btn-go — .btn-go có binding
    recommend() toàn cục + syncButtons ép label (bug 2 nút 'Đánh cú này'
    giữa tab Analyzer, BG25/BG26)."""
    html = _fe_src("index.html")
    assert 'id="anv-pick"' in html
    i = html.index('id="anv-pick"')
    tag = html[html.rindex("<button", 0, i):html.index(">", i)]
    assert "an-cta" in tag and "btn-go" not in tag
    # bảng §3.1 đủ cột; khu kết quả + tbody để JS đổ dần
    for header in [">V0<", ">φ<", ">Spin (a/b)<", ">Tin cậy<", ">RMSE<",
                   ">Trạng thái<"]:
        assert header in html
    assert 'id="anv-result"' in html and 'id="anv-tbody"' in html


def test_fe_bang_hien_dan_va_hang_loi_co_ly_do():
    """app.js phải: poll GET /api/analyzer/shots, dòng tiến độ "Phát hiện N
    cú — xong K/N", hàng lỗi .anv-err kèm lý do, click hàng done ra JSON
    thô, móc smoke __anvRenderMock đi qua ĐÚNG render thật."""
    js = _fe_src("app.js")
    assert "/api/analyzer/videos" in js
    assert "/api/analyzer/shots?video=" in js
    assert "Phát hiện ${body.n_total} cú — xong " in js
    assert "anv-err" in js and "anv-reason" in js
    assert "không phân tích được" in js
    assert "result_url" in js and "__anvRenderMock" in js
    assert "renderAnvShots" in js
    # RMSE resim là cột để dành A2 — không được bịa số
    assert "RMSE" in _fe_src("index.html")


def test_fe_luong_1_cu_da_go_mot_duong_vao(client):
    """A2b feedback #1: luồng 1-cú GỠ HẲN — không còn hai đường vào Analyzer
    trên UI (gate A2b.2), không còn code path POST /api/analyze trong FE,
    và endpoint cũng đã gỡ (grep scripts/tests 13/08: không script nào gọi
    HTTP — diag BG29b–BG32 chỉ đọc JSON đã lưu)."""
    js = _fe_src("app.js")
    assert '"/api/analyze"' not in js
    assert "__anRenderMock =" not in js          # mock luồng cũ đi theo
    assert "__anvRenderMock" in js               # mock đa cú còn nguyên
    html = _fe_src("index.html")
    assert 'id="an-pick"' not in html
    assert 'id="an-result"' not in html and 'id="an-progress"' not in html
    # đúng MỘT nút vào luồng phân tích ở khu đầu tab (trước #anv-result;
    # #and-replay trong trang chi tiết cũng .an-cta — không tính đường vào)
    i0 = html.index('<section id="analyzer"')
    entry = html[i0:html.index('id="anv-result"', i0)]
    assert entry.count('class="an-cta"') == 1 and 'id="anv-pick"' in entry
    # endpoint: POST 404/405, GET 404 (route không tồn tại — khác 503 queue)
    assert client.post("/api/analyze").status_code in (404, 405)
    assert client.get("/api/analyze/deadbeef").status_code == 404


def test_video_1_cu_ra_dung_1_hang_du_thong_so(client, fq,
                                               _tmp_analyzer_dir):
    """Gate A2b.2: video chứa ĐÚNG 1 cú đi luồng đa cú → bảng đúng 1 hàng
    done đủ thông số (V0/φ/spin model + analytic) — trường hợp con thay
    trọn vai luồng 1-cú cũ."""
    start_segment_worker(fq, _tmp_analyzer_dir, n_ok=1, n_err=0)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert body["n_total"] == 1 and body["n_done"] == 1
    s1 = body["shots"][0]
    assert s1["status"] == "done"
    assert s1["v0_mps"] == 2.64 and s1["phi_deg"] == 199.3
    assert s1["shotnet"]["v0_cue_mps"] == 2.31
    assert s1["spin_class"] == "follow"
    assert s1["result_url"].endswith("/shots/1/result")


def test_fe_thoat_tab_khong_dung_poll_nep_cu():
    """Nếp BG24 giữ nguyên cho luồng mới: rời tab KHÔNG dừng poll — job
    server cứ chạy, quay lại tab thấy kết quả."""
    js = _fe_src("app.js")
    i = js.index("function exitAnalyzer")
    body = js[i:js.index("\nfunction ", i + 10)]
    assert "stopAnvPoll" not in body


# ---------------------- van "nghi không phải cú đánh" (lát A2 phần 1)
# Cờ HIỂN THỊ từ 3 tín hiệu tự khai (V0 ngoài [1,8] / motion_start None /
# coverage < 0.8) — số đối chiếu là 3 cú ma + 11 cú thật rack_a1 ĐÃ CHỐT.

def _result_with_metrics(**over):
    res = json.loads(json.dumps(RESULT_SAMPLE))    # deep copy
    res["metrics"].update(over)
    return res


def test_van_bat_dung_3_chu_ky_cu_ma_rack_a1():
    """Chữ ký tự khai của 3 cú ma idx 7/8/9 (BRIEF A2): v0 0.27 / None /
    0.99 m/s, coverage 45.5% / 38.9% / 60% — van phải nổ cả 3."""
    cw = _cv_worker()
    ma7 = cw.not_shot_flag(_result_with_metrics(v0_mps=0.271,
                                                coverage=0.455))
    assert ma7["flagged"] is True
    assert any("V0" in r for r in ma7["reasons"])
    assert any("phủ" in r for r in ma7["reasons"])

    ma8 = cw.not_shot_flag(_result_with_metrics(
        v0_mps=None, motion_start_s=None, coverage=0.389))
    assert ma8["flagged"] is True
    assert any("motion_start" in r for r in ma8["reasons"])

    ma9 = cw.not_shot_flag(_result_with_metrics(v0_mps=0.991, coverage=0.60))
    assert ma9["flagged"] is True


def test_van_khong_bat_oan_cu_that():
    """Cú thật rack_a1: coverage thấp nhất 92.6%, V0 1.8–7.13 ∈ [1, 8] —
    van im lặng (flagged False, reasons rỗng); V0 cao 7.9 sát trần vẫn qua."""
    cw = _cv_worker()
    ok = cw.not_shot_flag(_result_with_metrics(v0_mps=1.805, coverage=0.926))
    assert ok == {"flagged": False, "reasons": []}
    assert cw.not_shot_flag(
        _result_with_metrics(v0_mps=7.9, coverage=1.0))["flagged"] is False


def test_van_v0_none_mot_minh_khong_no():
    """BRIEF chốt đúng 3 tín hiệu: V0 None mà motion_start CÓ ("không đủ
    frame đo V0") thì tín hiệu V0 không nổ — không bịa tín hiệu thứ tư."""
    cw = _cv_worker()
    out = cw.not_shot_flag(_result_with_metrics(v0_mps=None, coverage=0.95))
    assert out["flagged"] is False


def test_co_van_di_qua_api_hang_van_click_duoc(client, fq,
                                               _tmp_analyzer_dir):
    """JSON có suspect_not_shot → hàng GET mang nguyên cờ + reasons, và
    vẫn done + result_url (KHÔNG xoá/lọc — chỉ đổi cách hiển thị)."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    res = dict(RESULT_SAMPLE)
    res["suspect_not_shot"] = {
        "flagged": True,
        "reasons": ["V0 giải tích 0.27 m/s ngoài khoảng tin [1, 8] m/s"]}
    astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)

    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s1 = body["shots"][0]
    assert s1["status"] == "done"
    assert s1["suspect_not_shot"]["flagged"] is True
    assert "0.27" in s1["suspect_not_shot"]["reasons"][0]
    assert s1["result_url"].endswith("/shots/1/result")
    # kết quả trước A2 (không có key) — hàng không mọc cờ
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 2), RESULT_SAMPLE)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert "suspect_not_shot" not in body["shots"][1]


def test_fe_hang_van_xam_ly_do_van_click():
    """app.js: hàng cờ van dùng class RIÊNG .anv-suspect (không mượn
    .anv-err — ngữ nghĩa khác), nhãn "nghi không phải cú đánh", lý do từ
    server (reasons) nối thẳng; hàng done vẫn qua nhánh click chung."""
    js = _fe_src("app.js")
    assert "anv-suspect" in js
    assert "nghi không phải cú đánh" in js
    assert "suspect_not_shot" in js
    css = _fe_src("style.css")
    assert ".anv-suspect td" in css
    html = _fe_src("index.html")
    assert "nghi không phải cú đánh" in html


# ----------------------------- RMSE resim trong hàng bảng (lát A2 phần 2)

def _result_with_resim(sets):
    res = json.loads(json.dumps(RESULT_SAMPLE))
    res["resim"] = {"table_w_m": 1.27, "table_l_m": 2.54,
                    "ball_r_m": 0.028575, "sets": sets}
    return res


def test_rmse_bo_shotnet_vao_cot_bang(client, fq, _tmp_analyzer_dir):
    """rmse_mm của hàng = bộ shotnet (model đang chạy app), kèm nhãn
    rmse_set — bảng và JSON phải cùng một số (gate A2.3)."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    res = _result_with_resim({
        "shotnet": {"params": {"v0_mps": 2.31, "phi_deg": 200.8,
                               "a": -0.12, "b": 0.05},
                    "rmse_mm": 412.4, "n_points": 147, "t0_s": 0.45,
                    "t_align_s": 0.033,
                    "series": {"cue": [[0.45, 0.99, 0.586]]}},
        "analytic": {"params": {"v0_mps": 1.72, "phi_deg": 199.3,
                                "a": 0.0, "b": 0.0},
                     "rmse_mm": 734.2, "n_points": 147, "t0_s": 0.48,
                     "t_align_s": 0.0,
                     "series": {"cue": [[0.48, 0.99, 0.586]]}}})
    astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s1 = body["shots"][0]
    assert s1["rmse_mm"] == 412.4                 # bộ shotnet, KHÔNG trộn
    assert s1["rmse_set"] == "shotnet"
    # JSON thô giữ nguyên khối resim đầy đủ — hai nơi cùng nguồn
    raw = client.get(f"/api/analyzer/videos/{vid}/shots/1/result").json()
    assert raw["resim"]["sets"]["shotnet"]["rmse_mm"] == 412.4
    assert raw["resim"]["sets"]["analytic"]["rmse_mm"] == 734.2


def test_rmse_fallback_analytic_dan_nhan(client, fq, _tmp_analyzer_dir):
    """Vắng bộ shotnet (worker không ckpt) → fallback analytic NHƯNG
    rmse_set phải dán nhãn — "ghi rõ bộ nào, đừng trộn"."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    res = _result_with_resim({
        "shotnet": {"error": "kết quả không có khối shotnet"},
        "analytic": {"params": {"v0_mps": 1.72, "phi_deg": 199.3,
                                "a": 0.0, "b": 0.0},
                     "rmse_mm": 91.0, "n_points": 80, "t0_s": 0.5,
                     "t_align_s": 0.0, "series": {"cue": []}}})
    astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)
    s1 = client.get("/api/analyzer/shots",
                    params={"video": vid}).json()["shots"][0]
    assert s1["rmse_mm"] == 91.0 and s1["rmse_set"] == "analytic"
    # kết quả trước A2 (không resim) — không bịa số
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 2), RESULT_SAMPLE)
    s2 = client.get("/api/analyzer/shots",
                    params={"video": vid}).json()["shots"][1]
    assert "rmse_mm" not in s2


def test_fe_cot_rmse_hien_so_va_nhan_bo():
    js = _fe_src("app.js")
    assert "rmse_mm" in js and "rmse_set" in js
    html = _fe_src("index.html")
    assert "resim pooltool" in html


# --------------------------------- overlay.mp4 per cú (lát A2 phần 3)

def test_overlay_url_theo_file_tren_dia(client, fq, _tmp_analyzer_dir):
    """File overlay trên đĩa là nguồn sự thật: có file → hàng mọc
    overlay_url + endpoint trả video/mp4; không file → không url + 404
    tử tế (kết quả trước A2 không render hồi tố được)."""
    start_segment_worker(fq, _tmp_analyzer_dir)
    vid = post_video(client).json()["id"]
    wait_manifest(_tmp_analyzer_dir, vid)
    astore.write_json(
        astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
    p = astore.overlay_path(_tmp_analyzer_dir / vid, 1)
    p.write_bytes(b"\x00\x00\x00 ftypisomfake-overlay")

    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    s1, s2 = body["shots"][0], body["shots"][1]
    assert s1["overlay_url"].endswith(f"/videos/{vid}/shots/1/overlay")
    assert "overlay_url" not in s2
    r = client.get(f"/api/analyzer/videos/{vid}/shots/1/overlay")
    assert r.status_code == 200
    assert r.headers["content-type"] == "video/mp4"
    assert r.content.endswith(b"fake-overlay")
    r = client.get(f"/api/analyzer/videos/{vid}/shots/2/overlay")
    assert r.status_code == 404
    assert "hồi tố" in r.json()["detail"]


def test_segment_payload_mang_overlay_path(client, fq, _tmp_analyzer_dir):
    """Job analyze per cú phải nhận overlay_path — worker render TRONG job,
    trước khi clip bị xoá (bài học A1: clip mất là overlay mất)."""
    cw = _cv_worker()
    vdir = _tmp_analyzer_dir / ("d" * 32)
    assert str(astore.overlay_path(vdir, 3)).endswith("overlay_03.mp4")
    # nguồn enqueue trong segment_once: soi trực tiếp code path (worker
    # thật cần cv2/ffmpeg — transport đã có test riêng ở dưới)
    import inspect

    src = inspect.getsource(cw.segment_once)
    assert "overlay_path" in src


def test_fe_nut_phat_va_tai_overlay():
    js = _fe_src("app.js")
    assert "overlay_url" in js
    assert "anv-overlay-links" in js
    assert "stopPropagation" in js       # link không kích click-hàng mở JSON
    css = _fe_src("style.css")
    assert ".anv-overlay-links a" in css


# ------------------------------ trang chi tiết cú (lát A2 phần 4, §3.2)

def test_fe_trang_chi_tiet_du_manh():
    """index.html có đủ mảnh §3.2: video overlay + nút tải, bàn resim chồng
    (nét đứt vẽ canvas), bảng 2 cột (tái dụng #an-metrics builder), nút
    "Đánh lại cú"; app.js mở chi tiết từ click hàng, móc smoke riêng."""
    html = _fe_src("index.html")
    for i in ['id="and-detail"', 'id="and-back"', 'id="and-video"',
              'id="and-download"', 'id="and-replay"', 'id="and-metrics"',
              'id="and-caveat"', 'id="and-suspect"', 'id="and-resim"',
              'id="anv-list"', 'id="and-json"']:
        assert i in html, i
    js = _fe_src("app.js")
    for s in ["openAnvDetail", "renderAnvDetail", "closeAnvDetail",
              "drawAnvDetail", "anvChosenResim", "__andRenderMock"]:
        assert s in js, s


def test_fe_danh_lai_cu_tai_dung_animateShot_khong_animation_moi():
    """BRIEF A2 phần 4: nút "Đánh lại cú" phải đi qua ĐÚNG animateShot()
    sẵn có (không viết vòng requestAnimationFrame mới); thoát tab/đóng
    trang chi tiết bị chặn trong lúc animate — state sạch (bẫy BG25/26)."""
    js = _fe_src("app.js")
    i = js.index("async function anvReplay")
    body = js[i:js.index("\n}", i)]
    assert "animateShot(" in body
    assert "requestAnimationFrame" not in body     # không animation mới
    for fn in ["function exitAnalyzer", "function closeAnvDetail",
               "async function openAnvDetail"]:
        j = js.index(fn)
        head = js[j:js.index("\n}", j)]
        assert "S.animating" in head, fn


def test_fe_bang_2_cot_dung_chung_mot_builder():
    """Bảng thông số trang chi tiết đi qua fillAnMetrics duy nhất (một
    nguồn sự thật — từ A2b luồng 1-cú đã gỡ, chỉ còn trang chi tiết dùng)
    + hàng cờ height_comp / identifiable chỉ mọc ở chế độ flags."""
    js = _fe_src("app.js")
    assert js.count("function fillAnMetrics") == 1
    assert "fillAnMetrics(tbl, res, { flags: true })" in js      # chi tiết
    assert "Bù độ cao tâm bi" in js
    assert "identifiable" in js


# ------------------------------ xuất CSV/JSON danh sách cú (A2 phần 5)

def _seed_video_for_export(client, fq, base):
    start_segment_worker(fq, base)
    vid = post_video(client).json()["id"]
    wait_manifest(base, vid)
    res = json.loads(json.dumps(RESULT_SAMPLE))
    res["resim"] = {"table_w_m": 1.27, "table_l_m": 2.54,
                    "ball_r_m": 0.028575,
                    "sets": {"shotnet": {
                        "params": {"v0_mps": 2.31, "phi_deg": 200.8,
                                   "a": -0.12, "b": 0.05},
                        "rmse_mm": 412.4, "n_points": 147, "t0_s": 0.45,
                        "t_align_s": 0.033, "series": {"cue": []}}}}
    res["suspect_not_shot"] = {
        "flagged": True,
        "reasons": ["track chỉ phủ 45% thời lượng (< 80%)"]}
    astore.write_json(astore.shot_json_path(base / vid, 1), res)
    return vid


def test_export_csv_utf8_bom_du_hang_du_cot(client, fq, _tmp_analyzer_dir):
    """CSV: UTF-8 BOM (Excel không vỡ tiếng Việt), đủ 1 hàng/cú KỂ CẢ cú
    lỗi, cột van + RMSE; số TRÙNG với bảng (cùng _collect_shots)."""
    vid = _seed_video_for_export(client, fq, _tmp_analyzer_dir)
    r = client.get(f"/api/analyzer/videos/{vid}/export.csv")
    assert r.status_code == 200
    assert r.content.startswith(b"\xef\xbb\xbf")          # BOM utf-8-sig
    assert "attachment" in r.headers["content-disposition"]
    text = r.content.decode("utf-8-sig")
    lines = [ln for ln in text.split("\r\n") if ln]
    assert len(lines) == 1 + 3                            # header + 3 cú
    head = lines[0]
    for col in ["φ model", "RMSE resim (mm)", "nghi không phải cú đánh",
                "lý do van", "tin cậy model"]:
        assert col in head, col
    row1 = lines[1]
    assert "412.4" in row1 and "shotnet" in row1
    assert "có" in row1 and "45% thời lượng" in row1
    assert "xong" in row1
    # cú segmentation kết án vẫn CÓ HÀNG kèm lý do — không lọc
    assert "không phân tích được" in lines[3]
    assert "mất cảnh" in lines[3]


def test_export_json_trung_body_get_shots(client, fq, _tmp_analyzer_dir):
    vid = _seed_video_for_export(client, fq, _tmp_analyzer_dir)
    r = client.get(f"/api/analyzer/videos/{vid}/export.json")
    assert r.status_code == 200
    assert "attachment" in r.headers["content-disposition"]
    body = client.get("/api/analyzer/shots", params={"video": vid}).json()
    assert r.json() == body                # một nguồn — không thể lệch


def test_export_id_rac_khong_cham_file(client, fq):
    """id lạ bị chặn trước khi ghép đường dẫn: id sai dạng → 422; '../'
    bị starlette chuẩn hoá path nên rơi 404 — cả hai đều không đụng file
    (cùng khẩu vị test_video_id_rac A1)."""
    for vid in ["xyz", "A" * 32]:
        assert client.get(
            f"/api/analyzer/videos/{vid}/export.csv").status_code == 422
        assert client.get(
            f"/api/analyzer/videos/{vid}/export.json").status_code == 422
    assert client.get(
        "/api/analyzer/videos/../export.csv").status_code in (404, 422)


def test_fe_nut_xuat_csv_json():
    html = _fe_src("index.html")
    assert 'id="anv-csv"' in html and 'id="anv-json"' in html
    js = _fe_src("app.js")
    assert "/export.csv" in js and "/export.json" in js


# ------------------------------- transport worker (file-first, nếp BG29b)

def _cv_worker():
    import sys
    from pathlib import Path

    sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
    import cv_worker

    return cv_worker


def test_handle_analyze_ghi_file_ket_qua_ngay(fq, monkeypatch, tmp_path):
    """`result_path` trong payload → kết quả ra FILE ngay khi worker xong
    (điểm chốt BG29b). Job /api/analyze cũ (không result_path) không mọc
    file nào."""
    cw = _cv_worker()
    monkeypatch.setattr(cw, "analyze_once",
                        lambda *a, **k: dict(RESULT_SAMPLE))
    clip = tmp_path / "shot_01.mp4"
    clip.write_bytes(b"x")
    rp = tmp_path / "shot_01.json"
    reply = cw.handle_analyze(
        {"analyze_id": "vid00000000000000000000000000000as01",
         "clip_path": str(clip), "result_path": str(rp)},
        model=None, conf=0.3, predict_kw={})
    assert reply["ok"] is True
    assert json.loads(rp.read_text(encoding="utf-8")) == RESULT_SAMPLE
    assert not clip.exists()                  # clip per cú không đọng


def test_handle_analyze_loi_cung_ra_file(fq, monkeypatch, tmp_path):
    cw = _cv_worker()

    def boom(*a, **k):
        raise ValueError("Clip không còn trong thư mục tạm — upload lại.")
    monkeypatch.setattr(cw, "analyze_once", boom)
    rp = tmp_path / "shot_02.json"
    reply = cw.handle_analyze({"analyze_id": "a" * 35,
                               "result_path": str(rp)},
                              model=None, conf=0.3, predict_kw={})
    assert reply["ok"] is False
    data = json.loads(rp.read_text(encoding="utf-8"))
    assert data["error"] == "validation"
    assert "upload lại" in data["message"]


def test_handle_analyze_khong_result_path_khong_doi_hanh_vi(
        fq, monkeypatch, tmp_path):
    cw = _cv_worker()
    monkeypatch.setattr(cw, "analyze_once",
                        lambda *a, **k: dict(RESULT_SAMPLE))
    reply = cw.handle_analyze({"analyze_id": "b" * 32}, model=None,
                              conf=0.3, predict_kw={})
    assert reply["ok"] is True
    assert list(tmp_path.iterdir()) == []


def test_handle_segment_loi_ghi_manifest_error(fq, monkeypatch, tmp_path):
    """Segment hỏng vì input → manifest error ra ĐĨA (sống lâu hơn TTL) +
    video bị dọn."""
    cw = _cv_worker()

    def boom(*a, **k):
        raise ValueError("Không mở được video — cần MP4/MOV đọc được.")
    monkeypatch.setattr(cw, "segment_once", boom)
    vp = tmp_path / "video.mp4"
    vp.write_bytes(b"x")
    reply = cw.handle_segment(
        {"video_id": "c" * 32, "video_path": str(vp),
         "out_dir": str(tmp_path)}, model=None, conf=0.3, predict_kw={})
    assert reply["ok"] is False
    man = json.loads((tmp_path / "shots.json").read_text(encoding="utf-8"))
    assert man["status"] == "error"
    assert "Không mở được video" in man["message"]
    assert not vp.exists()
    st = jobqueue.get_video_status("c" * 32)
    assert st["status"] == "error"

# --------------- các test giữ lại từ test_analyze.py (endpoint gỡ ở A2b)
# Endpoint 1-cú POST/GET /api/analyze đã gỡ; nhóm dưới đây KHÔNG thuộc
# hợp đồng endpoint đó nên dời về đây thay vì xoá: van mật độ (hàm thuần
# cv_worker, BG29), probe FE (fillAnMetrics/caveat/chuẩn click sống trong
# trang chi tiết), và strictness của schema Analyze* (giờ là schema tài
# liệu của JSON per cú).


@pytest.mark.parametrize("density,raw,mong_doi", [
    (5.28, "high", "high"),      # trung bình train synth — không hạ
    (3.09, "high", "high"),      # cú 12 thật — trên ngưỡng, không hạ
    (2.00, "high", "high"),      # đúng p10 train (biên: chưa hạ)
    (1.99, "high", "medium"),    # dưới p10 → hạ 1 bậc
    (1.99, "medium", "low"),
    (1.01, "high", "low"),       # cú 11 thật — hạ 2 bậc
    (1.01, "medium", "low"),     # đã thấp thì không hạ dưới sàn
    (0.0, "low", "low"),
])
def test_van_mat_do_ha_cap_dung_bac(density, raw, mong_doi):
    assert _cv_worker().density_confidence(raw, density) == mong_doi


def test_van_mat_do_soan_dong_canh_bao_khi_va_chi_khi_ha():
    cw = _cv_worker()
    blk = {"confidence_raw": "high", "confidence": "low", "det_density": 1.01}
    note = cw.density_warning(blk)
    assert note is not None
    assert "1.01/frame" in note and "cao" in note and "thấp" in note
    assert "số model giữ nguyên" in note
    # không hạ → không có dòng nào (van im lặng khi mật độ đủ)
    assert cw.density_warning(
        {"confidence_raw": "high", "confidence": "high",
         "det_density": 3.09}) is None
    # worker cũ (không có confidence_raw) không được đẻ cảnh báo ma
    assert cw.density_warning({"confidence": "high"}) is None


def test_fe_hien_viec_ha_cap_tin_cay():
    """Probe nguồn FE: ô tin cậy phải NÓI RA là đã hạ và hạ vì cái gì —
    hạng tin tụt mà không giải thích thì người xem đọc thành 'model tệ'."""
    js = _fe_src("app.js")
    assert "confidence_raw" in js
    assert "det_density" in js
    assert "hạ từ" in js
    assert "/frame" in js


def test_fe_chuan_click_mep_nose_vai():
    """Probe nguồn FE (nếp #scan-go BG26): hướng dẫn chuẩn click BG31 phải
    nằm TĨNH trong index.html (một câu + hình minh hoạ), và bước chấm ①
    trong app.js nói cùng một chuẩn — click cao hơn mặt vải làm bù quá tay
    (BG30 đã đo)."""
    html = _fe_src("index.html")
    assert 'id="an-note"' in html
    assert "mép nose vải" in html
    assert "chấm miệng lỗ trên mặt gỗ" in html

    js = _fe_src("app.js")
    assert "mép nose vải" in js


def test_fe_hai_cot_va_caveat():
    """Probe nguồn FE (nếp #scan-go BG26): bảng 2 cột phải có đúng hai nhãn
    cột BRIEF 28, nhãn V0 phân biệt HAI THƯỚC (gậy vs bi đo), caveat model
    nằm TĨNH trong index.html (JS chỉ bật/tắt — không ai quên soạn nó; từ
    A2b caveat sống ở trang chi tiết #and-caveat) và app.js toggle theo
    `shotnet`."""
    js = _fe_src("app.js")
    assert "Model (ShotNet)" in js
    assert "Đo giải tích (đối chứng)" in js
    assert "V0 gậy (model)" in js
    assert "tốc độ bi đo được" in js
    assert "res.shotnet" in js
    assert "and-caveat" in js
    # ngưỡng bất đồng nằm ở bảng hằng, có cả hai trục φ + V0
    assert "AN_DIFF_PHI_DEG" in js and "AN_DIFF_V0_RATIO" in js

    html = _fe_src("index.html")
    assert 'id="and-caveat"' in html
    assert "chưa tinh chỉnh trên video thật" in html


def test_khoi_shotnet_gia_tri_la_khong_lot_schema():
    """Giá trị lạ trong khối shotnet (spin_vert ngoài 3 lớp) không được
    lọt qua schema Analyze* — schema là hợp đồng shape của JSON per cú."""
    import pydantic
    from app.schemas import AnalyzeShotnetOut

    with pytest.raises(pydantic.ValidationError):
        AnalyzeShotnetOut(model="x", v0_cue_mps=1.0, phi_deg=0.0, a=0.0,
                          b=0.0, spin_vert="topspin", spin_side="side-L",
                          identifiable_prob=0.5, confidence="high",
                          inference_ms=1.0, n_target_slots=1)


def test_height_comp_gia_tri_la_khong_lot_schema():
    import pydantic
    from app.schemas import AnalyzeHeightComp

    with pytest.raises(pydantic.ValidationError):
        AnalyzeHeightComp(on="maybe-la-gi-do", h_m="cao")


# --------------------- Analyzer là tab đầu + mặc định (A2b phần 3)

def test_fe_analyzer_tab_dau_va_mac_dinh():
    """A2b feedback #2: Analyzer là tab ĐẦU (trước Gợi ý, trước Bài tập) và
    active khi load; #analyzer hiện sẵn còn .controls/.legend của tab Gợi ý
    ẩn lúc boot — HTML tĩnh phải khớp S.anMode=true trong app.js (một nguồn
    sự thật trạng thái đầu, vùng bẫy init/state BG25/BG26)."""
    html = _fe_src("index.html")
    ia = html.index('id="tab-analyzer"')
    isg = html.index('id="tab-suggest"')
    idr = html.index('id="tab-drills"')
    assert ia < isg < idr
    assert '<button id="tab-analyzer" class="tab active">' in html
    assert '<button id="tab-suggest" class="tab">' in html
    assert '<button id="tab-drills" class="tab">' in html
    assert '<section id="analyzer">' in html          # KHÔNG hidden lúc boot
    assert '<section class="controls hidden">' in html
    assert '<section class="legend hidden">' in html
    js = _fe_src("app.js")
    assert "anMode: true" in js


def test_fe_deep_link_qr_vao_thang_tab_goi_y():
    """Deep-link CŨ ?qr=<token> (QR dán ở bàn) là lối vào luồng gợi ý —
    phải đáp xuống ĐÚNG tab Gợi ý dù Analyzer giờ là mặc định."""
    js = _fe_src("app.js")
    assert ('if (new URLSearchParams(location.search).get("qr")) '
            "exitAnalyzer();") in js
    # và exitAnalyzer vẫn chặn khi đang animate (A2 gate 4 giữ xanh)
    i = js.index("function exitAnalyzer")
    body = js[i:js.index("\nfunction ", i + 10)]
    assert "S.animating" in body


# ---------------- trang chi tiết A2b phần 4: replay quan sát + widget +
# toggle resim + số bi BallID trên bàn metric

def test_fe_danh_lai_cu_theo_quy_dao_quan_sat():
    """A2b (Danh chốt 13/08 tối): "Đánh lại cú" animate theo quỹ đạo QUAN
    SÁT (smoothTrack trên res.track), KHÔNG còn chạy series resim; vẫn
    animateShot() nguyên trạng."""
    js = _fe_src("app.js")
    i = js.index("async function anvReplay")
    body = js[i:js.index("\n}", i)]
    assert "smoothTrack(res.track)" in body
    assert "anvChosenResim" not in body           # không còn chạy resim
    assert "animateShot(" in body
    assert "requestAnimationFrame" not in body
    html = _fe_src("index.html")
    assert "Đánh lại cú (quỹ đạo quan sát)" in html


def test_fe_widget_thong_tin_cu_so_shotnet():
    """Widget khi đánh lại: chấm điểm đầu cơ (a/b) + thanh lực V0, số từ bộ
    SHOTNET có dán nhãn bộ; id/class RIÊNG (.and-spin-dot/.and-power-bar)
    vì .spin-dot/.power-bar bị initPowerBar + updateShotViz quét toàn cục
    (bài học .btn-play). Cùng quy ước chấm spin tab gợi ý (a=+1 mép TRÁI:
    cx = 50 − a·46)."""
    html = _fe_src("index.html")
    for i in ['id="and-shotviz"', 'id="and-spin-ball"', 'id="and-power-bar"',
              'id="and-power-val"', 'id="and-spin-cap"',
              'id="and-viz-label"', 'class="and-spin-dot"']:
        assert i in html, i
    js = _fe_src("app.js")
    assert "function fillAndShotviz" in js
    assert "sn.v0_cue_mps" in js and "50 - sn.a * 46" in js \
        and "50 - sn.b * 46" in js
    assert "số bộ shotnet" in js and "analytic không có a/b" in js
    # widget KHÔNG mượn class bị quét toàn cục
    assert 'class="and-power-bar"' in html
    i0 = html.index('id="and-shotviz"')
    widget = html[i0:html.index('id="and-suspect"', i0)]
    assert 'class="power-bar"' not in widget
    assert 'class="spin-dot"' not in widget


def test_fe_toggle_resim_mac_dinh_tat_mot_hang_config():
    """Lớp resim = toggle "So với mô phỏng" MẶC ĐỊNH TẮT (path quan sát ↔
    resim đang lệch V0-thang); bật-mặc-định-lại là MỘT hằng
    AND_RESIM_DEFAULT_ON. Bật mới vẽ nét đứt + RMSE (drawAnvDetail gate
    theo showResim; dòng RMSE sync theo toggle)."""
    js = _fe_src("app.js")
    assert "const AND_RESIM_DEFAULT_ON = false;" in js
    assert "showResim: AND_RESIM_DEFAULT_ON" in js
    i = js.index("function drawAnvDetail")
    body = js[i:js.index("\nfunction ", i + 10)]
    assert "ANV.detail.showResim ? anvChosenResim(res) : null" in body
    assert "function syncAndResim" in js
    html = _fe_src("index.html")
    assert 'id="and-resim-toggle"' in html
    assert "So với mô phỏng" in html
    # checkbox không được checked sẵn trong HTML — trạng thái từ JS
    i0 = html.index('id="and-resim-toggle"')
    tag = html[html.rindex("<input", 0, i0):html.index(">", i0)]
    assert "checked" not in tag


def test_fe_bi_ban_metric_mau_so_theo_ballid():
    """Bi trên bàn metric tô màu + số theo `number` (BallID) qua drawBall
    của app — kết quả cũ không có trường thì bi xám như trước (không bịa
    số); bi cái tĩnh không vẽ đè lên bi đang animate."""
    js = _fe_src("app.js")
    i = js.index("function drawAnObserved")
    body = js[i:js.index("\nfunction ", i + 10)]
    assert "b.number" in body
    assert "drawBall(" in body
    assert "#8d9aa8" in body                       # fallback bi xám còn đó
    assert 'ANIM && b.type === "cue"' in body      # không bi ma đôi khi replay


def test_worker_match_ballid_vao_balls_init():
    """Hàm matching thuần: gắn số vào entry gần nhất trong tol, conf cao
    ưu tiên, không đè entry đã có số, ngoài tol/None thì bỏ."""
    cw = _cv_worker()
    balls = [
        {"x_m": 0.50, "y_m": 0.30, "type": "cue", "conf": 0.9},
        {"x_m": 1.00, "y_m": 1.50, "type": "ball", "conf": 0.8},
        {"x_m": 0.20, "y_m": 0.40, "type": "ball", "conf": 0.7},
        {"x_m": 0.60, "y_m": 2.00, "type": "ball", "conf": 0.6},
    ]
    numbered = [
        (1.001, 1.501, 9, 0.91),      # khớp entry 2 (lệch ~1mm)
        (0.201, 0.399, 3, 0.85),      # khớp entry 3
        (0.202, 0.401, 5, 0.30),      # tranh entry 3 — conf thấp hơn, thua
                                      # (entry đã có số, nearest khác quá xa)
        (0.60, 2.20, 7, 0.99),        # lệch 20cm > tol — bỏ
        (1.10, 0.58, None, 0.0),      # màu không nhận ra — bỏ
    ]
    n = cw.match_ballid_to_init(balls, numbered, 0.03)
    assert n == 2
    assert balls[1]["number"] == 9 and balls[1]["number_conf"] == 0.91
    assert balls[2]["number"] == 3
    assert "number" not in balls[3]
    assert "number" not in balls[0]                # cue không bao giờ gắn


def test_schema_balls_init_number_optional():
    """AnalyzeBallInit: number/number_conf optional — kết quả cũ (không
    trường) lẫn mới (có số) đều qua; exclude_none không mọc key ma."""
    from app.schemas import AnalyzeBallInit

    old = AnalyzeBallInit(x_m=1.0, y_m=2.0, type="ball", conf=0.9)
    assert "number" not in old.model_dump(exclude_none=True)
    new = AnalyzeBallInit(x_m=1.0, y_m=2.0, type="ball", conf=0.9,
                          number=9, number_conf=0.8)
    assert new.model_dump(exclude_none=True)["number"] == 9