Two of my checks were brittle, not e6adfd9
Ran the suite against the pulled commit. Two failures, both mine. check_design_parity read the Overview column as findChildren(QScrollArea)[0].e6adfd9gives each event table a detail panel, which is also a scroll area and is built first — so the probe was inspecting a detail panel and reporting the pricing table as missing from a column it had never looked at. It now picks the scroll area that actually contains ov_usage_group. check_controls_alive flagged self.refresh_btn as vanished. It did, and on purpose: the single Refresh in the Monitoring header became one per table (title_refresh_btn on Bảo mật / MCP / Hành động / Agent). Tổng quan has none because it auto-refreshes every 3s on _REFRESH_MS, and Icon has nothing to refresh — I checked both before recording it in MOVED rather than assuming. Nothing ine6adfd9needed changing. 24/24 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
527092f8f9
commit
2ea20a8548
@@ -67,6 +67,13 @@ MOVED = {
|
||||
"ui\\help_agent_widget.py": {
|
||||
"self.collapse_btn": "→ mục 'Ẩn trợ lý' trong menu ⋯ của panel",
|
||||
},
|
||||
"ui\\monitoring_tab.py": {
|
||||
# e6adfd9 turned one Refresh in the Monitoring header into one per
|
||||
# table (page.title_refresh_btn on Bảo mật / MCP / Hành động / Agent).
|
||||
# Tổng quan gets none because it auto-refreshes every 3s (_REFRESH_MS);
|
||||
# Icon has nothing to refresh.
|
||||
"self.refresh_btn": "→ nút 'Làm mới' riêng trên từng bảng (title_refresh_btn)",
|
||||
},
|
||||
"ui\\schedule_task_tab.py": {
|
||||
"self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)",
|
||||
},
|
||||
|
||||
+392
-381
@@ -1,381 +1,392 @@
|
||||
"""Compare the running app against every proposal on the audit page.
|
||||
|
||||
The checklist is not written here — it is read from build_audit_page.ANALYSIS,
|
||||
so a proposal cannot be quietly dropped from the audit and from this check at
|
||||
the same time. Each item has a probe against a real MainWindow built offscreen.
|
||||
|
||||
Verdicts:
|
||||
OK the probe passes
|
||||
CHUA not implemented
|
||||
KHAC implemented differently on purpose (reason printed)
|
||||
TAY cannot be probed mechanically — inspect by eye
|
||||
|
||||
Run: python tools/check_design_parity.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))
|
||||
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
|
||||
|
||||
|
||||
def page_proposals():
|
||||
"""The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
|
||||
|
||||
Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
|
||||
sections are hand-written, and for those two the generator's text is NOT
|
||||
what the page shows. Checking against ANALYSIS reported Settings and the
|
||||
Task editor as matching the design when the page asked for something else
|
||||
(and, for the Task editor, the opposite).
|
||||
"""
|
||||
import re
|
||||
html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
|
||||
html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
|
||||
out: dict[str, list[str]] = {}
|
||||
for m in re.finditer(r'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S):
|
||||
block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', m.group(2), re.S)
|
||||
if not block:
|
||||
continue
|
||||
out[m.group(1)] = [
|
||||
re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
|
||||
for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)]
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or 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, 900)
|
||||
win.show()
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
return app, win
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app, win = build()
|
||||
ws = win.workspace
|
||||
|
||||
def goto(sub):
|
||||
win._goto(win._ROW_WORKSPACE, sub)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
def page(row):
|
||||
win._goto(row, None)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
return win._page_widgets[row]
|
||||
|
||||
import cowork_local.ui.co4e_tab as co4e_mod
|
||||
co4e = win.findChildren(co4e_mod.Co4ETab)[0]
|
||||
dash = page(win._ROW_DASHBOARD)
|
||||
mon = page(win._ROW_MONITORING)
|
||||
sched = page(win._ROW_SCHEDULE)
|
||||
goto(ws._cowork_tab_idx)
|
||||
chat = ws._cowork
|
||||
dock = win.help_agent
|
||||
|
||||
def rows_of(widget, names):
|
||||
"""How many distinct y-bands the named widgets occupy."""
|
||||
bands = set()
|
||||
for n in names:
|
||||
w = getattr(widget, n, None)
|
||||
if w is not None:
|
||||
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
|
||||
return len(bands)
|
||||
|
||||
# (slug, proposal, verdict, evidence)
|
||||
R: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def add(slug, text, ok, ev, other=None):
|
||||
R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
|
||||
|
||||
# --- 1 Dashboard ---
|
||||
n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
|
||||
"currency_combo"])
|
||||
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
|
||||
# Taller than the small tiles AND a bigger number = it reads as the headline.
|
||||
taller = dash.card_cost.height() > dash.card_total.height() * 1.5
|
||||
bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
|
||||
add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
|
||||
f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
|
||||
f"cỡ số {'34px' if bigger else 'như cũ'}")
|
||||
|
||||
# --- 2 Schedule Kanban ---
|
||||
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
|
||||
if not lanes:
|
||||
from cowork_local.core.tasks import STATUSES
|
||||
lanes = len(STATUSES)
|
||||
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
|
||||
has_combo = getattr(sched, "view_combo", None) is not None
|
||||
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
|
||||
"vẫn là combo" if has_combo else "đã thành tab")
|
||||
# The lane is only outlined while it actually holds something — seed data
|
||||
# may leave it empty, so drop a card in and read the style back.
|
||||
run_col = sched.columns.get("running")
|
||||
styled = ""
|
||||
if run_col is not None:
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
run_col.addItem(QListWidgetItem("probe"))
|
||||
sched.column_headers["running"].setStyleSheet("")
|
||||
sched.refresh()
|
||||
app.processEvents()
|
||||
styled = run_col.styleSheet()
|
||||
add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
|
||||
styled or "không có viền")
|
||||
|
||||
# --- 4/5 Workspace ---
|
||||
add("workspace-project", "History lên sidebar thành RECENTS",
|
||||
win.nav_recents.topLevelItemCount() > 0,
|
||||
f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
|
||||
add("workspace-project", "Thanh chọn project dùng chung mọi màn",
|
||||
win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
|
||||
goto(ws._co4e_tab_idx)
|
||||
hdr_off = ws._header.isHidden()
|
||||
goto(ws._project_tab_idx)
|
||||
hdr_on = not ws._header.isHidden()
|
||||
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
|
||||
"chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
|
||||
# The design's own wireframes draw the rail on every screen and a different
|
||||
# in-page pane per screen, so "the fixed left pane" is the rail — which now
|
||||
# carries the project picker and RECENTS on all of them.
|
||||
fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
|
||||
win.nav_recents.topLevelItemCount() > 0
|
||||
add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
|
||||
"rail (project + RECENTS) không đổi theo màn")
|
||||
add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
|
||||
win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
|
||||
add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
|
||||
any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
|
||||
for i in range(win.nav_recents.topLevelItemCount())),
|
||||
"có dòng 'Tất cả project…'")
|
||||
# The extras are added to the composer by ChatPanel/CoworkTab via
|
||||
# add_bottom_right/left, so counting attributes on the composer itself said
|
||||
# "clean" while the row underneath was full. Count the row instead.
|
||||
# The design keeps agent / routing / usage / folder — it draws them as a
|
||||
# status line under the typing box, not inside it. So the test is that the
|
||||
# TYPING row holds only input + attach/send/stop, and the rest sits in its
|
||||
# own strip below. Demanding an empty strip would mean deleting features.
|
||||
composer = getattr(chat, "composer", None)
|
||||
bar = getattr(composer, "extra_bar", None)
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
|
||||
typing = composer.input
|
||||
in_typing_row = typing.parentWidget() is composer
|
||||
below = bar is not None and bar.objectName() == "composerStatus"
|
||||
usage = getattr(chat, "_usage_total_lbl", None)
|
||||
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
|
||||
add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
|
||||
below and usage_in_bar,
|
||||
f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
|
||||
f"{bar.layout().count() if bar else 0} mục")
|
||||
|
||||
# --- 6 Co4E ---
|
||||
add("workspace-co4e", "Bỏ dải tab flow",
|
||||
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
|
||||
add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
|
||||
len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
|
||||
add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
|
||||
not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
|
||||
|
||||
# --- 7 Folder / 8 GraphRAG ---
|
||||
folder = ws.tabs.widget(ws._folder_tab_idx)
|
||||
title_lbl = getattr(folder, "path_lbl", None)
|
||||
add("workspace-folder", "Path bar gộp vào tiêu đề",
|
||||
title_lbl is not None and getattr(folder, "path_edit", None) is None,
|
||||
f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
|
||||
# "Thin bar at the bottom" = the terminal is the last thing in the column
|
||||
# and starts collapsed; the AI panel is a hideable right-hand pane.
|
||||
# Geometry is meaningless for a page that has never been shown, so ask the
|
||||
# widgets what state they are in instead of how tall they currently are.
|
||||
term = getattr(folder, "terminal", None)
|
||||
lay = folder.layout()
|
||||
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
|
||||
collapsed = term is not None and term._body.isHidden()
|
||||
at_bottom = term is not None and last is term
|
||||
add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
|
||||
collapsed and at_bottom,
|
||||
f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
|
||||
graph = ws.tabs.widget(ws._graphrag_tab_idx)
|
||||
# One row = the path box and Export share a y-band.
|
||||
def band(w):
|
||||
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
|
||||
one_row = band(graph.path_edit) == band(graph._export_btn)
|
||||
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
|
||||
f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
|
||||
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
|
||||
# _msg_btn — a stale name); while it exists, this is still one button whose
|
||||
# label flips, not a pair of tabs.
|
||||
toggle = getattr(graph, "_msgs_toggle_btn", None)
|
||||
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
|
||||
"vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
|
||||
|
||||
# --- 9/15 Monitoring ---
|
||||
from PySide6.QtWidgets import QHBoxLayout, QScrollArea
|
||||
from PySide6.QtWidgets import QSpinBox as QSpinBoxT
|
||||
ov = mon.findChildren(QScrollArea)[0].widget()
|
||||
one_col = not isinstance(ov.layout(), QHBoxLayout)
|
||||
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
|
||||
"cột dọc" if one_col else "vẫn 2 cột")
|
||||
# Its own section = it is a direct child of the single column, not sharing a
|
||||
# row with the resource meters as it used to.
|
||||
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
|
||||
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
|
||||
f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
|
||||
strip = not mon.tabs.tabBar().isHidden()
|
||||
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
|
||||
strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
|
||||
|
||||
# --- 17/18 dialogs ---
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
s = SettingsDialog(win.ctx)
|
||||
add("dialog-settings", "Thêm cột mục lục bên trái",
|
||||
s.section_list.count() == 5, f"{s.section_list.count()} mục")
|
||||
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
|
||||
# left as its own group — not everything merged together.
|
||||
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
|
||||
in_general = s._general_box.isAncestorOf(s.language_combo) and \
|
||||
s._general_box.isAncestorOf(s.theme_combo)
|
||||
prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
|
||||
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
|
||||
in_general and prov_apart,
|
||||
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
|
||||
n_switch = len(s.findChildren(ToggleSwitch))
|
||||
n_seg = len(s.findChildren(SegmentedControl))
|
||||
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
|
||||
add("dialog-settings",
|
||||
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
|
||||
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
|
||||
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
|
||||
s.close()
|
||||
t = TaskEditorDialog(ctx=win.ctx)
|
||||
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
|
||||
like_settings = (t.section_list.count() == 5
|
||||
and t.section_stack.count() == 5
|
||||
and not hasattr(t, "step_tabs"))
|
||||
add("dialog-task-editor",
|
||||
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
|
||||
like_settings, " · ".join(rows))
|
||||
t.close()
|
||||
|
||||
# --- 27 help dock ---
|
||||
# The page says 26px. The user asked for it doubled — recorded here rather
|
||||
# than scored against a number the app no longer intends.
|
||||
from cowork_local.ui.help_agent_widget import _DOT
|
||||
|
||||
add("overlay-help-panel",
|
||||
f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)",
|
||||
dock.width() == _DOT and not dock.launcher.text().strip(),
|
||||
f"{dock.width()}px")
|
||||
from cowork_local.i18n import tr
|
||||
dock.launcher._set_open(True)
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
|
||||
tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
|
||||
dock.launcher._set_open(False)
|
||||
# The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for
|
||||
# that menu to go: its two entries were "thu nhỏ", which the − button next
|
||||
# to it already does, and "ẩn". Recorded as a deliberate deviation rather
|
||||
# than quietly scored as done — the action itself moved to a right-click on
|
||||
# the dot and on the panel header, so nothing became unreachable.
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
has_menu = hasattr(dock, "more_btn")
|
||||
by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu
|
||||
add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải",
|
||||
(not has_menu) and by_right_click,
|
||||
"menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề")
|
||||
add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
|
||||
dock._hide_to_edge()
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
|
||||
dock._show_launcher()
|
||||
app.processEvents()
|
||||
goto(ws._cowork_tab_idx)
|
||||
comp = chat.composer
|
||||
dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
|
||||
comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
|
||||
add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
|
||||
dock_top + dock.height() <= comp_top,
|
||||
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
|
||||
|
||||
# --- coverage: is every bullet ON THE PAGE actually probed? -------------
|
||||
proposals = page_proposals()
|
||||
probed = {}
|
||||
for slug, text, _v, _e in R:
|
||||
probed.setdefault(slug, 0)
|
||||
probed[slug] += 1
|
||||
gaps = []
|
||||
for slug, bullets in proposals.items():
|
||||
n_probe = probed.get(slug, 0)
|
||||
if len(bullets) > n_probe:
|
||||
for extra in bullets[n_probe:]:
|
||||
gaps.append((slug, extra))
|
||||
|
||||
# --- report ---
|
||||
order = ["OK", "KHAC", "CHUA", "TAY"]
|
||||
counts = {k: 0 for k in order}
|
||||
cur = None
|
||||
for slug, text, verdict, ev in R:
|
||||
counts[verdict] = counts.get(verdict, 0) + 1
|
||||
if slug != cur:
|
||||
print(f"\n{slug}")
|
||||
cur = slug
|
||||
print(f" [{verdict:4}] {text}")
|
||||
print(f" {ev}")
|
||||
if gaps:
|
||||
print()
|
||||
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
|
||||
for slug, text in gaps:
|
||||
print(f" {slug}")
|
||||
print(f" {text[:160]}")
|
||||
print()
|
||||
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
|
||||
f" · da co phep do : {len(R)}")
|
||||
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
|
||||
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
|
||||
print(f" KHAC = co y lam khac, da ghi ly do")
|
||||
print(f" CHUA = chua lam")
|
||||
# This used to return 0 unconditionally — a report, not a check. Every probe
|
||||
# in it was therefore unable to fail, so a regression would have been shown
|
||||
# on screen and still exited green for any script that only reads the code.
|
||||
return 1 if counts.get("CHUA") else 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)
|
||||
"""Compare the running app against every proposal on the audit page.
|
||||
|
||||
The checklist is not written here — it is read from build_audit_page.ANALYSIS,
|
||||
so a proposal cannot be quietly dropped from the audit and from this check at
|
||||
the same time. Each item has a probe against a real MainWindow built offscreen.
|
||||
|
||||
Verdicts:
|
||||
OK the probe passes
|
||||
CHUA not implemented
|
||||
KHAC implemented differently on purpose (reason printed)
|
||||
TAY cannot be probed mechanically — inspect by eye
|
||||
|
||||
Run: python tools/check_design_parity.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))
|
||||
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
|
||||
|
||||
|
||||
def page_proposals():
|
||||
"""The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
|
||||
|
||||
Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
|
||||
sections are hand-written, and for those two the generator's text is NOT
|
||||
what the page shows. Checking against ANALYSIS reported Settings and the
|
||||
Task editor as matching the design when the page asked for something else
|
||||
(and, for the Task editor, the opposite).
|
||||
"""
|
||||
import re
|
||||
html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
|
||||
html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
|
||||
out: dict[str, list[str]] = {}
|
||||
for m in re.finditer(r'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S):
|
||||
block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', m.group(2), re.S)
|
||||
if not block:
|
||||
continue
|
||||
out[m.group(1)] = [
|
||||
re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
|
||||
for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)]
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or 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, 900)
|
||||
win.show()
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
return app, win
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app, win = build()
|
||||
ws = win.workspace
|
||||
|
||||
def goto(sub):
|
||||
win._goto(win._ROW_WORKSPACE, sub)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
def page(row):
|
||||
win._goto(row, None)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
return win._page_widgets[row]
|
||||
|
||||
import cowork_local.ui.co4e_tab as co4e_mod
|
||||
co4e = win.findChildren(co4e_mod.Co4ETab)[0]
|
||||
dash = page(win._ROW_DASHBOARD)
|
||||
mon = page(win._ROW_MONITORING)
|
||||
sched = page(win._ROW_SCHEDULE)
|
||||
goto(ws._cowork_tab_idx)
|
||||
chat = ws._cowork
|
||||
dock = win.help_agent
|
||||
|
||||
def rows_of(widget, names):
|
||||
"""How many distinct y-bands the named widgets occupy."""
|
||||
bands = set()
|
||||
for n in names:
|
||||
w = getattr(widget, n, None)
|
||||
if w is not None:
|
||||
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
|
||||
return len(bands)
|
||||
|
||||
# (slug, proposal, verdict, evidence)
|
||||
R: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def add(slug, text, ok, ev, other=None):
|
||||
R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
|
||||
|
||||
# --- 1 Dashboard ---
|
||||
n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
|
||||
"currency_combo"])
|
||||
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
|
||||
# Taller than the small tiles AND a bigger number = it reads as the headline.
|
||||
taller = dash.card_cost.height() > dash.card_total.height() * 1.5
|
||||
bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
|
||||
add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
|
||||
f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
|
||||
f"cỡ số {'34px' if bigger else 'như cũ'}")
|
||||
|
||||
# --- 2 Schedule Kanban ---
|
||||
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
|
||||
if not lanes:
|
||||
from cowork_local.core.tasks import STATUSES
|
||||
lanes = len(STATUSES)
|
||||
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
|
||||
has_combo = getattr(sched, "view_combo", None) is not None
|
||||
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
|
||||
"vẫn là combo" if has_combo else "đã thành tab")
|
||||
# The lane is only outlined while it actually holds something — seed data
|
||||
# may leave it empty, so drop a card in and read the style back.
|
||||
run_col = sched.columns.get("running")
|
||||
styled = ""
|
||||
if run_col is not None:
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
run_col.addItem(QListWidgetItem("probe"))
|
||||
sched.column_headers["running"].setStyleSheet("")
|
||||
sched.refresh()
|
||||
app.processEvents()
|
||||
styled = run_col.styleSheet()
|
||||
add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
|
||||
styled or "không có viền")
|
||||
|
||||
# --- 4/5 Workspace ---
|
||||
add("workspace-project", "History lên sidebar thành RECENTS",
|
||||
win.nav_recents.topLevelItemCount() > 0,
|
||||
f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
|
||||
add("workspace-project", "Thanh chọn project dùng chung mọi màn",
|
||||
win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
|
||||
goto(ws._co4e_tab_idx)
|
||||
hdr_off = ws._header.isHidden()
|
||||
goto(ws._project_tab_idx)
|
||||
hdr_on = not ws._header.isHidden()
|
||||
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
|
||||
"chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
|
||||
# The design's own wireframes draw the rail on every screen and a different
|
||||
# in-page pane per screen, so "the fixed left pane" is the rail — which now
|
||||
# carries the project picker and RECENTS on all of them.
|
||||
fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
|
||||
win.nav_recents.topLevelItemCount() > 0
|
||||
add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
|
||||
"rail (project + RECENTS) không đổi theo màn")
|
||||
add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
|
||||
win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
|
||||
add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
|
||||
any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
|
||||
for i in range(win.nav_recents.topLevelItemCount())),
|
||||
"có dòng 'Tất cả project…'")
|
||||
# The extras are added to the composer by ChatPanel/CoworkTab via
|
||||
# add_bottom_right/left, so counting attributes on the composer itself said
|
||||
# "clean" while the row underneath was full. Count the row instead.
|
||||
# The design keeps agent / routing / usage / folder — it draws them as a
|
||||
# status line under the typing box, not inside it. So the test is that the
|
||||
# TYPING row holds only input + attach/send/stop, and the rest sits in its
|
||||
# own strip below. Demanding an empty strip would mean deleting features.
|
||||
composer = getattr(chat, "composer", None)
|
||||
bar = getattr(composer, "extra_bar", None)
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
|
||||
typing = composer.input
|
||||
in_typing_row = typing.parentWidget() is composer
|
||||
below = bar is not None and bar.objectName() == "composerStatus"
|
||||
usage = getattr(chat, "_usage_total_lbl", None)
|
||||
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
|
||||
add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
|
||||
below and usage_in_bar,
|
||||
f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
|
||||
f"{bar.layout().count() if bar else 0} mục")
|
||||
|
||||
# --- 6 Co4E ---
|
||||
add("workspace-co4e", "Bỏ dải tab flow",
|
||||
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
|
||||
add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
|
||||
len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
|
||||
add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
|
||||
not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
|
||||
|
||||
# --- 7 Folder / 8 GraphRAG ---
|
||||
folder = ws.tabs.widget(ws._folder_tab_idx)
|
||||
title_lbl = getattr(folder, "path_lbl", None)
|
||||
add("workspace-folder", "Path bar gộp vào tiêu đề",
|
||||
title_lbl is not None and getattr(folder, "path_edit", None) is None,
|
||||
f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
|
||||
# "Thin bar at the bottom" = the terminal is the last thing in the column
|
||||
# and starts collapsed; the AI panel is a hideable right-hand pane.
|
||||
# Geometry is meaningless for a page that has never been shown, so ask the
|
||||
# widgets what state they are in instead of how tall they currently are.
|
||||
term = getattr(folder, "terminal", None)
|
||||
lay = folder.layout()
|
||||
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
|
||||
collapsed = term is not None and term._body.isHidden()
|
||||
at_bottom = term is not None and last is term
|
||||
add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
|
||||
collapsed and at_bottom,
|
||||
f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
|
||||
graph = ws.tabs.widget(ws._graphrag_tab_idx)
|
||||
# One row = the path box and Export share a y-band.
|
||||
def band(w):
|
||||
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
|
||||
one_row = band(graph.path_edit) == band(graph._export_btn)
|
||||
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
|
||||
f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
|
||||
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
|
||||
# _msg_btn — a stale name); while it exists, this is still one button whose
|
||||
# label flips, not a pair of tabs.
|
||||
toggle = getattr(graph, "_msgs_toggle_btn", None)
|
||||
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
|
||||
"vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
|
||||
|
||||
# --- 9/15 Monitoring ---
|
||||
from PySide6.QtWidgets import QHBoxLayout, QScrollArea
|
||||
from PySide6.QtWidgets import QSpinBox as QSpinBoxT
|
||||
# The Overview column is not simply the first scroll area any more — the
|
||||
# event tables' detail panels are scroll areas too, and are built first
|
||||
# (e6adfd9). Pick the one that actually holds Overview's own sections.
|
||||
ov = None
|
||||
for area in mon.findChildren(QScrollArea):
|
||||
body = area.widget()
|
||||
if body is None or body.layout() is None:
|
||||
continue
|
||||
if body.layout().indexOf(mon.ov_usage_group) >= 0:
|
||||
ov = body
|
||||
break
|
||||
assert ov is not None, "khong tim thay cot Tong quan"
|
||||
one_col = not isinstance(ov.layout(), QHBoxLayout)
|
||||
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
|
||||
"cột dọc" if one_col else "vẫn 2 cột")
|
||||
# Its own section = it is a direct child of the single column, not sharing a
|
||||
# row with the resource meters as it used to.
|
||||
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
|
||||
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
|
||||
f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
|
||||
strip = not mon.tabs.tabBar().isHidden()
|
||||
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
|
||||
strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
|
||||
|
||||
# --- 17/18 dialogs ---
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
s = SettingsDialog(win.ctx)
|
||||
add("dialog-settings", "Thêm cột mục lục bên trái",
|
||||
s.section_list.count() == 5, f"{s.section_list.count()} mục")
|
||||
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
|
||||
# left as its own group — not everything merged together.
|
||||
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
|
||||
in_general = s._general_box.isAncestorOf(s.language_combo) and \
|
||||
s._general_box.isAncestorOf(s.theme_combo)
|
||||
prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
|
||||
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
|
||||
in_general and prov_apart,
|
||||
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
|
||||
n_switch = len(s.findChildren(ToggleSwitch))
|
||||
n_seg = len(s.findChildren(SegmentedControl))
|
||||
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
|
||||
add("dialog-settings",
|
||||
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
|
||||
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
|
||||
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
|
||||
s.close()
|
||||
t = TaskEditorDialog(ctx=win.ctx)
|
||||
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
|
||||
like_settings = (t.section_list.count() == 5
|
||||
and t.section_stack.count() == 5
|
||||
and not hasattr(t, "step_tabs"))
|
||||
add("dialog-task-editor",
|
||||
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
|
||||
like_settings, " · ".join(rows))
|
||||
t.close()
|
||||
|
||||
# --- 27 help dock ---
|
||||
# The page says 26px. The user asked for it doubled — recorded here rather
|
||||
# than scored against a number the app no longer intends.
|
||||
from cowork_local.ui.help_agent_widget import _DOT
|
||||
|
||||
add("overlay-help-panel",
|
||||
f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)",
|
||||
dock.width() == _DOT and not dock.launcher.text().strip(),
|
||||
f"{dock.width()}px")
|
||||
from cowork_local.i18n import tr
|
||||
dock.launcher._set_open(True)
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
|
||||
tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
|
||||
dock.launcher._set_open(False)
|
||||
# The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for
|
||||
# that menu to go: its two entries were "thu nhỏ", which the − button next
|
||||
# to it already does, and "ẩn". Recorded as a deliberate deviation rather
|
||||
# than quietly scored as done — the action itself moved to a right-click on
|
||||
# the dot and on the panel header, so nothing became unreachable.
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
has_menu = hasattr(dock, "more_btn")
|
||||
by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu
|
||||
add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải",
|
||||
(not has_menu) and by_right_click,
|
||||
"menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề")
|
||||
add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
|
||||
dock._hide_to_edge()
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
|
||||
dock._show_launcher()
|
||||
app.processEvents()
|
||||
goto(ws._cowork_tab_idx)
|
||||
comp = chat.composer
|
||||
dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
|
||||
comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
|
||||
add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
|
||||
dock_top + dock.height() <= comp_top,
|
||||
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
|
||||
|
||||
# --- coverage: is every bullet ON THE PAGE actually probed? -------------
|
||||
proposals = page_proposals()
|
||||
probed = {}
|
||||
for slug, text, _v, _e in R:
|
||||
probed.setdefault(slug, 0)
|
||||
probed[slug] += 1
|
||||
gaps = []
|
||||
for slug, bullets in proposals.items():
|
||||
n_probe = probed.get(slug, 0)
|
||||
if len(bullets) > n_probe:
|
||||
for extra in bullets[n_probe:]:
|
||||
gaps.append((slug, extra))
|
||||
|
||||
# --- report ---
|
||||
order = ["OK", "KHAC", "CHUA", "TAY"]
|
||||
counts = {k: 0 for k in order}
|
||||
cur = None
|
||||
for slug, text, verdict, ev in R:
|
||||
counts[verdict] = counts.get(verdict, 0) + 1
|
||||
if slug != cur:
|
||||
print(f"\n{slug}")
|
||||
cur = slug
|
||||
print(f" [{verdict:4}] {text}")
|
||||
print(f" {ev}")
|
||||
if gaps:
|
||||
print()
|
||||
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
|
||||
for slug, text in gaps:
|
||||
print(f" {slug}")
|
||||
print(f" {text[:160]}")
|
||||
print()
|
||||
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
|
||||
f" · da co phep do : {len(R)}")
|
||||
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
|
||||
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
|
||||
print(f" KHAC = co y lam khac, da ghi ly do")
|
||||
print(f" CHUA = chua lam")
|
||||
# This used to return 0 unconditionally — a report, not a check. Every probe
|
||||
# in it was therefore unable to fail, so a regression would have been shown
|
||||
# on screen and still exited green for any script that only reads the code.
|
||||
return 1 if counts.get("CHUA") else 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)
|
||||
|
||||
Reference in New Issue
Block a user