Spaces:
Sleeping
Sleeping
| """Contract cách ly tab trên MOBILE (14/08/2026 — bàn giao 2). | |
| Bug Danh thấy trên điện thoại thật (bản Space vừa deploy): mở app vào thẳng | |
| tab Analyzer — tab ĐẦU + MẶC ĐỊNH từ ``3740e17`` — mà đáy màn hình vẫn là UI | |
| tab Gợi ý (badge bi mục tiêu, chấm đầu cơ, thanh lực, nút Gợi ý/Đánh). | |
| Nguyên nhân: ``#quickbar`` nằm NGOÀI ``#panel``. ``setTab()`` chỉ ẩn/hiện các | |
| section BÊN TRONG ``#panel`` (``.controls``/``.legend``/``#drills``/ | |
| ``#analyzer``) nên không có gì với tới quickbar. Desktop không lộ vì | |
| ``#quickbar`` là ``display:none`` ngoài media query mobile — đúng vế "desktop | |
| không bị" trong báo cáo. | |
| Hai vế bị khoá ở đây, vì mỗi vế hỏng một kiểu: | |
| * **Trạng thái TĨNH lúc boot.** ``setTab()`` KHÔNG chạy khi mở app bình | |
| thường — nó chỉ chạy khi bấm tab, hoặc khi deep-link ``?qr=`` gọi | |
| ``exitAnalyzer()``. Trạng thái đầu do chính HTML tĩnh mang (nếp | |
| ``.controls``/``.legend`` có sẵn ``hidden``: MỘT nguồn sự thật, không flash | |
| tab). Sửa mỗi JS là bug còn nguyên ở đúng ca Danh gặp — mở app. | |
| * **Lối ra phải ở lại.** Trên mobile ``#panel`` là ``display:none`` cho tới | |
| khi "Chi tiết ▴" mở sheet. Ẩn cả ``#quickbar`` cho gọn là nhốt người dùng | |
| trong tab hiện tại — không còn nút nào mở panel để đổi tab. | |
| Test đọc FILE THẬT trong ``app/static/`` (nếp ``test_static_cache.py`` / | |
| ``test_scan.py``): repo không có JS runtime, nên cái khoá được là markup + | |
| nguồn ``setTab``. Vế mắt người (điện thoại thật) vẫn do smoke lo. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from pathlib import Path | |
| import pytest | |
| STATIC = Path(__file__).resolve().parents[1] / "app" / "static" | |
| # UI thuộc tab Gợi ý sống trong quickbar — phải biến mất ở tab Analyzer. | |
| # #qb-target = song sinh của #panel-target trong .controls (badge bi mục tiêu). | |
| QB_SHOT_UI = ["qb-spin", "qb-info", "qb-go", "qb-play"] | |
| # Điều hướng — PHẢI ở lại mọi tab (lối duy nhất mở #panel khi sheet đóng). | |
| QB_NAV = ["qb-drills", "qb-detail"] | |
| def html() -> str: | |
| return (STATIC / "index.html").read_text(encoding="utf-8") | |
| def js() -> str: | |
| return (STATIC / "app.js").read_text(encoding="utf-8") | |
| def _quickbar(html: str) -> str: | |
| m = re.search(r'<nav id="quickbar">(.*?)</nav>', html, re.S) | |
| assert m, "không thấy <nav id=\"quickbar\"> trong index.html" | |
| return m.group(1) | |
| def _classes(block: str, tag: str, el_id: str) -> set[str]: | |
| m = re.search(rf'<{tag}[^>]*\bid="{el_id}"[^>]*>', block) | |
| assert m, f"không thấy #{el_id} trong quickbar" | |
| cls = re.search(r'class="([^"]*)"', m.group(0)) | |
| return set(cls.group(1).split()) if cls else set() | |
| def _settab_body(js: str, *, code_only: bool = False) -> str: | |
| m = re.search(r"function setTab\(mode\) \{(.*?)\n\}", js, re.S) | |
| assert m, "không thấy function setTab(mode) trong app.js" | |
| body = m.group(1) | |
| if code_only: | |
| # bỏ comment: thân hàm giải thích vì sao KHÔNG đụng .qb-settings, mà | |
| # chính lời giải thích đó lại chứa tên id đang muốn kiểm là vắng mặt | |
| body = re.sub(r"//[^\n]*", "", body) | |
| return body | |
| def test_boot_tinh_an_san_ui_goi_y_cua_quickbar(html): | |
| """Boot = tab Analyzer, mà setTab() không chạy lúc mở app → markup tĩnh | |
| phải tự mang trạng thái ẩn, y như .controls/.legend.""" | |
| qb = _quickbar(html) | |
| assert "hidden" in _classes(qb, "span", "qb-target"), \ | |
| "#qb-target thiếu .hidden ở markup tĩnh — mở app trên mobile là badge " \ | |
| "bi mục tiêu của tab Gợi ý hiện ngay giữa tab Analyzer" | |
| m = re.search(r'<div[^>]*\bclass="([^"]*\bqb-main\b[^"]*)"', qb) | |
| assert m, "không thấy hàng .qb-main trong quickbar" | |
| assert "hidden" in m.group(1).split(), \ | |
| "hàng .qb-main thiếu .hidden ở markup tĩnh — spin/lực/Gợi ý/Đánh hiện " \ | |
| "ngay lúc mở app ở tab Analyzer (đúng bug Danh báo 14/08)" | |
| def test_loi_ra_cua_quickbar_khong_bao_gio_bi_an(html, js): | |
| """Trên mobile #panel là display:none tới khi sheet mở — hàng .qb-settings | |
| là lối duy nhất mở panel để đổi tab. Ẩn nó là app cụt đường.""" | |
| qb = _quickbar(html) | |
| for el_id in QB_NAV: | |
| assert "hidden" not in _classes(qb, "button", el_id), \ | |
| f"#{el_id} bị ẩn tĩnh — không còn lối mở #panel trên mobile" | |
| body = _settab_body(js, code_only=True) | |
| for token in ("qb-settings", *QB_NAV): | |
| assert token not in body, \ | |
| f"setTab() đụng tới {token} — điều hướng phải sống ở mọi tab" | |
| def test_settab_an_hien_ui_goi_y_theo_tab(js): | |
| """Vế động: bấm qua lại tab phải kéo theo quickbar, không chỉ đúng lúc boot.""" | |
| body = _settab_body(js) | |
| assert 'mode === "analyzer"' in body | |
| for sel in ('$("#qb-target")', '$(".qb-main")'): | |
| assert f'{sel}.classList.toggle("hidden"' in body, \ | |
| f"setTab() không toggle {sel} — đổi tab xong quickbar giữ nguyên" | |
| def test_moi_widget_goi_y_cua_quickbar_nam_trong_khoi_bi_an(html): | |
| """Khoá cấu trúc cho lần sau: widget gợi ý mới mà lỡ đặt vào hàng | |
| .qb-settings thì nó nằm ngoài khối bị ẩn → bug quay lại y hệt.""" | |
| qb = _quickbar(html) | |
| cut = qb.find("qb-main") | |
| assert cut > 0 | |
| settings_row, main_row = qb[:cut], qb[cut:] | |
| for el_id in QB_SHOT_UI: | |
| assert f'id="{el_id}"' in main_row, \ | |
| f"#{el_id} không nằm trong hàng .qb-main — sẽ không bị ẩn theo tab" | |
| for el_id in QB_NAV: | |
| assert f'id="{el_id}"' in settings_row, \ | |
| f"#{el_id} rơi vào .qb-main — lối ra sẽ bị ẩn cùng UI gợi ý" | |
| # ---------------------------------- hàng quickbar của tab Analyzer (14/08) | |
| # Vế ĐỐI XỨNG của mọi test trên: tab Gợi ý giấu UI của nó ở tab Analyzer, thì | |
| # tab Analyzer cũng phải giấu UI của nó ở nơi khác. Hàng .qb-an mang thông số | |
| # cú đang mở + nút đánh lại — chỉ có nghĩa khi ĐANG mở một cú. | |
| QB_AN_UI = ["qban-spin", "qban-info", "qban-replay"] | |
| def test_hang_analyzer_an_san_luc_boot(html): | |
| """Mở app = tab Analyzer nhưng ở màn DANH SÁCH, chưa mở cú nào → không có | |
| thông số nào để hiện. setTab() không chạy lúc boot nên markup phải tự | |
| mang .hidden (cùng lý do với .qb-main).""" | |
| qb = _quickbar(html) | |
| m = re.search(r'<div[^>]*\bclass="([^"]*\bqb-an\b[^"]*)"', qb) | |
| assert m, "không thấy hàng .qb-an trong quickbar" | |
| assert "hidden" in m.group(1).split(), \ | |
| "hàng .qb-an thiếu .hidden ở markup tĩnh — mở app là thấy widget cú " \ | |
| "rỗng ở đáy màn hình" | |
| def test_hang_analyzer_khong_muon_class_co_binding(html): | |
| """Bài học .btn-play: .power-bar/.spin-dot bị updateShotViz quét toàn cục | |
| (tab Gợi ý), .btn-go/.btn-play bị syncButtons ép label + disabled. Hàng | |
| Analyzer PHẢI dùng class riêng, trừ .and-* là cố ý dùng chung với widget | |
| trang chi tiết để một hàm nuôi cả hai.""" | |
| qb = _quickbar(html) | |
| m = re.search(r'<div[^>]*\bclass="[^"]*\bqb-an\b[^"]*".*?</div>\s*</div>', | |
| qb, re.S) | |
| assert m, "không cắt được khối .qb-an" | |
| row = m.group(0) | |
| # so theo TOKEN class, không phải substring: "and-power-bar" chứa | |
| # "power-bar" nhưng là class khác hẳn | |
| tokens = {t for attr in re.findall(r'class="([^"]*)"', row) | |
| for t in attr.split()} | |
| for bad in ("power-bar", "spin-dot", "btn-go", "btn-play"): | |
| assert bad not in tokens, \ | |
| f"hàng .qb-an mượn .{bad} — sẽ bị quét chéo" | |
| # ...nhưng PHẢI mang .and-* để fillAndShotviz + initPowerBar nuôi nó | |
| assert "and-power-bar" in row and "and-spin-dot" in row, \ | |
| "hàng .qb-an không mang class .and-* — thanh lực sẽ không có nấc và " \ | |
| "chấm spin không bao giờ được cập nhật" | |
| def test_sync_hang_analyzer_goi_o_moi_cho_trang_thai_doi(js): | |
| """Hàng chỉ đúng nếu được đồng bộ ở CẢ 4 chỗ: đổi tab, mở cú, đóng cú, và | |
| sau khi animate xong. Thiếu chỗ nào là hàng kẹt lại sai trạng thái.""" | |
| assert "function syncQbAnalyzer()" in js | |
| for fn in ("setTab", "renderAnvDetail", "closeAnvDetail", "anvReplay"): | |
| m = re.search(rf"function {fn}\(.*?\n\}}", js, re.S) | |
| assert m, f"không thấy function {fn} trong app.js" | |
| assert "syncQbAnalyzer()" in m.group(0), \ | |
| f"{fn}() không gọi syncQbAnalyzer() — hàng quickbar Analyzer sẽ " \ | |
| f"kẹt lại trạng thái cũ" | |
| def test_danh_lai_cu_dong_sheet(js): | |
| """Trên mobile #panel là bottom sheet CHE BÀN — không đóng thì cú chạy | |
| sau lưng người xem. Cả hai nút (trong sheet + quickbar) đi chung | |
| anvReplay nên chỉ cần một chỗ, nhưng phải có.""" | |
| m = re.search(r"async function anvReplay\(\) \{(.*?)\n\}", js, re.S) | |
| assert m, "không thấy function anvReplay() trong app.js" | |
| assert "closeSheet();" in m.group(1) | |
| assert '$("#qban-replay").addEventListener("click", anvReplay);' in js | |
| def test_fill_shotviz_nuoi_ca_hai_widget(js): | |
| """Một nguồn số cho widget trang chi tiết VÀ hàng quickbar — nếu quay lại | |
| ghi theo id đơn lẻ thì hai chỗ hiện hai con số khác nhau.""" | |
| m = re.search(r"function fillAndShotviz\(sn\) \{(.*?)\n\}", js, re.S) | |
| assert m, "không thấy function fillAndShotviz trong app.js" | |
| body = m.group(1) | |
| assert 'all(".and-power-bar")' in body and 'all(".and-spin-dot")' in body, \ | |
| "fillAndShotviz ghi theo id đơn lẻ — hàng quickbar sẽ không được nuôi" | |
| def test_deep_link_qr_van_dap_xuong_tab_goi_y(js): | |
| """Hành vi chốt từ A2b: QR dán ở bàn là lối vào luồng gợi ý — deep-link | |
| phải tự thoát tab Analyzer, không bắt người chơi bấm tab. | |
| Liên quan tới quickbar vì nó là đường DUY NHẤT trả UI gợi ý về sau khi | |
| markup tĩnh ẩn sẵn: ``exitAnalyzer() → setTab("suggest")``. Cắt mắt xích | |
| này là ``?qr=`` mở ra một tab Gợi ý cụt quickbar trên mobile.""" | |
| assert 'if (new URLSearchParams(location.search).get("qr")) exitAnalyzer();' in js | |
| m = re.search(r"function exitAnalyzer\(\) \{(.*?)\n\}", js, re.S) | |
| assert m, "không thấy function exitAnalyzer() trong app.js" | |
| assert 'setTab("suggest")' in m.group(1), \ | |
| "exitAnalyzer() không còn gọi setTab(\"suggest\") — quickbar sẽ kẹt ở " \ | |
| "trạng thái ẩn của tab Analyzer" | |