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>
213 lines
7.8 KiB
Python
213 lines
7.8 KiB
Python
"""Round 3: is every control in the inventory still in the built app?
|
|
|
|
docs/screens/controls.json was extracted from the source by AST before the
|
|
redesign started. Rounds 1 and 2 ask whether the new shape is right; this one
|
|
asks the opposite question — whether rearranging dropped anything.
|
|
|
|
A control counts as alive if the attribute still exists on its screen's widget
|
|
AND is a real QWidget. Ones that were deliberately moved or replaced are listed
|
|
in MOVED with where they went, so an intentional change reads differently from
|
|
an accidental loss.
|
|
|
|
Run: python tools/check_controls_alive.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
|
|
|
|
# controls.json lists every control in a FILE, and several files hold more than
|
|
# one class (schedule_task_tab.py alone has the tab plus three dialogs). Only
|
|
# the screen's own class lives on the widget we can inspect, so each control is
|
|
# attributed to its class first, using the file as it was when the inventory
|
|
# was taken — that is the baseline commit, not today's line numbers.
|
|
BASELINE = "291a611"
|
|
|
|
|
|
def class_ranges(path: str) -> list[tuple[str, int, int]]:
|
|
"""(class name, first line, last line) from the file at BASELINE."""
|
|
import ast
|
|
import subprocess
|
|
try:
|
|
src = subprocess.run(["git", "show", f"{BASELINE}:{path}"],
|
|
cwd=REPO, capture_output=True, text=True,
|
|
encoding="utf-8", check=True).stdout
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
try:
|
|
tree = ast.parse(src)
|
|
except SyntaxError:
|
|
return []
|
|
return [(n.name, n.lineno, max(getattr(x, "lineno", n.lineno)
|
|
for x in ast.walk(n)))
|
|
for n in tree.body if isinstance(n, ast.ClassDef)]
|
|
|
|
|
|
def owning_class(ranges, line: int) -> str:
|
|
for name, start, end in ranges:
|
|
if start <= line <= end:
|
|
return name
|
|
return ""
|
|
|
|
|
|
# Controls that are gone ON PURPOSE, with what replaced them. Anything missing
|
|
# and NOT listed here is a regression.
|
|
MOVED = {
|
|
"ui\\help_agent_widget.py": {
|
|
"self.collapse_btn": "→ mục 'Ẩn trợ lý' trong menu ⋯ của panel",
|
|
},
|
|
"ui\\schedule_task_tab.py": {
|
|
"self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)",
|
|
},
|
|
"ui\\folder_tab.py": {
|
|
"self.path_edit": "→ tiêu đề màn (path_lbl)",
|
|
},
|
|
"ui\\structure_graph_view.py": {
|
|
"self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)",
|
|
},
|
|
"app.py": {
|
|
"self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)",
|
|
},
|
|
}
|
|
|
|
# The class whose controls each owner widget actually holds.
|
|
MAIN_CLASS = {
|
|
"app.py": "MainWindow",
|
|
"ui\\workspace_tab.py": "WorkspaceTab",
|
|
"ui\\cowork_tab.py": "CoworkTab",
|
|
"ui\\chat_panel.py": "ChatPanel",
|
|
"ui\\composer.py": "Composer",
|
|
"ui\\sidebar.py": "HistorySidebar",
|
|
"ui\\co4e_tab.py": "Co4ETab",
|
|
"ui\\folder_tab.py": "FolderTab",
|
|
"ui\\structure_graph_view.py": "StructureGraphView",
|
|
"ui\\schedule_task_tab.py": "ScheduleTaskTab",
|
|
"ui\\dashboard_tab.py": "DashboardTab",
|
|
"ui\\monitoring_tab.py": "MonitoringTab",
|
|
"ui\\help_agent_widget.py": "HelpAgentWidget",
|
|
}
|
|
|
|
# Which built widget owns each source file's controls.
|
|
def owners(win):
|
|
ws = win.workspace
|
|
import cowork_local.ui.co4e_tab as co4e_mod
|
|
return {
|
|
"app.py": win,
|
|
"ui\\workspace_tab.py": ws,
|
|
"ui\\cowork_tab.py": ws._cowork,
|
|
"ui\\chat_panel.py": ws._cowork,
|
|
"ui\\composer.py": ws._cowork.composer,
|
|
"ui\\sidebar.py": win.sidebar,
|
|
"ui\\co4e_tab.py": win.findChildren(co4e_mod.Co4ETab)[0],
|
|
"ui\\folder_tab.py": ws.tabs.widget(ws._folder_tab_idx),
|
|
"ui\\structure_graph_view.py": ws.tabs.widget(ws._graphrag_tab_idx),
|
|
"ui\\schedule_task_tab.py": win._page_widgets[win._ROW_SCHEDULE],
|
|
"ui\\dashboard_tab.py": win._page_widgets[win._ROW_DASHBOARD],
|
|
"ui\\monitoring_tab.py": win._page_widgets[win._ROW_MONITORING],
|
|
"ui\\help_agent_widget.py": win.help_agent,
|
|
}
|
|
|
|
|
|
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 AppConfig, 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.i18n import set_language
|
|
from cowork_local.state import AppContext
|
|
|
|
set_language("vi")
|
|
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
|
win.resize(1600, 950)
|
|
win.show()
|
|
# Build every lazy page before looking for its controls.
|
|
for row in (win._ROW_DASHBOARD, win._ROW_SCHEDULE, win._ROW_MONITORING):
|
|
win._goto(row, None)
|
|
for _ in range(6):
|
|
app.processEvents()
|
|
for sub in range(win.workspace.tabs.count()):
|
|
win._goto(win._ROW_WORKSPACE, sub)
|
|
for _ in range(6):
|
|
app.processEvents()
|
|
|
|
index = json.loads((REPO / "docs" / "screens" / "controls.json")
|
|
.read_text(encoding="utf-8"))
|
|
own = owners(win)
|
|
|
|
alive = dead = moved = skipped = other_class = 0
|
|
losses: list[tuple[str, str, str]] = []
|
|
for rec in index:
|
|
holder = own.get(rec["file"])
|
|
if holder is None:
|
|
skipped += len(rec["controls"])
|
|
continue
|
|
ranges = class_ranges(rec["file"].replace("\\", "/"))
|
|
want = MAIN_CLASS.get(rec["file"], "")
|
|
for c in rec["controls"]:
|
|
var = c["var"]
|
|
if not var.startswith("self."):
|
|
skipped += 1
|
|
continue
|
|
# Belongs to a dialog defined in the same file → not on this widget.
|
|
if ranges and want and owning_class(ranges, c["line"]) != want:
|
|
other_class += 1
|
|
continue
|
|
name = var.split(".", 1)[1]
|
|
if getattr(holder, name, None) is not None:
|
|
alive += 1
|
|
elif var in MOVED.get(rec["file"], {}):
|
|
moved += 1
|
|
else:
|
|
dead += 1
|
|
losses.append((rec["file"], var,
|
|
c.get("label_vi") or c.get("label") or "?"))
|
|
|
|
print(f"control con song : {alive}")
|
|
print(f"co y doi cho : {moved}")
|
|
for f, v, w in [(f, v, MOVED[f][v]) for f in MOVED for v in MOVED[f]]:
|
|
print(f" {v:26} {w}")
|
|
print(f"thuoc class khac : {other_class} (hop thoai dinh nghia cung file)")
|
|
print(f"khong kiem duoc : {skipped} (dialog dung rieng, bien cuc bo)")
|
|
print(f"MAT : {dead}")
|
|
for f, var, label in losses:
|
|
print(f" ! {f}: {var} ({label})")
|
|
print()
|
|
if dead:
|
|
print("*** VONG 3 THAT BAI: co control bien mat ***")
|
|
return 1
|
|
print("KET QUA VONG 3: khong control nao bien mat ngoai y muon")
|
|
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)
|