File size: 10,992 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
"""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"]


@pytest.fixture(scope="module")
def html() -> str:
    return (STATIC / "index.html").read_text(encoding="utf-8")


@pytest.fixture(scope="module")
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"