test(ui): five verification rounds, and two things they found

Five rounds, each looking from an angle the previous one cannot:

  1. text     — every "Thay đổi" bullet on the page vs a probe (existing)
  2. geometry — check_layout_geometry.py: reading order of the rail, section
                order down Monitoring, which side each pane is on, and the size
                relationships the design names (hero card, 26px dot)
  3. inventory— check_controls_alive.py: every control in controls.json still
                present in the built app, attributed to its owning CLASS via the
                baseline commit (a file holds several classes, so a per-file
                check reported eight dialog controls as missing)
  4. eyes     — rendered screens read against the wireframes
  5. adversarial — check_probes_bite.py: break one feature at a time and fail if
                the matching check still passes

What rounds 4 and 5 caught, which 1-3 could not:

  * Schedule showed six of seven lanes; the seventh needed a horizontal
    scroll. The design says "giữ đủ 7 lane, thu hẹp cho vừa một màn". Lane
    minimum width 190 → 150, so 7 × 150 + gaps fits a 1280 window. Round 2 now
    measures this instead of relying on someone noticing.

  * check_design_parity ALWAYS returned 0. It was a report, not a check: every
    probe in it was incapable of failing, so a regression would print on screen
    and still exit green. It now exits non-zero when anything is CHUA — which
    is what let round 5 detect the two mutations it had been sleeping through.

Also fixed in the harness itself: it rewrote line endings while restoring
mutated files (read_text/write_text translate both ways), and it compared the
tree against "clean" rather than against its own starting state.

All ten checkers green by exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 13:55:47 +09:00
co-authored by Claude Opus 5
parent 62bec1f984
commit f381fd298a
5 changed files with 535 additions and 2 deletions
+202
View File
@@ -0,0 +1,202 @@
"""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
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 _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()
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__":
raise SystemExit(main())
+4 -1
View File
@@ -346,7 +346,10 @@ def main() -> int:
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")
return 0
# 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__":
+194
View File
@@ -0,0 +1,194 @@
"""Round 2: does the built layout have the SHAPE the wireframes draw?
Round 1 asks "does the feature exist". A screen can pass that and still be laid
out wrongly — right widgets, wrong order, wrong side, wrong proportions. This
round measures real geometry against what the audit page's wireframes depict:
reading order of the rail, section order down Monitoring, which side each pane
is on, and the size relationships the design calls out (hero card, 26px dot).
Run: python tools/check_layout_geometry.py
"""
from __future__ import annotations
import os
import sys
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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
# The rail, top to bottom, as the audit page's rail() helper draws it.
RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"]
RAIL_BOTTOM = ["Dashboard", "Giám sát"]
# Monitoring ▸ Tổng quan, as the wireframe stacks it.
MON_ORDER = ["ov_usage_group", "ov_activity_group", "ov_resource_group",
"ov_sandbox_details_group", "ov_pricing_group", "ov_audit_group"]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
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()
for _ in range(8):
app.processEvents()
ws = win.workspace
fails: list[str] = []
def top_of(w, ref):
return w.mapTo(ref, w.rect().topLeft()).y()
def left_of(w, ref):
return w.mapTo(ref, w.rect().topLeft()).x()
# --- 1. rail: reading order, and the rail is on the LEFT ---------------
rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
bottom = [win.nav_bottom.topLevelItem(i).text(0)
for i in range(win.nav_bottom.topLevelItemCount())]
print(f"thanh menu : {rows}")
print(f"nhom day : {bottom}")
if rows != RAIL_ORDER:
fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}")
if bottom != RAIL_BOTTOM:
fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}")
rail_x = left_of(win._nav_wrap, win)
content_x = left_of(win.pages, win)
print(f"rail x={rail_x} · noi dung x={content_x}")
if rail_x >= content_x:
fails.append("rail khong nam ben trai noi dung")
# --- 2. rail header order: picker ABOVE the new-chat button ------------
py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win)
ry = top_of(win.nav_recents, win)
ay = top_of(win._account_row, win)
print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}")
if not (py < by < ry < ay):
fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)")
# --- 3. Monitoring: one column, sections in the drawn order ------------
win._goto(win._ROW_MONITORING, None)
for _ in range(8):
app.processEvents()
mon = win._page_widgets[win._ROW_MONITORING]
tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)]
lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops}
print("Monitoring, tu tren xuong:")
for n, y in tops:
print(f" {n:28} y={y:5} x={lefts[n]}")
if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]:
fails.append("thu tu muc trong Monitoring khong khop ban ve")
# Sandbox and Permissions share a row; everything else is full width.
perm_y = top_of(mon.ov_permissions_group, mon)
sbx_y = top_of(mon.ov_sandbox_details_group, mon)
same_row = abs(perm_y - sbx_y) < 20
print(f"Sandbox | Quyen cung hang: {same_row}")
if not same_row:
fails.append("Sandbox va Quyen khong cung mot hang")
price_w = mon.ov_pricing_group.width()
res_w = mon.ov_resource_group.width()
print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)")
if price_w < res_w * 0.95:
fails.append("bang gia model khong chiem tron be ngang")
# --- 3b. Schedule: all seven lanes on screen, no horizontal scroll -----
win._goto(win._ROW_SCHEDULE, None)
for _ in range(8):
app.processEvents()
sched = win._page_widgets[win._ROW_SCHEDULE]
from PySide6.QtWidgets import QScrollArea
lanes = list(sched.columns.values())
# The page holds more than one scroll area — take the one the lanes live in.
board = next(sa for sa in sched.findChildren(QScrollArea)
if sa.isAncestorOf(lanes[0]))
rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes)
fits = rightmost <= board.viewport().width() + 2
print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · "
f"khung rong {board.viewport().width()} · vua mot man = {fits}")
if len(lanes) != 7:
fails.append(f"chi co {len(lanes)} lane, thiet ke la 7")
if not fits:
fails.append(f"lane thu 7 nam ngoai man ({rightmost} > "
f"{board.viewport().width()}) — phai cuon ngang")
# --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 --------
win._goto(win._ROW_DASHBOARD, None)
for _ in range(8):
app.processEvents()
dash = win._page_widgets[win._ROW_DASHBOARD]
hero, small = dash.card_cost, dash.card_total
print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · "
f"the phu x={left_of(small, dash)} cao={small.height()}")
if left_of(hero, dash) >= left_of(small, dash):
fails.append("the Chi phi khong nam ben trai cac the phu")
if hero.height() < small.height() * 1.5:
fails.append("the Chi phi khong cao gap ruoi the phu")
row1 = top_of(dash.card_total, dash)
row2 = top_of(dash.card_out, dash)
print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)")
if row2 <= row1:
fails.append("4 the phu khong xep 2x2")
# --- 5. Cowork: the dot clears the composer, dot is 26px --------------
win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx)
for _ in range(8):
app.processEvents()
dock = win.help_agent
comp = ws._cowork.composer
dock_bottom = top_of(dock, win) + dock.height()
comp_top = top_of(comp, win)
print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}")
if dock.width() > 30:
fails.append(f"cham tro ly rong {dock.width()}px, thiet ke la 26px")
if dock_bottom > comp_top:
fails.append("cham tro ly de len o nhap")
if left_of(dock, win) + dock.width() > win.width():
fails.append("cham tro ly tran ra ngoai cua so")
# --- 6. Co4E: sidebar left, canvas middle, config right ---------------
win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx)
for _ in range(8):
app.processEvents()
import cowork_local.ui.co4e_tab as co4e_mod
c4 = win.findChildren(co4e_mod.Co4ETab)[0]
xs = [c4._split.widget(i).x() for i in range(c4._split.count())]
print(f"Co4E 3 pane x = {xs}")
if xs != sorted(xs):
fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)")
heads = [h.text() for h, _b, _s in c4._sections.values()]
print(f"cot sidebar: {heads}")
if len(heads) != 4:
fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4")
print()
if fails:
print("*** LECH BO CUC ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA VONG 2: hinh hoc khop ban ve")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
"""Round 5: do the checks actually bite?
Rounds 1–4 all report green. That is only worth something if the checks would
have turned red had the work not been done. So this round breaks the app on
purpose, one feature at a time, and fails if the corresponding check still
passes — a check that cannot fail is not evidence.
Each mutation is applied by monkey-patching the module BEFORE the checker
builds its own window, then undone.
Run: python tools/check_probes_bite.py
"""
from __future__ import annotations
import io
import os
import runpy
import subprocess
import sys
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")
# (name, file, find, replace, checker that must FAIL because of it)
MUTATIONS = [
("bo cham tro ly 26px -> 64px",
"ui/help_agent_widget.py", "_DOT = 26", "_DOT = 64",
"check_layout_geometry.py"),
("tra lane Running ve khong vien",
"ui/schedule_task_tab.py",
'if status == "running" and counts[status]:',
'if False:',
"check_design_parity.py"),
("bo cot muc luc cua Cai dat",
"ui/settings_dialog.py",
"self.section_list, self.section_stack = section_panels(pages)",
"self.section_list, self.section_stack = section_panels(pages[:1])",
"check_dialogs.py"),
("noi lai dai tab flow Co4E",
"ui/co4e_tab.py",
"self.flow_scroll.setVisible(False)",
"self.flow_scroll.setVisible(True)",
"check_co4e.py"),
("bo dong 'Tat ca project...' khoi GAN DAY",
"app.py",
'more.setData(0, Qt.UserRole, {"all": True})',
'more.setData(0, Qt.UserRole, {})',
"check_design_parity.py"),
("tra thanh menu ve accordion (bo nhom day)",
"app.py",
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
"check_layout_geometry.py"),
]
def run_checker(script: str) -> int:
"""Run a checker in a fresh process; return its exit code."""
proc = subprocess.run(
[sys.executable, str(REPO / "tools" / script)],
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
"PYTHONIOENCODING": "utf-8"})
return proc.returncode
def tree_state() -> str:
return subprocess.run(["git", "status", "--short"], cwd=REPO,
capture_output=True, text=True).stdout.strip()
def main() -> int:
fails: list[str] = []
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
# progress is legitimately uncommitted, and demanding a clean tree made this
# round fail for a reason that has nothing to do with the mutations.
before = tree_state()
print(f"{'hong gi':44} {'phep do':26} ket qua")
print("-" * 88)
for name, rel, find, repl, checker in MUTATIONS:
path = REPO / rel
# newline="" both ways: the default translates on read AND write, so a
# LF file came back as CRLF and every mutated file was left "modified"
# even after being restored.
with io.open(path, "r", encoding="utf-8", newline="") as fh:
original = fh.read()
if find not in original:
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
continue
def write(text: str) -> None:
with io.open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
write(original.replace(find, repl, 1))
try:
code = run_checker(checker)
finally:
write(original) # always restore
bit = code != 0
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
if not bit:
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
# Everything must be back exactly as it was before this run.
after = tree_state()
same = after == before
print()
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
if not same:
print(" truoc:", before.replace("\n", " | ") or "(sach)")
print(" sau :", after.replace("\n", " | ") or "(sach)")
fails.append("file chua duoc khoi phuc sau khi thu")
print()
if fails:
print("*** VONG 5 THAT BAI ***")
for f in fails:
print(" " + f)
return 1
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -1
View File
@@ -53,7 +53,11 @@ class _KanbanColumn(QListWidget):
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setWordWrap(True)
self.setMinimumWidth(190)
# Narrow enough that all SEVEN lanes fit on one screen, which is what
# the design asks for — at 190 the seventh (Paused) fell off the right
# edge and needed a horizontal scroll to reach.
# 7 × 150 + 6 gaps = 1098px, inside the content area of a 1280 window.
self.setMinimumWidth(150)
def dropEvent(self, event): # noqa: N802
source = event.source()