diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py new file mode 100644 index 0000000..31ead91 --- /dev/null +++ b/tools/check_controls_alive.py @@ -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()) diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index d335642..cf07c08 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -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__": diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py new file mode 100644 index 0000000..52e1305 --- /dev/null +++ b/tools/check_layout_geometry.py @@ -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()) diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py new file mode 100644 index 0000000..a8fa372 --- /dev/null +++ b/tools/check_probes_bite.py @@ -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()) diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index 28e22f4..8d888d4 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -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()