Settings sat 7px further in than the Dashboard/Giám sát rows above it — its
QSS gave it a 6px side margin where those rows start at the rail edge. At the
collapsed 54px width that put its icon near the middle of the rail, which is
what "thu gọn menu lại ra giữa" was describing. Icon and label now start on
the same x as the rows, open and collapsed, in both themes.
The help panel's transcript is rendered HTML, so switching language re-labelled
the chrome but left the greeting — and the "AI Assistant" speaker label — in
whatever language the panel was built in. retranslate() now rewrites the
greeting (matched by identity, so a real reply is never touched) and re-renders.
The sparkle is #FDBE59, sampled from the audit page's own render. Its CSS says
.spark{color:#0F9B8A}, but the glyph is the ✨ emoji and a colour emoji ignores
CSS colour, so the page has always drawn a gold star.
Behind all three: MainWindow does not style itself — run() calls
app.setStyleSheet — so 12 of 13 checkers were measuring a window with no
padding, margins or borders. Every QSS-driven layout bug was invisible to them,
and an unstyled window reported an icon drift that does not exist. Added
_apply_theme() and wired it through.
Two checker repairs that followed:
· check_no_hscroll flagged the 9pt dialogs on sizeHintForColumn(0), which
returns 182px at 9pt, 11pt and 14pt alike. Nothing was clipped. It now
compares the painted text against the width actually on screen, and fails
on a squeezed list (24 combos) where the old test passed.
· the checkers print Vietnamese and died mid-report on a cp932 console.
New: check_rail_align (icons hold one line, both themes, both states) and
check_help_i18n (transcript follows the language). Both verified to fail
without their fix.
15/15 checkers pass. check_nav and check_design_parity segfault in Qt teardown
roughly one run in three — pre-existing, after the verdict prints, and it
happens with or without the theme change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""Smoke-test the flat nav rail against a real MainWindow.
|
||
|
||
Builds the window offscreen on a COPY of ~/.cowork_local (schedulers no-oped, so
|
||
nothing scheduled can fire) and answers the questions the redesign has to get
|
||
right:
|
||
|
||
* is every destination that used to be reachable still reachable?
|
||
* does the rail highlight follow the content, from clicks AND from _goto?
|
||
* do the project-gated rows stay listed (greyed) instead of disappearing?
|
||
* does Monitoring still expose all eight sub-views, now via its own tab strip?
|
||
|
||
Run: python tools/check_nav.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
from pathlib import Path
|
||
|
||
REPO = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(REPO.parent)) # `import cowork_local`
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
|
||
|
||
|
||
def rows(tree):
|
||
from PySide6.QtCore import Qt
|
||
out = []
|
||
for i in range(tree.topLevelItemCount()):
|
||
it = tree.topLevelItem(i)
|
||
data = it.data(0, Qt.UserRole) or {}
|
||
out.append((it.text(0), data.get("page"), data.get("sub"),
|
||
not it.isDisabled()))
|
||
return out
|
||
|
||
|
||
def main() -> int:
|
||
sandbox = _isolate_home()
|
||
from PySide6.QtWidgets import QApplication
|
||
|
||
app = QApplication([])
|
||
_load_fonts()
|
||
_freeze_schedulers()
|
||
|
||
_apply_theme(app) # measure the styled window, not a bare one
|
||
from cowork_local.config import CONFIG_DIR
|
||
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||
|
||
from seed_demo_data import seed
|
||
seed()
|
||
|
||
from cowork_local.app import MainWindow
|
||
from cowork_local.config import AppConfig
|
||
from cowork_local.i18n import set_language
|
||
from cowork_local.state import AppContext
|
||
|
||
set_language("vi")
|
||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||
app.processEvents()
|
||
|
||
fails: list[str] = []
|
||
print("THANH MENU CHINH")
|
||
for label, page, sub, on in rows(win.nav):
|
||
print(f" {label:22} page={page} sub={sub} {'' if on else '(mo — chua chon project)'}")
|
||
print("NHOM GHIM DAY")
|
||
for label, page, sub, on in rows(win.nav_bottom):
|
||
print(f" {label:22} page={page} sub={sub}")
|
||
print(f"NUT: {win._nav_settings_btn.text()}")
|
||
print()
|
||
|
||
main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom)
|
||
n_total = len(main_rows) + len(bottom_rows)
|
||
# Five Workspace sub-views + Schedule, then Dashboard + Monitoring.
|
||
if len(main_rows) != 6:
|
||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6")
|
||
if len(bottom_rows) != 2:
|
||
fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2")
|
||
if any(sub is not None for _l, _p, sub, _o in bottom_rows):
|
||
fails.append("nhom day khong duoc mang sub-tab")
|
||
|
||
# The two gated rows must be PRESENT (that is the point) — greyed is fine.
|
||
labels = [r[0] for r in main_rows]
|
||
ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()]
|
||
for lab in ws_labels:
|
||
if lab not in labels:
|
||
fails.append(f"mat dong Workspace: {lab}")
|
||
print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}"
|
||
f" ({', '.join(ws_labels)})")
|
||
|
||
# Highlight must follow the content for every row, both ways round.
|
||
# Re-fetch items by index every time: navigating can rebuild the rail, which
|
||
# deletes the C++ objects a held reference points at.
|
||
ok_click = ok_goto = 0
|
||
for which, name in ((win.nav, "chinh"), (win.nav_bottom, "day")):
|
||
for i in range(which.topLevelItemCount()):
|
||
label, page, sub, on = rows(which)[i]
|
||
if not on:
|
||
continue
|
||
which.setCurrentItem(which.topLevelItem(i)) # as if clicked
|
||
app.processEvents()
|
||
if win.pages.currentIndex() == page:
|
||
ok_click += 1
|
||
else:
|
||
fails.append(f"bam '{label}' ({name}) khong mo dung trang")
|
||
win._goto(page, sub) # programmatic
|
||
app.processEvents()
|
||
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
|
||
if t.currentItem() is not None and t.currentItem().isSelected()), None)
|
||
if cur is not None and cur.text(0) == label:
|
||
ok_goto += 1
|
||
else:
|
||
fails.append(f"_goto toi '{label}' nhung vet sang o "
|
||
f"'{cur.text(0) if cur else 'khong dau'}'")
|
||
n_live = sum(1 for r in main_rows + bottom_rows if r[3])
|
||
print(f"bam mo dung trang : {ok_click}/{n_live}")
|
||
print(f"vet sang theo _goto : {ok_goto}/{n_live}")
|
||
|
||
# Only one row may look active across the two lists.
|
||
lit = sum(1 for t in (win.nav, win.nav_bottom) for i in range(t.topLevelItemCount())
|
||
if t.topLevelItem(i).isSelected())
|
||
print(f"so dong dang sang : {lit} (phai la 1)")
|
||
if lit != 1:
|
||
fails.append(f"{lit} dong cung sang")
|
||
|
||
# Monitoring's eight sub-views moved to its own tab strip — check it is shown.
|
||
win._ensure_page(win._ROW_MONITORING)
|
||
mon = win._page_widgets[win._ROW_MONITORING]
|
||
# isVisible() is False for everything while the window has never been shown;
|
||
# isHidden() asks the question that actually matters here.
|
||
strip_visible = not mon.tabs.tabBar().isHidden() if hasattr(mon, "tabs") else False
|
||
n_sub = len(mon.nav_subtabs())
|
||
print(f"Monitoring: {n_sub} man, dai tab hien = {strip_visible}")
|
||
if n_sub != 8:
|
||
fails.append(f"Monitoring chi con {n_sub} man")
|
||
if not strip_visible:
|
||
fails.append("dai tab Monitoring van bi an — 8 man khong toi duoc")
|
||
|
||
# Workspace's own strip stays hidden: the rail lists those five instead.
|
||
ws_strip = not win.workspace.tabs.tabBar().isHidden()
|
||
print(f"Workspace: dai tab hien = {ws_strip} (phai la False — thanh menu lo roi)")
|
||
if ws_strip:
|
||
fails.append("dai tab Workspace hien lai — trung voi thanh menu")
|
||
|
||
# The whole point of the change: with no project selected the two gated rows
|
||
# must stay in place, greyed — not vanish and resize the menu.
|
||
win.workspace._update_tab_visibility(False)
|
||
app.processEvents()
|
||
gated = rows(win.nav)
|
||
off = [lab for lab, _p, _s, on in gated if not on]
|
||
print()
|
||
print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}")
|
||
if len(gated) != len(main_rows):
|
||
fails.append(f"chua chon project thi thanh menu con {len(gated)} dong "
|
||
f"(truoc {len(main_rows)}) — item van bien mat")
|
||
if len(off) != 2:
|
||
fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}")
|
||
|
||
# --- rail header: project picker + new chat (Phase A) ------------------
|
||
print()
|
||
n_proj = win.nav_project.count()
|
||
print(f"bo chon project : {n_proj} muc · dang chon "
|
||
f"{win.nav_project.currentText()!r}")
|
||
print(f"nut chat moi : {win.nav_new_chat.text()!r} "
|
||
f"(bat = {win.nav_new_chat.isEnabled()})")
|
||
if win.nav_project.currentData() != win.workspace.selected_project_id():
|
||
fails.append("bo chon project khong khop voi project dang chon")
|
||
|
||
# Picking in the rail must move the real selection, not just the combo.
|
||
if n_proj > 1:
|
||
other = next(i for i in range(n_proj)
|
||
if win.nav_project.itemData(i) != win.workspace.selected_project_id())
|
||
want = win.nav_project.itemData(other)
|
||
win.nav_project.setCurrentIndex(other)
|
||
app.processEvents()
|
||
got = win.workspace.selected_project_id()
|
||
print(f"doi project tu rail: chon {want} -> workspace dang o {got}")
|
||
if got != want:
|
||
fails.append("doi project tren rail khong doi project that")
|
||
if win.nav_project.currentData() != got:
|
||
fails.append("bo chon khong dong bo nguoc lai")
|
||
|
||
# New chat from any screen: lands on Cowork with an empty thread, and the
|
||
# old toolbar button must still be there.
|
||
win._goto(win._ROW_DASHBOARD, None)
|
||
app.processEvents()
|
||
before = win.cowork.current_session_id() if hasattr(win.cowork, "current_session_id") else None
|
||
win._on_rail_new_chat()
|
||
app.processEvents()
|
||
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
|
||
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
|
||
print(f"bam '+ chat moi' tu Dashboard -> dung o Cowork: {on_cowork}")
|
||
if not on_cowork:
|
||
fails.append("nut chat moi khong dua toi Cowork")
|
||
old_btn = getattr(win.cowork, "_new_btn", None)
|
||
print(f"nut cu tren thanh Cowork con nguyen: {old_btn is not None} "
|
||
f"({old_btn.text()!r})" if old_btn is not None else "MAT NUT CU")
|
||
if old_btn is None:
|
||
fails.append("nut 'Cuoc tro chuyen moi' cu tren Cowork bi mat")
|
||
|
||
# --- rail RECENTS (Phase B) --------------------------------------------
|
||
from PySide6.QtCore import Qt as _Qt
|
||
win._refresh_rail_recents()
|
||
app.processEvents()
|
||
rec = win.nav_recents
|
||
items = [(rec.topLevelItem(i).text(0), rec.topLevelItem(i).data(0, _Qt.UserRole) or {})
|
||
for i in range(rec.topLevelItemCount())]
|
||
threads = [t for t, d in items if d.get("path")]
|
||
print()
|
||
print(f"GAN DAY ({win.nav_recents_hdr.text()}): {len(threads)} thread"
|
||
f" + dong '{items[-1][0]}'")
|
||
for t in threads:
|
||
print(f" {t}")
|
||
if not items[-1][1].get("all"):
|
||
fails.append("thieu dong 'Tat ca project…'")
|
||
if len(threads) > win._RAIL_RECENTS:
|
||
fails.append(f"GAN DAY liet ke {len(threads)} thread, toi da {win._RAIL_RECENTS}")
|
||
|
||
# Scoped to the active project — a flat cross-project list would lose that.
|
||
pid = win.workspace.selected_project_id()
|
||
all_titles = {t["title"] for t in win.workspace.recent_threads(99)}
|
||
other_pid = next((p for _n, p in win.workspace.project_choices() if p != pid), "")
|
||
if other_pid:
|
||
win.workspace.choose_project(other_pid)
|
||
app.processEvents()
|
||
win._refresh_rail_recents()
|
||
other_titles = {t["title"] for t in win.workspace.recent_threads(99)}
|
||
print(f"doi sang project khac: danh sach doi = {other_titles != all_titles}")
|
||
if other_titles & all_titles and other_titles == all_titles:
|
||
fails.append("GAN DAY khong gom theo project — hai project cung mot danh sach")
|
||
win.workspace.choose_project(pid)
|
||
app.processEvents()
|
||
win._refresh_rail_recents()
|
||
|
||
# Clicking a thread must open it through the normal route.
|
||
if threads:
|
||
win._goto(win._ROW_DASHBOARD, None)
|
||
app.processEvents()
|
||
# Re-fetch: the project switch above rebuilt this list, deleting the
|
||
# items a held reference would point at.
|
||
win._on_rail_recent(win.nav_recents.topLevelItem(0))
|
||
app.processEvents()
|
||
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
|
||
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
|
||
print(f"bam thread gan day -> mo o Cowork: {on_cowork}")
|
||
if not on_cowork:
|
||
fails.append("bam thread trong GAN DAY khong mo duoc")
|
||
|
||
# The full History panel must still exist, with all its controls.
|
||
sb = win.sidebar
|
||
kept = [n for n in ("search_box", "search_btn", "tree", "_collapse_btn")
|
||
if getattr(sb, n, None) is not None]
|
||
print(f"khung History day du van con: {len(kept)}/4 control goc {kept}")
|
||
if len(kept) != 4:
|
||
fails.append("khung History bi mat control")
|
||
|
||
# --- account row moved off the top bar (Phase C) -----------------------
|
||
print()
|
||
from PySide6.QtWidgets import QWidget as _QWidget
|
||
top_kids = {w.objectName() or type(w).__name__
|
||
for w in win.findChildren(_QWidget)
|
||
if w.parent() is not None and w.parent().objectName() == "topbar"}
|
||
print(f"top bar con lai : {sorted(top_kids)}")
|
||
for name in ("provider_combo", "language_combo", "theme_btn"):
|
||
w = getattr(win, name, None)
|
||
if w is None:
|
||
fails.append(f"mat control {name}")
|
||
continue
|
||
in_rail = win._nav_wrap.isAncestorOf(w)
|
||
print(f" {name:16} nam trong rail = {in_rail}")
|
||
if not in_rail:
|
||
fails.append(f"{name} chua chuyen xuong rail")
|
||
# They must still work, not just exist: flipping the language must retranslate.
|
||
from cowork_local.i18n import get_language
|
||
before_lang = get_language()
|
||
other = next(i for i in range(win.language_combo.count())
|
||
if win.language_combo.itemData(i) != before_lang)
|
||
win.language_combo.setCurrentIndex(other)
|
||
app.processEvents()
|
||
after_lang = get_language()
|
||
print(f"doi ngon ngu tu rail: {before_lang} -> {after_lang}")
|
||
if after_lang == before_lang:
|
||
fails.append("combo ngon ngu o rail khong doi duoc ngon ngu")
|
||
win.language_combo.setCurrentIndex(win.language_combo.findData(before_lang))
|
||
app.processEvents()
|
||
print(f"provider dang chon : {win.provider_combo.currentText()!r}")
|
||
print(f"tai khoan : {win.account_lbl.text()!r}")
|
||
|
||
print()
|
||
print(f"tong dong dieu huong: {n_total} + nut Cai dat")
|
||
if fails:
|
||
print("*** LOI ***")
|
||
for f in fails:
|
||
print(" " + f)
|
||
return 1
|
||
print("KET QUA: thanh menu phang chay dung")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_rc = main()
|
||
# Qt (WebEngine especially) crashes during interpreter teardown with
|
||
# 0xC0000409 AFTER the work is done, which would mask the real result —
|
||
# and check_probes_bite reads these exit codes to decide whether a probe
|
||
# caught its mutation. Leave immediately with the verdict instead.
|
||
sys.stdout.flush()
|
||
sys.stderr.flush()
|
||
os._exit(_rc)
|