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].
e6adfd9 gives 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 in e6adfd9 needed changing. 24/24 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-19 19:21:04 +09:00
co-authored by Claude Opus 5
parent 527092f8f9
commit 2ea20a8548
2 changed files with 399 additions and 381 deletions
+7
View File
@@ -67,6 +67,13 @@ MOVED = {
"ui\\help_agent_widget.py": { "ui\\help_agent_widget.py": {
"self.collapse_btn": "→ mục 'Ẩn trợ lý' trong menu ⋯ của panel", "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": { "ui\\schedule_task_tab.py": {
"self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)", "self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)",
}, },
+392 -381
View File
@@ -1,381 +1,392 @@
"""Compare the running app against every proposal on the audit page. """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, 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 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. the same time. Each item has a probe against a real MainWindow built offscreen.
Verdicts: Verdicts:
OK the probe passes OK the probe passes
CHUA not implemented CHUA not implemented
KHAC implemented differently on purpose (reason printed) KHAC implemented differently on purpose (reason printed)
TAY cannot be probed mechanically — inspect by eye TAY cannot be probed mechanically — inspect by eye
Run: python tools/check_design_parity.py Run: python tools/check_design_parity.py
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import sys␍ import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO.parent)) sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
def page_proposals(): def page_proposals():
"""The 'Thay đổi' bullets as they appear ON THE PAGE, per section. """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 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 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 what the page shows. Checking against ANALYSIS reported Settings and the
Task editor as matching the design when the page asked for something else Task editor as matching the design when the page asked for something else
(and, for the Task editor, the opposite). (and, for the Task editor, the opposite).
""" """
import re import re
html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8") html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html) html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
out: dict[str, list[str]] = {} out: dict[str, list[str]] = {}
for m in re.finditer(r'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S): 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) block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', m.group(2), re.S)
if not block: if not block:
continue continue
out[m.group(1)] = [ out[m.group(1)] = [
re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip() re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)] for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)]
return out return out
def build(): def build():
sandbox = _isolate_home() sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
_load_fonts() _load_fonts()
_freeze_schedulers() _freeze_schedulers()
_apply_theme(app) # measure the styled window, not a bare one _apply_theme(app) # measure the styled window, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from seed_demo_data import seed from seed_demo_data import seed
seed() seed()
from cowork_local.app import MainWindow from cowork_local.app import MainWindow
from cowork_local.i18n import set_language from cowork_local.i18n import set_language
from cowork_local.state import AppContext from cowork_local.state import AppContext
set_language("vi") set_language("vi")
win = MainWindow(AppContext(AppConfig.load()), user_name="local") win = MainWindow(AppContext(AppConfig.load()), user_name="local")
win.resize(1600, 900) win.resize(1600, 900)
win.show() win.show()
for _ in range(8): for _ in range(8):
app.processEvents() app.processEvents()
return app, win return app, win
def main() -> int: def main() -> int:
app, win = build() app, win = build()
ws = win.workspace ws = win.workspace
def goto(sub): def goto(sub):
win._goto(win._ROW_WORKSPACE, sub) win._goto(win._ROW_WORKSPACE, sub)
for _ in range(6): for _ in range(6):
app.processEvents() app.processEvents()
def page(row): def page(row):
win._goto(row, None) win._goto(row, None)
for _ in range(6): for _ in range(6):
app.processEvents() app.processEvents()
return win._page_widgets[row] return win._page_widgets[row]
import cowork_local.ui.co4e_tab as co4e_mod import cowork_local.ui.co4e_tab as co4e_mod
co4e = win.findChildren(co4e_mod.Co4ETab)[0] co4e = win.findChildren(co4e_mod.Co4ETab)[0]
dash = page(win._ROW_DASHBOARD) dash = page(win._ROW_DASHBOARD)
mon = page(win._ROW_MONITORING) mon = page(win._ROW_MONITORING)
sched = page(win._ROW_SCHEDULE) sched = page(win._ROW_SCHEDULE)
goto(ws._cowork_tab_idx) goto(ws._cowork_tab_idx)
chat = ws._cowork chat = ws._cowork
dock = win.help_agent dock = win.help_agent
def rows_of(widget, names): def rows_of(widget, names):
"""How many distinct y-bands the named widgets occupy.""" """How many distinct y-bands the named widgets occupy."""
bands = set() bands = set()
for n in names: for n in names:
w = getattr(widget, n, None) w = getattr(widget, n, None)
if w is not None: if w is not None:
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
return len(bands) return len(bands)
# (slug, proposal, verdict, evidence) # (slug, proposal, verdict, evidence)
R: list[tuple[str, str, str, str]] = [] R: list[tuple[str, str, str, str]] = []
def add(slug, text, ok, ev, other=None): def add(slug, text, ok, ev, other=None):
R.append((slug, text, other or ("OK" if ok else "CHUA"), ev)) R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
# --- 1 Dashboard --- # --- 1 Dashboard ---
n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn", n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
"currency_combo"]) "currency_combo"])
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") 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 than the small tiles AND a bigger number = it reads as the headline.
taller = dash.card_cost.height() > dash.card_total.height() * 1.5 taller = dash.card_cost.height() > dash.card_total.height() * 1.5
bigger = "34px" in dash.card_cost.value_lbl.styleSheet() bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
add("dashboard", "Chi phí làm thẻ chính", taller and bigger, 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"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
f"cỡ số {'34px' if bigger else 'như cũ'}") f"cỡ số {'34px' if bigger else 'như cũ'}")
# --- 2 Schedule Kanban --- # --- 2 Schedule Kanban ---
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
if not lanes: if not lanes:
from cowork_local.core.tasks import STATUSES from cowork_local.core.tasks import STATUSES
lanes = len(STATUSES) lanes = len(STATUSES)
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
has_combo = getattr(sched, "view_combo", None) is not None has_combo = getattr(sched, "view_combo", None) is not None
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
"vẫn là combo" if has_combo else "đã thành tab") "vẫn là combo" if has_combo else "đã thành tab")
# The lane is only outlined while it actually holds something — seed data # 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. # may leave it empty, so drop a card in and read the style back.
run_col = sched.columns.get("running") run_col = sched.columns.get("running")
styled = "" styled = ""
if run_col is not None: if run_col is not None:
from PySide6.QtWidgets import QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
run_col.addItem(QListWidgetItem("probe")) run_col.addItem(QListWidgetItem("probe"))
sched.column_headers["running"].setStyleSheet("") sched.column_headers["running"].setStyleSheet("")
sched.refresh() sched.refresh()
app.processEvents() app.processEvents()
styled = run_col.styleSheet() styled = run_col.styleSheet()
add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
styled or "không có viền") styled or "không có viền")
# --- 4/5 Workspace --- # --- 4/5 Workspace ---
add("workspace-project", "History lên sidebar thành RECENTS", add("workspace-project", "History lên sidebar thành RECENTS",
win.nav_recents.topLevelItemCount() > 0, win.nav_recents.topLevelItemCount() > 0,
f"{win.nav_recents.topLevelItemCount()} dòng trên rail") f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
add("workspace-project", "Thanh chọn project dùng chung mọi màn", 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") win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
goto(ws._co4e_tab_idx) goto(ws._co4e_tab_idx)
hdr_off = ws._header.isHidden() hdr_off = ws._header.isHidden()
goto(ws._project_tab_idx) goto(ws._project_tab_idx)
hdr_on = not ws._header.isHidden() hdr_on = not ws._header.isHidden()
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on, 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") "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 # 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 # 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. # carries the project picker and RECENTS on all of them.
fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
win.nav_recents.topLevelItemCount() > 0 win.nav_recents.topLevelItemCount() > 0
add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
"rail (project + RECENTS) không đổi theo màn") "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", 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") 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…'", add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all") any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
for i in range(win.nav_recents.topLevelItemCount())), for i in range(win.nav_recents.topLevelItemCount())),
"có dòng 'Tất cả project…'") "có dòng 'Tất cả project…'")
# The extras are added to the composer by ChatPanel/CoworkTab via # The extras are added to the composer by ChatPanel/CoworkTab via
# add_bottom_right/left, so counting attributes on the composer itself said # add_bottom_right/left, so counting attributes on the composer itself said
# "clean" while the row underneath was full. Count the row instead. # "clean" while the row underneath was full. Count the row instead.
# The design keeps agent / routing / usage / folder — it draws them as a # 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 # 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 # 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. # own strip below. Demanding an empty strip would mean deleting features.
composer = getattr(chat, "composer", None) composer = getattr(chat, "composer", None)
bar = getattr(composer, "extra_bar", None) bar = getattr(composer, "extra_bar", None)
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
typing = composer.input typing = composer.input
in_typing_row = typing.parentWidget() is composer in_typing_row = typing.parentWidget() is composer
below = bar is not None and bar.objectName() == "composerStatus" below = bar is not None and bar.objectName() == "composerStatus"
usage = getattr(chat, "_usage_total_lbl", None) usage = getattr(chat, "_usage_total_lbl", None)
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) 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", 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, below and usage_in_bar,
f"dải riêng={below} · usage nằm trong dải={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") f"{bar.layout().count() if bar else 0} mục")
# --- 6 Co4E --- # --- 6 Co4E ---
add("workspace-co4e", "Bỏ dải tab flow", add("workspace-co4e", "Bỏ dải tab flow",
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") 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", 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") 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", 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") not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
# --- 7 Folder / 8 GraphRAG --- # --- 7 Folder / 8 GraphRAG ---
folder = ws.tabs.widget(ws._folder_tab_idx) folder = ws.tabs.widget(ws._folder_tab_idx)
title_lbl = getattr(folder, "path_lbl", None) title_lbl = getattr(folder, "path_lbl", None)
add("workspace-folder", "Path bar gộp vào tiêu đề", add("workspace-folder", "Path bar gộp vào tiêu đề",
title_lbl is not None and getattr(folder, "path_edit", None) is None, 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") 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 # "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. # 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 # 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. # widgets what state they are in instead of how tall they currently are.
term = getattr(folder, "terminal", None) term = getattr(folder, "terminal", None)
lay = folder.layout() lay = folder.layout()
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
collapsed = term is not None and term._body.isHidden() collapsed = term is not None and term._body.isHidden()
at_bottom = term is not None and last is term at_bottom = term is not None and last is term
add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
collapsed and at_bottom, collapsed and at_bottom,
f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
graph = ws.tabs.widget(ws._graphrag_tab_idx) graph = ws.tabs.widget(ws._graphrag_tab_idx)
# One row = the path box and Export share a y-band. # One row = the path box and Export share a y-band.
def band(w): def band(w):
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
one_row = band(graph.path_edit) == band(graph._export_btn) 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, 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}") 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 # 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 # _msg_btn — a stale name); while it exists, this is still one button whose
# label flips, not a pair of tabs. # label flips, not a pair of tabs.
toggle = getattr(graph, "_msgs_toggle_btn", None) toggle = getattr(graph, "_msgs_toggle_btn", None)
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is 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") "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
# --- 9/15 Monitoring --- # --- 9/15 Monitoring ---
from PySide6.QtWidgets import QHBoxLayout, QScrollArea from PySide6.QtWidgets import QHBoxLayout, QScrollArea
from PySide6.QtWidgets import QSpinBox as QSpinBoxT from PySide6.QtWidgets import QSpinBox as QSpinBoxT
ov = mon.findChildren(QScrollArea)[0].widget() # The Overview column is not simply the first scroll area any more — the
one_col = not isinstance(ov.layout(), QHBoxLayout) # event tables' detail panels are scroll areas too, and are built first
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col, # (e6adfd9). Pick the one that actually holds Overview's own sections.
"cột dọc" if one_col else "vẫn 2 cột") ov = None
# Its own section = it is a direct child of the single column, not sharing a for area in mon.findChildren(QScrollArea):
# row with the resource meters as it used to. body = area.widget()
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 if body is None or body.layout() is None:
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, continue
f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px") if body.layout().indexOf(mon.ov_usage_group) >= 0:
strip = not mon.tabs.tabBar().isHidden() ov = body
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", break
strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab") assert ov is not None, "khong tim thay cot Tong quan"
one_col = not isinstance(ov.layout(), QHBoxLayout)
# --- 17/18 dialogs --- add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
from cowork_local.ui.settings_dialog import SettingsDialog "cột dọc" if one_col else "vẫn 2 cột")
from cowork_local.ui.task_editor_dialog import TaskEditorDialog # Its own section = it is a direct child of the single column, not sharing a
s = SettingsDialog(win.ctx) # row with the resource meters as it used to.
add("dialog-settings", "Thêm cột mục lục bên trái", own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
s.section_list.count() == 5, f"{s.section_list.count()} mục") add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
# left as its own group — not everything merged together. strip = not mon.tabs.tabBar().isHidden()
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
in_general = s._general_box.isAncestorOf(s.language_combo) and \ strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
s._general_box.isAncestorOf(s.theme_combo)
prov_apart = not s._general_box.isAncestorOf(s.provider_combo) # --- 17/18 dialogs ---
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", from cowork_local.ui.settings_dialog import SettingsDialog
in_general and prov_apart, from cowork_local.ui.task_editor_dialog import TaskEditorDialog
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") s = SettingsDialog(win.ctx)
n_switch = len(s.findChildren(ToggleSwitch)) add("dialog-settings", "Thêm cột mục lục bên trái",
n_seg = len(s.findChildren(SegmentedControl)) s.section_list.count() == 5, f"{s.section_list.count()} mục")
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2] # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
add("dialog-settings", # left as its own group — not everything merged together.
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper", from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1, in_general = s._general_box.isAncestorOf(s.language_combo) and \
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") s._general_box.isAncestorOf(s.theme_combo)
s.close() prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
t = TaskEditorDialog(ctx=win.ctx) add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] in_general and prov_apart,
like_settings = (t.section_list.count() == 5 f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
and t.section_stack.count() == 5 n_switch = len(s.findChildren(ToggleSwitch))
and not hasattr(t, "step_tabs")) n_seg = len(s.findChildren(SegmentedControl))
add("dialog-task-editor", steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", add("dialog-settings",
like_settings, " · ".join(rows)) "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
t.close() n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
# --- 27 help dock --- s.close()
# The page says 26px. The user asked for it doubled — recorded here rather t = TaskEditorDialog(ctx=win.ctx)
# than scored against a number the app no longer intends. rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
from cowork_local.ui.help_agent_widget import _DOT like_settings = (t.section_list.count() == 5
and t.section_stack.count() == 5
add("overlay-help-panel", and not hasattr(t, "step_tabs"))
f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)", add("dialog-task-editor",
dock.width() == _DOT and not dock.launcher.text().strip(), "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
f"{dock.width()}px") like_settings, " · ".join(rows))
from cowork_local.i18n import tr t.close()
dock.launcher._set_open(True)
app.processEvents() # --- 27 help dock ---
add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'", # The page says 26px. The user asked for it doubled — recorded here rather
tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip()) # than scored against a number the app no longer intends.
dock.launcher._set_open(False) from cowork_local.ui.help_agent_widget import _DOT
# 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 add("overlay-help-panel",
# to it already does, and "ẩn". Recorded as a deliberate deviation rather f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)",
# than quietly scored as done — the action itself moved to a right-click on dock.width() == _DOT and not dock.launcher.text().strip(),
# the dot and on the panel header, so nothing became unreachable. f"{dock.width()}px")
from PySide6.QtCore import Qt from cowork_local.i18n import tr
dock.launcher._set_open(True)
has_menu = hasattr(dock, "more_btn") app.processEvents()
by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải", tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
(not has_menu) and by_right_click, dock.launcher._set_open(False)
"menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề") # The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for
add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn") # that menu to go: its two entries were "thu nhỏ", which the − button next
dock._hide_to_edge() # to it already does, and "ẩn". Recorded as a deliberate deviation rather
app.processEvents() # than quietly scored as done — the action itself moved to a right-click on
add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px") # the dot and on the panel header, so nothing became unreachable.
dock._show_launcher() from PySide6.QtCore import Qt
app.processEvents()
goto(ws._cowork_tab_idx) has_menu = hasattr(dock, "more_btn")
comp = chat.composer by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu
dock_top = dock.mapTo(win, dock.rect().topLeft()).y() add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải",
comp_top = comp.mapTo(win, comp.rect().topLeft()).y() (not has_menu) and by_right_click,
add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập", "menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề")
dock_top + dock.height() <= comp_top, add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}") dock._hide_to_edge()
app.processEvents()
# --- coverage: is every bullet ON THE PAGE actually probed? ------------- add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
proposals = page_proposals() dock._show_launcher()
probed = {} app.processEvents()
for slug, text, _v, _e in R: goto(ws._cowork_tab_idx)
probed.setdefault(slug, 0) comp = chat.composer
probed[slug] += 1 dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
gaps = [] comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
for slug, bullets in proposals.items(): add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
n_probe = probed.get(slug, 0) dock_top + dock.height() <= comp_top,
if len(bullets) > n_probe: f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
for extra in bullets[n_probe:]:
gaps.append((slug, extra)) # --- coverage: is every bullet ON THE PAGE actually probed? -------------
proposals = page_proposals()
# --- report --- probed = {}
order = ["OK", "KHAC", "CHUA", "TAY"] for slug, text, _v, _e in R:
counts = {k: 0 for k in order} probed.setdefault(slug, 0)
cur = None probed[slug] += 1
for slug, text, verdict, ev in R: gaps = []
counts[verdict] = counts.get(verdict, 0) + 1 for slug, bullets in proposals.items():
if slug != cur: n_probe = probed.get(slug, 0)
print(f"\n{slug}") if len(bullets) > n_probe:
cur = slug for extra in bullets[n_probe:]:
print(f" [{verdict:4}] {text}") gaps.append((slug, extra))
print(f" {ev}")
if gaps: # --- report ---
print() order = ["OK", "KHAC", "CHUA", "TAY"]
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***") counts = {k: 0 for k in order}
for slug, text in gaps: cur = None
print(f" {slug}") for slug, text, verdict, ev in R:
print(f" {text[:160]}") counts[verdict] = counts.get(verdict, 0) + 1
print() if slug != cur:
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}" print(f"\n{slug}")
f" · da co phep do : {len(R)}") cur = slug
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order)) print(f" [{verdict:4}] {text}")
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})") print(f" {ev}")
print(f" KHAC = co y lam khac, da ghi ly do") if gaps:
print(f" CHUA = chua lam") print()
# This used to return 0 unconditionally — a report, not a check. Every probe print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
# in it was therefore unable to fail, so a regression would have been shown for slug, text in gaps:
# on screen and still exited green for any script that only reads the code. print(f" {slug}")
return 1 if counts.get("CHUA") else 0 print(f" {text[:160]}")
print()
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
if __name__ == "__main__": f" · da co phep do : {len(R)}")
_rc = main() print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
# Qt (WebEngine especially) crashes during interpreter teardown with print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
# 0xC0000409 AFTER the work is done, which would mask the real result — print(f" KHAC = co y lam khac, da ghi ly do")
# and check_probes_bite reads these exit codes to decide whether a probe print(f" CHUA = chua lam")
# caught its mutation. Leave immediately with the verdict instead. # This used to return 0 unconditionally — a report, not a check. Every probe
sys.stdout.flush() # in it was therefore unable to fail, so a regression would have been shown
sys.stderr.flush() # on screen and still exited green for any script that only reads the code.
os._exit(_rc) 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)