Spaces:
Sleeping
Sleeping
| """Bất biến sim → validate: mọi trạng thái `simulate_shot_multi` sinh ra phải | |
| được `validate_full` chấp nhận (bug 27/07/2026, tái phát 28/07/2026). | |
| Biên cũ `[BALL_R, w−BALL_R]` giả định tâm bi không bao giờ lấn vào trong đường | |
| băng. Sai: bi dừng trong HÀM LỖ có tâm lọt vào trong biên đó, và đấy là vị trí | |
| vật lý hợp lệ. Hệ quả không nằm ở script eval — `autoPlay()` của FE làm đúng | |
| vòng lặp `applyFinal → recommend → POST`, nên bản chạy trên HF Space trả 422 | |
| giữa chừng khi auto-play trọn ván. | |
| Bản vá 27/07 nới đúng `BALL_R` mỗi phía (biên thành `[0, w] × [0, l]`) với lập | |
| luận "dư ~5× so với mức đo được". 28/07 gặp trạng thái vượt **29.7 mm** → | |
| headroom chọn theo mẫu đã thấy không bao giờ chứng minh được là đã chạm đáy. | |
| Biên hiện tại là ENVELOPE HÌNH HỌC: hộp bao của (mặt bàn ∪ mọi đường tròn lỗ), | |
| dẫn từ `table.pockets` của chính bàn đang mô phỏng — xem | |
| `envs.position_env.table_bounds`. Bi chưa bị ăn không ra ngoài hộp đó được, nên | |
| không có lần nới thứ ba. | |
| Ba toạ độ trong case 1-2-6 là số THẬT bắt được tối 27/07 và tối 28/07, chép | |
| nguyên không làm tròn — làm tròn là mất chính cái sai số mà test này canh. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pytest | |
| from poolcoach_rl.envs.position_env import BALL_R, table_bounds | |
| from poolcoach_rl.recommend.core import validate_full | |
| from conftest import EnvStub | |
| # Bi hợp lệ ở giữa bàn, để ghép cùng bi đang thử mà không đụng luật chồng bi. | |
| CUE_MID = np.array([0.45, 0.60]) | |
| # Specs lỗ của `pt.Table.default()` — ĐỌC RA từ pooltool thật, không chọn tay: | |
| # for name, p in pt.Table.default().pockets.items(): | |
| # print(name, p.center[:2], p.radius) | |
| # Chép vào đây vì conftest cố ý nhét stub pooltool rỗng (test kiểm contract, | |
| # không kiểm vật lý) nên test không dựng nổi bàn thật. | |
| TABLE_W, TABLE_L = 0.9906, 1.9812 | |
| TABLE_POCKET_SPECS = [ | |
| ((-0.0294857, -0.0294857), 0.062), # lb góc dưới-trái | |
| ((-0.0685000, 0.9906000), 0.0645), # lc giữa-trái | |
| ((-0.0294857, 2.0106857), 0.062), # lt góc trên-trái | |
| ((1.0200857, -0.0294857), 0.062), # rb góc dưới-phải | |
| ((1.0591000, 0.9906000), 0.0645), # rc giữa-phải | |
| ((1.0200857, 2.0106857), 0.062), # rt góc trên-phải | |
| ] | |
| class FakePocket: | |
| """Shape tối thiểu mà `table_bounds` cần: `.center` + `.radius`.""" | |
| def __init__(self, center, radius): | |
| self.center = center | |
| self.radius = radius | |
| def _pockets(specs): | |
| return [FakePocket(c, r) for c, r in specs] | |
| # --------------------------------------------- trạng thái sim sinh ra: NHẬN | |
| def test_case1_ban_chua_bi_dung_trong_ham_lo_goc_tren_trai(env_stub): | |
| """bi 7 (0.02617662835464931, 1.9593260782344746) — hụt 2.4 mm so với | |
| biên gốc ở góc trên-trái. Bắt được khi oracle chạy eval_runout ván 3.""" | |
| balls = {"cue": CUE_MID, | |
| "7": np.array([0.02617662835464931, 1.9593260782344746])} | |
| validate_full(env_stub, balls) # không raise | |
| def test_case2_ban_chua_bi_dung_trong_ham_lo_giua_phai(env_stub): | |
| """bi 8 (0.9678554302996148, 0.9514915281161108) — vượt 5.9 mm so với | |
| biên gốc ở lỗ giữa-phải. Bắt được khi hybrid γ=0.6 chạy ván 34.""" | |
| balls = {"cue": CUE_MID, | |
| "8": np.array([0.9678554302996148, 0.9514915281161108])} | |
| validate_full(env_stub, balls) # không raise | |
| def test_case6_bi_cai_dung_trong_ham_lo_x_am(env_stub): | |
| """cue (-0.0010683236740548342, 1.9491727149488998) — x ÂM 1.07 mm, tức | |
| vượt 29.7 mm so với biên gốc `[BALL_R, w−BALL_R]`. Đây là toạ độ đã giết | |
| `eval_runout --rand-seed 2` ở ván 12 tối 28/07, TRÊN bản vá 27/07: bằng | |
| chứng rằng nới hằng số theo mẫu quan sát được là cách làm hụt được lần nữa. | |
| Bi cái nằm ở hàm lỗ góc trên-trái; tâm lỗ đó là (−0.0295, 2.0107) nên toạ | |
| độ này thừa sức nằm trong đường tròn lỗ — không phải input rác. | |
| """ | |
| balls = {"cue": np.array([-0.0010683236740548342, 1.9491727149488998]), | |
| "1": CUE_MID} | |
| validate_full(env_stub, balls) # không raise | |
| # ------------------------------------------------------- input rác: TỪ CHỐI | |
| def test_case3_van_tu_choi_input_rac_that(env_stub, xy, mo_ta): | |
| """Envelope hình học KHÔNG được rộng tới mức nuốt luôn toạ độ vô nghĩa: | |
| đây vẫn là lớp chắn duy nhất giữa FE và tầng sim (BE trả 422 từ chính | |
| message này). | |
| Bộ toạ độ ĐÃ ĐỔI so với bản trước, và đây là dự kiến chứ không phải hồi | |
| quy: ba toạ độ cũ `(-0.05, 0.60)` · `(0.9906+0.05, 0.60)` · `(0.45, -0.01)` | |
| được chọn để vượt biên CŨ `[0, w] × [0, l]`, mà cả ba đều NẰM TRONG | |
| envelope mới (x ∈ [−0.133, 1.124], y ∈ [−0.091, 2.073]). Cách sửa đúng là | |
| đổi toạ độ, KHÔNG phải thu hẹp envelope cho test cũ xanh lại — làm thế là | |
| quay lại đúng vòng lặp nới-rồi-hụt mà bản vá này đang thoát ra. | |
| """ | |
| balls = {"cue": CUE_MID, "3": np.array(xy)} | |
| with pytest.raises(ValueError, match="ngoài bàn"): | |
| validate_full(env_stub, balls) | |
| def test_case7_tu_choi_gia_tri_khong_huu_han(env_stub, xy, mo_ta): | |
| """NaN/inf phải bị từ chối bằng message RIÊNG. | |
| Mọi so sánh với NaN đều trả False, nên `not (lo <= x <= hi)` là True và NaN | |
| rơi thẳng vào nhánh "ngoài bàn" — message đó dẫn người debug đi tìm lỗi toạ | |
| độ trong khi lỗi thật là dữ liệu hỏng. Tệ hơn: `np.linalg.norm` với NaN trả | |
| NaN, `NaN < 2R` là False, nên NaN cũng lọt luôn cả kiểm chồng bi. | |
| """ | |
| balls = {"cue": CUE_MID, "3": np.array(xy)} | |
| with pytest.raises(ValueError, match="không phải số hữu hạn"): | |
| validate_full(env_stub, balls) | |
| with pytest.raises(ValueError, match="không phải số hữu hạn"): | |
| validate_full(env_stub, {"cue": np.array(xy), "3": CUE_MID}) | |
| # ------------------------------------------------------------ luật chồng bi | |
| def test_case4_hai_bi_tua_nhau_dung_2R_khong_raise(env_stub): | |
| """Sau sim, hai bi tựa vào nhau cách nhau ĐÚNG 2R. So sánh `< 2R` trần | |
| trụi thì sai số float một chiều là raise — OVERLAP_EPS chính là để đây.""" | |
| a = np.array([0.45, 0.60]) | |
| balls = {"cue": a, "3": a + np.array([2 * BALL_R, 0.0])} | |
| validate_full(env_stub, balls) # không raise | |
| def test_case5_chong_bi_that_van_raise(env_stub): | |
| """0.9 × 2R = lệch 5.7 mm — chồng bi thật, không phải sai số float.""" | |
| a = np.array([0.45, 0.60]) | |
| balls = {"cue": a, "3": a + np.array([0.9 * 2 * BALL_R, 0.0])} | |
| with pytest.raises(ValueError, match="chồng lên nhau"): | |
| validate_full(env_stub, balls) | |
| # ------------------------------- envelope ĐƯỢC DẪN RA, không phải gõ tay | |
| def test_case8_envelope_bao_tron_moi_duong_tron_lo(): | |
| """Tính chất ĐỊNH NGHĨA của envelope, kiểm trên một bàn GIẢ có hình học | |
| khác hẳn bàn thật: mọi đường tròn lỗ phải nằm TRỌN trong hộp, và hộp phải | |
| chứa cả mặt bàn. | |
| Bàn giả chứ không phải bàn thật có chủ đích: một cài đặt trả hằng số của | |
| bàn mặc định sẽ qua được test trên bàn thật, nhưng trượt ở đây. | |
| """ | |
| w, l = 2.0, 3.0 | |
| specs = [((-0.10, -0.20), 0.15), ((-0.30, 1.50), 0.25), | |
| ((2.40, -0.05), 0.10), ((2.10, 3.30), 0.20)] | |
| pks = _pockets(specs) | |
| lo_x, hi_x, lo_y, hi_y = table_bounds(w, l, pks) | |
| for (cx, cy), r in specs: | |
| assert lo_x <= cx - r and cx + r <= hi_x, f"lỗ ({cx},{cy}) lòi theo x" | |
| assert lo_y <= cy - r and cy + r <= hi_y, f"lỗ ({cx},{cy}) lòi theo y" | |
| assert lo_x <= 0.0 and hi_x >= w | |
| assert lo_y <= 0.0 and hi_y >= l | |
| def test_case9_envelope_la_hop_bao_CHAT_khong_phai_hop_nao_do_rong_hon(): | |
| """Mỗi cạnh phải CHẠM đúng một ràng buộc — hoặc mép bàn, hoặc mép một | |
| đường tròn lỗ. "Chứa hết" + "chạm" mới đặc trưng được hộp bao nhỏ nhất; | |
| chỉ "chứa hết" thì một hộp nới bừa cũng qua, và nới bừa chính là thứ bản | |
| vá này đang bỏ.""" | |
| w, l = 2.0, 3.0 | |
| specs = [((-0.10, -0.20), 0.15), ((-0.30, 1.50), 0.25), | |
| ((2.40, -0.05), 0.10), ((2.10, 3.30), 0.20)] | |
| lo_x, hi_x, lo_y, hi_y = table_bounds(w, l, _pockets(specs)) | |
| def cham(v, moc): | |
| return any(abs(v - m) < 1e-12 for m in moc) | |
| assert cham(lo_x, [0.0] + [cx - r for (cx, _cy), r in specs]) | |
| assert cham(hi_x, [w] + [cx + r for (cx, _cy), r in specs]) | |
| assert cham(lo_y, [0.0] + [cy - r for (_cx, cy), r in specs]) | |
| assert cham(hi_y, [l] + [cy + r for (_cx, cy), r in specs]) | |
| def test_case10_envelope_phan_ung_voi_ban_kinh_lo(): | |
| """Nới bán kính một lỗ thì hộp phải rộng ra đúng phần nới. | |
| Đây là test bắt được kiểu hồi quy nguy hiểm nhất: ai đó thay `table_bounds` | |
| bằng một bộ hằng số (hoặc bằng `[0, w] × [0, l]` cộng slack) thì hai test | |
| trên còn có thể lách, test này thì không. | |
| """ | |
| w, l = 2.0, 3.0 | |
| specs = [((-0.10, 1.0), 0.15), ((2.10, 1.0), 0.15)] | |
| lo0, hi0, _, _ = table_bounds(w, l, _pockets(specs)) | |
| grown = [((-0.10, 1.0), 0.25), ((2.10, 1.0), 0.15)] | |
| lo1, hi1, _, _ = table_bounds(w, l, _pockets(grown)) | |
| assert lo1 == pytest.approx(lo0 - 0.10) | |
| assert hi1 == pytest.approx(hi0) | |
| def test_case11_bounds_cua_EnvStub_khop_ban_that(): | |
| """Hằng số `EnvStub._bounds` phải ĐÚNG BẰNG `table_bounds` trên specs lỗ | |
| của `pt.Table.default()`. | |
| Stub phải chép cứng 4 số (conftest không có pooltool thật), nên test này là | |
| thứ duy nhất giữ nó khỏi trôi khỏi bàn thật khi specs bàn đổi. | |
| """ | |
| want = table_bounds(TABLE_W, TABLE_L, _pockets(TABLE_POCKET_SPECS)) | |
| assert EnvStub._bounds == pytest.approx(want, abs=1e-6) | |
| # và khớp bộ số Cowork tính tay trong BRIEF 29/07 (4 chữ số) | |
| assert EnvStub._bounds == pytest.approx((-0.1330, 1.1236, | |
| -0.0915, 2.0727), abs=5e-5) | |
| def test_case12_envelope_moi_khong_bao_gio_hep_hon_bien_cu(): | |
| """Bản vá chỉ được NỚI, không được siết: mọi trạng thái biên cũ | |
| `[0, w] × [0, l]` chấp nhận thì biên mới cũng phải chấp nhận. | |
| Đây là lập luận đứng sau gate chống drift B3 — nếu envelope siết ở một | |
| hướng nào đó thì `eval_runout` có thể đổi kết quả một ván mà không ai ngờ. | |
| """ | |
| lo_x, hi_x, lo_y, hi_y = EnvStub._bounds | |
| assert lo_x <= 0.0 and hi_x >= EnvStub.w | |
| assert lo_y <= 0.0 and hi_y >= EnvStub.l | |