diff --git a/tools/capture_screens.py b/tools/capture_screens.py index b7c2a53..bdfdd0d 100644 --- a/tools/capture_screens.py +++ b/tools/capture_screens.py @@ -37,6 +37,63 @@ OUT_DIR = REPO / "docs" / "screens" THEMES = ("dark", "light") +def _own_member(widget, name: str) -> bool: + """True when `name` is declared by the app on `widget`, not inherited from Qt. + + Instance attributes live in ``vars(widget)``; methods live on the class, so + both are checked. Only classes defined inside ``cowork_local`` count, so a + Qt base class that happens to use the same name can never be mistaken for + the app's own control. + """ + if name in vars(widget): + return True + for base in type(widget).__mro__: + if getattr(base, "__module__", "").startswith("cowork_local") and name in vars(base): + return True + return False + + +def owner_of(root, name: str): + """The widget in `root`'s subtree that actually holds `name` today. + + EPIC R08 split every screen's god-widget into child widgets, so a control + that used to be ``tab.gran_combo`` now lives at ``tab.chart.gran_combo``, + and ``ScheduleTaskTab.columns`` moved to ``ScheduleTaskTab.kanban.columns``. + Checkers ask for a control by name and get back whichever widget owns it + today, so a further split does not break them again — while a control that + is genuinely gone still returns ``None`` and is still reported as a loss. + + Breadth-first, so the shallowest owner wins if a name appears twice. + """ + from PySide6.QtWidgets import QWidget + + seen: set[int] = set() + queue = [root] + while queue: + w = queue.pop(0) + if id(w) in seen: + continue + seen.add(id(w)) + if _own_member(w, name): + return w + queue.extend(c for c in w.children() if isinstance(c, QWidget)) + return None + + +def control(root, name: str, default=None): + """The control named `name` anywhere in `root`'s subtree — see `owner_of`. + + Falls back to a plain ``getattr`` on `root` so a screen that already bridges + its old attribute names itself keeps working: ``MonitoringTab.__getattr__`` + maps ``ov_*`` onto the extracted tabs, and a name served that way is on no + widget's ``__dict__`` for `owner_of` to find. + """ + holder = owner_of(root, name) + if holder is not None: + return getattr(holder, name, default) + return getattr(root, name, default) + + def _isolate_home() -> Path: """Copy the real config dir into a temp HOME and repoint the env at it.""" real = Path.home() / ".cowork_local" diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py index 9979d26..0fe36c6 100644 --- a/tools/check_controls_alive.py +++ b/tools/check_controls_alive.py @@ -25,7 +25,9 @@ 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 +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of, +) # 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 @@ -82,6 +84,11 @@ MOVED = { }, "ui\\structure_graph_view.py": { "self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)", + # R08-T14 split the screen and renamed these two on the way out. Both + # are still on screen, under a new owner and a new name, so they are + # deliberate moves rather than losses. + "self._ag_collapse": "→ GraphQaWidget._collapse_btn (nút thu gọn bảng Agent)", + "self._msgs_view": "→ GraphMessagesView.widget (cây Tin nhắn theo ngày)", }, "app.py": { "self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)", @@ -163,8 +170,9 @@ def main() -> int: .read_text(encoding="utf-8")) own = owners(win) - alive = dead = moved = skipped = other_class = 0 + alive = dead = moved = skipped = other_class = relocated = 0 losses: list[tuple[str, str, str]] = [] + moves: list[tuple[str, str, str]] = [] for rec in index: holder = own.get(rec["file"]) if holder is None: @@ -187,14 +195,28 @@ def main() -> int: 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 "?")) + # EPIC R08 extracted sub-widgets out of every screen, so a + # control can still be on screen while no longer being a direct + # attribute of the screen's own widget (FolderTab.mode_btn -> + # FolderTab.preview.mode_btn). Searching the subtree keeps the + # subtraction test honest: it still fails on a control that is + # genuinely gone, but a relocation now reads as a relocation. + sub = owner_of(holder, name) + if sub is not None: + relocated += 1 + moves.append((rec["file"], var, type(sub).__name__)) + 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"doi cho khi tach : {relocated} (con tren man, nam trong widget con)") + for f, var, own in sorted(moves): + print(f" {var:26} -> {own}.{var.split('.', 1)[1]}") 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}") diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py index 0c856a2..72fcb40 100644 --- a/tools/check_dashboard.py +++ b/tools/check_dashboard.py @@ -19,7 +19,7 @@ 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, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts, control # noqa: E402 HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn", "gran_combo", "metric_combo", "currency_lbl", "currency_combo", @@ -42,7 +42,11 @@ def main() -> int: from cowork_local.i18n import set_language from cowork_local.state import AppContext - from cowork_local.ui.dashboard_tab import DashboardTab + # R08-T13 moved the screen out of ui/ into presentation/dashboard/ and + # split its header controls across UsageChartWidget / HabitsWidget, so + # every control below is looked up through `control()` rather than as a + # direct attribute of the tab. + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab set_language("vi") tab = DashboardTab(AppContext(AppConfig.load())) @@ -53,7 +57,7 @@ def main() -> int: app.processEvents() fails: list[str] = [] - missing = [n for n in HEADER if getattr(tab, n, None) is None] + missing = [n for n in HEADER if control(tab, n) is None] print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}") if missing: fails.append(f"mat control: {missing}") @@ -61,7 +65,7 @@ def main() -> int: # Two rows: everything in the header must sit at one of exactly two y bands. tops = {} for n in HEADER: - w = getattr(tab, n) + w = control(tab, n) tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n) print(f"so hang cua header : {len(tops)}") for band, names in sorted(tops.items()): @@ -70,14 +74,15 @@ def main() -> int: fails.append(f"header co {len(tops)} hang, cho 2") # Still wired: changing the metric must not throw and must stick. - before = tab.metric_combo.currentData() - tab.metric_combo.setCurrentIndex(1 - tab.metric_combo.currentIndex()) + metric = control(tab, "metric_combo") + before = metric.currentData() + metric.setCurrentIndex(1 - metric.currentIndex()) app.processEvents() - after = tab.metric_combo.currentData() + after = metric.currentData() print(f"doi chi so bieu do : {before} -> {after}") if after == before: fails.append("combo chi so khong doi duoc") - tab.refresh_btn.click() + control(tab, "refresh_btn").click() app.processEvents() print("bam Lam moi : khong loi") diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index 30148bd..0567c49 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -25,7 +25,16 @@ 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 +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control, +) + +# EPIC R08 split every screen's god-widget into child widgets, so controls +# this file used to read straight off the screen object now live one level +# down (DashboardTab.chart.gran_combo, ScheduleTaskTab.kanban.columns, the +# Monitoring Overview sections, the Settings sub-pages...). `control()` +# looks a name up anywhere in the screen's subtree, so this checker keeps +# measuring the real control and still returns None when one is truly gone. def page_proposals(): @@ -107,7 +116,7 @@ def main() -> int: """How many distinct y-bands the named widgets occupy.""" bands = set() for n in names: - w = getattr(widget, n, None) + w = control(widget, n) if w is not None: bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) return len(bands) @@ -123,29 +132,31 @@ def main() -> int: "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() + card_cost = control(dash, "card_cost") + card_total = control(dash, "card_total") + taller = card_cost.height() > card_total.height() * 1.5 + bigger = "34px" in 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"cao {card_cost.height()}px vs thẻ phụ {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 [])) + lanes = len(control(sched, "columns") 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 + has_combo = control(sched, "view_combo") 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") + run_col = (control(sched, "columns") or {}).get("running") styled = "" if run_col is not None: from PySide6.QtWidgets import QListWidgetItem run_col.addItem(QListWidgetItem("probe")) - sched.column_headers["running"].setStyleSheet("") + control(sched, "column_headers")["running"].setStyleSheet("") sched.refresh() app.processEvents() styled = run_col.styleSheet() @@ -184,13 +195,13 @@ def main() -> int: # 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) + composer = control(chat, "composer") + bar = control(composer, "extra_bar") 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 = control(chat, "_usage_total_lbl") 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, @@ -199,23 +210,25 @@ def main() -> int: # --- 6 Co4E --- add("workspace-co4e", "Bỏ dải tab flow", - not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") + not (control(co4e, "flow_scroll").isVisible() + or control(co4e, "flow_add_btn").isVisible()), "đã ẩn") + sections = control(co4e, "_sections") or [] 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(sections) >= 3, f"{len(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") + not control(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) + title_lbl = control(folder, "path_lbl") 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 control(folder, "path_edit") 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) + term = control(folder, "terminal") lay = folder.layout() last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None collapsed = term is not None and term._body.isHidden() @@ -227,13 +240,14 @@ def main() -> int: # 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) + g_path, g_export = control(graph, "path_edit"), control(graph, "_export_btn") + one_row = band(g_path) == band(g_export) 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(g_path) * 10} · Export y≈{band(g_export) * 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) + toggle = control(graph, "_msgs_toggle_btn") 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") @@ -248,7 +262,7 @@ def main() -> int: body = area.widget() if body is None or body.layout() is None: continue - if body.layout().indexOf(mon.ov_usage_group) >= 0: + if body.layout().indexOf(control(mon, "ov_usage_group")) >= 0: ov = body break assert ov is not None, "khong tim thay cot Tong quan" @@ -257,25 +271,29 @@ def main() -> int: "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 + pricing = control(mon, "ov_pricing_group") + own = ov.layout().indexOf(pricing) >= 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() + f"là mục riêng trong cột, rộng {pricing.width()}px") + mon_tabs = control(mon, "tabs") + 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") + 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) + s_list = control(s, "section_list") 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") + s_list.count() == 5, f"{s_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) + gen_box = control(s, "_general_box") + in_general = gen_box.isAncestorOf(control(s, "language_combo")) and \ + gen_box.isAncestorOf(control(s, "theme_combo")) + prov_apart = not gen_box.isAncestorOf(control(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}") @@ -288,10 +306,11 @@ def main() -> int: 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")) + t_list, t_stack = control(t, "section_list"), control(t, "section_stack") + rows = [t_list.item(i).text() for i in range(t_list.count())] + like_settings = (t_list.count() == 5 + and t_stack.count() == 5 + and control(t, "step_tabs") is None) 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)) diff --git a/tools/check_graphrag_rescan.py b/tools/check_graphrag_rescan.py index 860a50c..79535fc 100644 --- a/tools/check_graphrag_rescan.py +++ b/tools/check_graphrag_rescan.py @@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from capture_screens import ( # noqa: E402 - _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of) def main() -> int: @@ -50,6 +50,10 @@ def main() -> int: win.show() app.processEvents() st, w = win.structure, win.workspace + # R08-T14 split StructureGraphView: the browser view, the cached graph + # and the scan/render steps all moved onto GraphRenderer, while the shell + # only forwards the public methods. Probe the widget that owns them. + st = owner_of(st, "web") or st fails: list[str] = [] # startup itself must not build any of it diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py index e806a5e..5a0cce8 100644 --- a/tools/check_layout_geometry.py +++ b/tools/check_layout_geometry.py @@ -1,213 +1,220 @@ -"""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, the dot). - -Run: python tools/check_layout_geometry.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 - -# 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, in the order the wireframe stacks it: what it cost → -# what the machine is doing → what the agent may touch → per-model prices → -# what actually happened. -MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", - "ov_pricing_group", "ov_activity_group", "ov_audit_group"] - - -def main() -> int: - sandbox = _isolate_home() - from PySide6.QtWidgets import QApplication - - app = QApplication([]) - _load_fonts() - _freeze_schedulers() - - _apply_theme(app) # measure the styled window, not a bare one - from cowork_local.config import AppConfig, CONFIG_DIR - assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" - - from seed_demo_data import seed - seed() - - from cowork_local.app import MainWindow - from cowork_local.i18n import set_language - from cowork_local.state import AppContext - - set_language("vi") - win = MainWindow(AppContext(AppConfig.load()), user_name="local") - win.resize(1600, 950) - win.show() - 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, and is the declared size -- - 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}") - # Reading _DOT and comparing against it makes this unfailable — change the - # constant and the expectation moves with it (check_probes_bite caught - # exactly that). Bound what the design actually claims instead: a square - # chip, big enough to hit, far smaller than the 84x64 button it replaced. - # 26px was drawn, 52px is what the user asked for; 64 is the ceiling past - # which "gọn" stops being true. - if not 24 <= dock.width() <= 64 or dock.width() != dock.height(): - fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, " - f"cho o khoang 24..64 va phai vuong") - 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__": - _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) +"""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, the dot). + +Run: python tools/check_layout_geometry.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 ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control, +) + +# 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, in the order the wireframe stacks it: what it cost → +# what the machine is doing → what the agent may touch → per-model prices → +# what actually happened. +MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", + "ov_pricing_group", "ov_activity_group", "ov_audit_group"] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + 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 + # R08-T11 moved the Kanban lanes onto KanbanBoardWidget; the shell only + # holds the header and the view switch. + lanes = list(control(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] + # R08-T13 moved the stat cards onto TokenUsageCardWidget. + hero, small = control(dash, "card_cost"), control(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(control(dash, "card_total"), dash) + row2 = top_of(control(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, and is the declared size -- + win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) + for _ in range(8): + app.processEvents() + dock = win.help_agent + comp = control(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}") + # Reading _DOT and comparing against it makes this unfailable — change the + # constant and the expectation moves with it (check_probes_bite caught + # exactly that). Bound what the design actually claims instead: a square + # chip, big enough to hit, far smaller than the 84x64 button it replaced. + # 26px was drawn, 52px is what the user asked for; 64 is the ceiling past + # which "gọn" stops being true. + if not 24 <= dock.width() <= 64 or dock.width() != dock.height(): + fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, " + f"cho o khoang 24..64 va phai vuong") + 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] + c4_split = control(c4, "_split") + 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 (control(c4, "_sections") or {}).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__": + _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) diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py index efa2ba7..d957992 100644 --- a/tools/check_probes_bite.py +++ b/tools/check_probes_bite.py @@ -33,7 +33,9 @@ MUTATIONS = [ "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", "check_layout_geometry.py"), ("tra lane Running ve khong vien", - "ui/schedule_task_tab.py", + # R08-T11 doi cho: ScheduleTaskTab tach ra, phan ve Kanban (ke ca vien + # canh bao cua lane Running) nam o presentation/scheduling/. + "presentation/scheduling/kanban_board_widget.py", 'if status == "running" and counts[status]:', 'if False:', "check_design_parity.py"),