## Summary What changed and why? ## Change Type - [ ] Cowork feature - [ ] Bug fix - [x] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Co-authored-by: NamPDT <minhanhpkpro@gmail.com> Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""List every show/hide/enable/disable rule, baseline vs now.
|
||||
|
||||
The redesign was allowed to change the flow. It was NOT allowed to change what
|
||||
is hidden or greyed out — those rules encode real preconditions, and dropping
|
||||
one turns a guarded action into a broken one.
|
||||
|
||||
Reports rules that disappeared, appeared, or changed target between the
|
||||
pre-redesign commit and HEAD.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
BASE = "291a611"
|
||||
CALL = re.compile(
|
||||
r"(?P<target>[\w\.\[\]\(\)_]*?)\.?(?P<verb>setVisible|setHidden|setEnabled|"
|
||||
r"setDisabled|setTabVisible|setTabEnabled|hide|show)\s*\(")
|
||||
|
||||
|
||||
def files():
|
||||
out = subprocess.run(["git", "diff", "--name-only", f"{BASE}..HEAD", "--", "*.py"],
|
||||
capture_output=True, text=True, encoding="utf-8").stdout
|
||||
return [f for f in out.split() if f.endswith(".py") and not f.startswith("tools/")]
|
||||
|
||||
|
||||
def _arg(s, start):
|
||||
"""Text inside the call's parentheses — the CONDITION, which matters as
|
||||
much as the call being there at all."""
|
||||
depth, out = 0, []
|
||||
for ch in s[start:]:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
continue
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
if depth >= 1:
|
||||
out.append(ch)
|
||||
return "".join(out).strip()
|
||||
|
||||
|
||||
def rules(rev, path):
|
||||
"""{(target, verb): set(conditions)} for one revision of one file."""
|
||||
src = subprocess.run(["git", "show", f"{rev}:{path}"],
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace").stdout or ""
|
||||
found = {}
|
||||
for n, line in enumerate(src.splitlines(), 1):
|
||||
s = line.strip()
|
||||
if s.startswith("#") or s.startswith('"'):
|
||||
continue
|
||||
for m in CALL.finditer(s):
|
||||
target, verb = m.group("target"), m.group("verb")
|
||||
if not target or (verb in ("hide", "show") and not target):
|
||||
continue
|
||||
cond = _arg(s, m.end() - 1) or "-"
|
||||
found.setdefault((target, verb), {}).setdefault(cond, n)
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
gone, added, changed = [], [], []
|
||||
for path in files():
|
||||
old, new = rules(BASE, path), rules("HEAD", path)
|
||||
for key in sorted(set(old) - set(new)):
|
||||
gone.append((path, key, old[key]))
|
||||
for key in sorted(set(new) - set(old)):
|
||||
added.append((path, key, new[key]))
|
||||
for key in sorted(set(new) & set(old)):
|
||||
if set(old[key]) != set(new[key]):
|
||||
changed.append((path, key, old[key], new[key]))
|
||||
|
||||
print(f"=== A. LUAT BI BO ({len(gone)}) ===")
|
||||
for path, (target, verb), conds in gone:
|
||||
for cond, line in conds.items():
|
||||
print(f" {path}:{line:<5} {target}.{verb}({cond})")
|
||||
|
||||
print()
|
||||
print(f"=== B. DIEU KIEN DOI ({len(changed)}) ===")
|
||||
for path, (target, verb), oldc, newc in changed:
|
||||
print(f" {path} {target}.{verb}()")
|
||||
for c in sorted(set(oldc) - set(newc)):
|
||||
print(f" cu : ({c})")
|
||||
for c in sorted(set(newc) - set(oldc)):
|
||||
print(f" moi : ({c}) dong {newc[c]}")
|
||||
|
||||
print()
|
||||
print(f"=== C. LUAT MOI THEM ({len(added)}) ===")
|
||||
for path, (target, verb), conds in added:
|
||||
for cond, line in conds.items():
|
||||
print(f" {path}:{line:<5} {target}.{verb}({cond})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page.
|
||||
|
||||
Run: python tools/capture_screens.py
|
||||
|
||||
Two safety measures, both mandatory — this script drives the REAL application:
|
||||
|
||||
1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a
|
||||
module-level constant resolved at import time. We copy that folder to a temp
|
||||
directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing
|
||||
``cowork_local``, so every write the app makes lands in the copy. The user's
|
||||
real data is never opened for writing.
|
||||
|
||||
2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and
|
||||
``RoutingScheduler``, which would *execute the user's scheduled tasks* — real
|
||||
agent turns writing real files. Both ``start`` methods are patched to no-ops
|
||||
before the window is built.
|
||||
|
||||
We also construct ``MainWindow`` directly rather than calling ``app.run()``:
|
||||
``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``.
|
||||
|
||||
Screens that fail to render (QtWebEngine generally cannot initialise offscreen)
|
||||
are recorded in the manifest with their error. They are never silently skipped —
|
||||
the audit page renders an explicit "could not capture" placeholder for them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent # …/cowork_local
|
||||
OUT_DIR = REPO / "docs" / "screens"
|
||||
THEMES = ("dark", "light")
|
||||
|
||||
|
||||
def _isolate_home() -> Path:
|
||||
"""Copy the real config dir into a temp HOME and repoint the env at it."""
|
||||
real = Path.home() / ".cowork_local"
|
||||
sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-"))
|
||||
if real.exists():
|
||||
shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True)
|
||||
else:
|
||||
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
|
||||
for var in ("USERPROFILE", "HOME"):
|
||||
os.environ[var] = str(sandbox)
|
||||
os.environ.pop("HOMEDRIVE", None)
|
||||
os.environ.pop("HOMEPATH", None)
|
||||
return sandbox
|
||||
|
||||
|
||||
def _load_fonts() -> int:
|
||||
"""Register system fonts with the offscreen platform.
|
||||
|
||||
The offscreen plugin ships with NO font database (``QFontDatabase.families()``
|
||||
returns an empty list), so every glyph renders as a tofu box — unusable when
|
||||
the screenshots are the deliverable. Loading the real Windows faces fixes
|
||||
both Latin and Vietnamese diacritics, and Consolas covers the code views.
|
||||
"""
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
|
||||
wanted = [
|
||||
"SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf",
|
||||
"seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf",
|
||||
]
|
||||
root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts"
|
||||
loaded = 0
|
||||
for name in wanted:
|
||||
path = root / name
|
||||
if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1:
|
||||
loaded += 1
|
||||
return loaded
|
||||
|
||||
|
||||
def _apply_theme(app, name: str | None = None) -> str:
|
||||
"""Load the app's real stylesheet onto `app`.
|
||||
|
||||
`MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a
|
||||
checker that builds the window directly measures a window with no padding,
|
||||
no margins and no borders. Every QSS-driven layout bug is invisible there.
|
||||
"""
|
||||
from cowork_local import theme
|
||||
from cowork_local.config import AppConfig
|
||||
|
||||
name = name or AppConfig.load().theme
|
||||
theme.set_active_theme(name)
|
||||
app.setStyleSheet(theme.stylesheet(name))
|
||||
return name
|
||||
|
||||
|
||||
def _freeze_schedulers() -> None:
|
||||
"""No-op the background engines so nothing is executed while we capture."""
|
||||
from cowork_local.core.task_scheduler import TaskScheduler
|
||||
TaskScheduler.start = lambda self: None # type: ignore[assignment]
|
||||
try:
|
||||
from cowork_local.core.routing.scheduler import RoutingScheduler
|
||||
RoutingScheduler.start = lambda self: None # type: ignore[assignment]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
sandbox = _isolate_home()
|
||||
sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication([])
|
||||
|
||||
n_fonts = _load_fonts()
|
||||
print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform")
|
||||
if not n_fonts:
|
||||
print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes")
|
||||
|
||||
_freeze_schedulers()
|
||||
|
||||
import cowork_local.theme as theme
|
||||
from cowork_local.config import AppConfig, CONFIG_DIR
|
||||
from cowork_local.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
assert str(sandbox) in str(CONFIG_DIR), (
|
||||
f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}")
|
||||
print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}")
|
||||
|
||||
# Fill the sandbox with demo data so the screenshots show a working app.
|
||||
# Safe by construction: seed() re-asserts it is inside a capture sandbox.
|
||||
from seed_demo_data import seed
|
||||
counts = seed()
|
||||
print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items()))
|
||||
|
||||
from cowork_local.app import MainWindow
|
||||
|
||||
cfg = AppConfig.load()
|
||||
set_language("vi")
|
||||
ctx = AppContext(cfg)
|
||||
|
||||
manifest: list[dict] = []
|
||||
# Label of the nav row selected right now, recorded into every shot so the
|
||||
# "is the rail pointing at the right thing?" question is machine-checked
|
||||
# instead of eyeballed across 54 images.
|
||||
nav_state = {"label": "", "expected": ""}
|
||||
|
||||
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
|
||||
"""Navigate the way a user does, and record where the rail ends up.
|
||||
|
||||
Since the rail became a flat list, ``_goto`` moves the highlight itself
|
||||
(``_select_nav_row``), so this no longer needs the two-step workaround
|
||||
that existed while selecting a Workspace child destroyed the row being
|
||||
selected.
|
||||
"""
|
||||
win._ensure_page(page)
|
||||
win._goto(page, sub)
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
|
||||
if t.currentItem() is not None and t.currentItem().isSelected()),
|
||||
None)
|
||||
nav_state["label"] = cur.text(0) if cur is not None else ""
|
||||
nav_state["expected"] = expect or nav_state["label"]
|
||||
|
||||
def shot(widget, slug: str, title: str, note: str = "") -> None:
|
||||
"""Grab `widget` for the active theme; record success or the error."""
|
||||
rec = {"slug": slug, "title": title, "theme": theme.current_theme(),
|
||||
"note": note, "file": "", "error": "",
|
||||
"nav": nav_state["label"], "nav_expected": nav_state["expected"]}
|
||||
try:
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
pm = widget.grab()
|
||||
if pm.isNull() or pm.width() < 2:
|
||||
raise RuntimeError("grab() returned an empty pixmap")
|
||||
name = f"{slug}-{theme.current_theme()}.png"
|
||||
pm.save(str(OUT_DIR / name))
|
||||
rec["file"] = f"screens/{name}"
|
||||
print(f" ok {name} ({pm.width()}x{pm.height()})")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
rec["error"] = f"{type(exc).__name__}: {exc}"
|
||||
print(f" FAIL {slug}: {rec['error']}")
|
||||
manifest.append(rec)
|
||||
|
||||
for th in THEMES:
|
||||
print(f"\n=== theme: {th} ===")
|
||||
ctx.config.theme = th
|
||||
theme.set_active_theme(th)
|
||||
app.setStyleSheet(theme.stylesheet(th))
|
||||
|
||||
win = MainWindow(ctx, user_name="local")
|
||||
win.resize(1600, 1000)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
|
||||
# ---- main screens, driven through the app's own navigation API ------
|
||||
ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3
|
||||
|
||||
nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard"))
|
||||
shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35")
|
||||
|
||||
nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule"))
|
||||
sched = win._page_widgets[ROW_SCHED]
|
||||
shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70")
|
||||
try: # combo index 1 == Calendar view
|
||||
sched.view_combo.setCurrentIndex(1)
|
||||
app.processEvents()
|
||||
shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88")
|
||||
sched.view_combo.setCurrentIndex(0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar",
|
||||
"theme": th, "note": "ui/calendar_view.py:88", "file": "",
|
||||
"error": f"{type(exc).__name__}: {exc}"})
|
||||
print(f" FAIL schedule-calendar: {exc}")
|
||||
|
||||
# Workspace: capture with no project selected, then with one selected so
|
||||
# the project-gated sub-tabs (Cowork, GraphRAG) actually exist.
|
||||
nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace"))
|
||||
ws = win.workspace
|
||||
shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188")
|
||||
try:
|
||||
if ws.project_list.count():
|
||||
ws.project_list.setCurrentRow(0)
|
||||
app.processEvents()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
for attr, slug, title, note in (
|
||||
("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"),
|
||||
("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"),
|
||||
("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"),
|
||||
("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"),
|
||||
):
|
||||
idx = getattr(ws, attr, None)
|
||||
if idx is None:
|
||||
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
||||
"file": "", "error": "sub-tab index not present"})
|
||||
continue
|
||||
nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx))
|
||||
shot(win, slug, title, note)
|
||||
|
||||
# Monitoring: enumerate its sub-tabs from the app itself.
|
||||
nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring"))
|
||||
mon = win._page_widgets[ROW_MON]
|
||||
try:
|
||||
subs = mon.nav_subtabs()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
subs = []
|
||||
print(f" FAIL monitoring subtabs: {exc}")
|
||||
for label, sub, _icon in subs:
|
||||
nav_to(win, ROW_MON, sub, expect=label)
|
||||
slug = "monitoring-" + "".join(
|
||||
c.lower() if c.isalnum() else "-" for c in label).strip("-")
|
||||
shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132")
|
||||
|
||||
# Dialogs/overlays below are not nav destinations.
|
||||
nav_state["label"] = nav_state["expected"] = ""
|
||||
|
||||
# ---- dialogs: built directly and shown (never exec(), it blocks) -----
|
||||
for slug, title, note, build in _dialog_specs(ctx, win):
|
||||
try:
|
||||
dlg = build()
|
||||
dlg.show()
|
||||
app.processEvents()
|
||||
shot(dlg, slug, title, note)
|
||||
dlg.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
||||
"file": "", "error": f"{type(exc).__name__}: {exc}"})
|
||||
print(f" FAIL {slug}: {type(exc).__name__}: {exc}")
|
||||
|
||||
# ---- overlays --------------------------------------------------------
|
||||
try:
|
||||
help_dock = win.help_agent
|
||||
help_dock._expand()
|
||||
app.processEvents()
|
||||
shot(help_dock, "overlay-help-panel", "Help dock — expanded panel",
|
||||
"ui/help_agent_widget.py:79")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" FAIL overlay-help-panel: {exc}")
|
||||
|
||||
win.close()
|
||||
|
||||
(OUT_DIR / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
bad_nav = [r for r in manifest
|
||||
if r["nav_expected"] and r["nav"] != r["nav_expected"]]
|
||||
checked = sum(1 for r in manifest if r["nav_expected"])
|
||||
print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}")
|
||||
for r in bad_nav:
|
||||
print(f" MISMATCH {r['slug']} [{r['theme']}]: "
|
||||
f"rail says '{r['nav']}', screen is '{r['nav_expected']}'")
|
||||
|
||||
ok = sum(1 for r in manifest if r["file"])
|
||||
bad = [r for r in manifest if not r["file"]]
|
||||
print(f"\ncaptured {ok}/{len(manifest)}")
|
||||
if bad:
|
||||
print("could NOT capture (recorded in manifest, shown as placeholders):")
|
||||
for r in bad:
|
||||
print(f" - {r['slug']} [{r['theme']}]: {r['error']}")
|
||||
print(f"sandbox (safe to delete): {sandbox}")
|
||||
return 0
|
||||
|
||||
|
||||
def _dialog_specs(ctx, win):
|
||||
"""(slug, title, note, factory) for each dialog we can build headlessly.
|
||||
|
||||
Signatures differ per dialog (some take ctx first, some take parent first,
|
||||
some require a real model object) — each factory below matches the actual
|
||||
``__init__`` it calls, not a guessed one.
|
||||
"""
|
||||
# NOTE: two different classes share the name `CustomAgent` —
|
||||
# core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the
|
||||
# co4e one (it has `.role`); importing the other raises AttributeError.
|
||||
from cowork_local.core.co4e import CustomAgent
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog
|
||||
from cowork_local.ui.file_edit_dialog import FileEditDialog
|
||||
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
|
||||
from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog
|
||||
from cowork_local.ui.permission_dialog import PermissionDialog
|
||||
from cowork_local.ui.agents_admin_tab import AgentEditDialog
|
||||
from cowork_local.ui.login_dialog import LoginDialog
|
||||
|
||||
return [
|
||||
# SettingsDialog(ctx, parent)
|
||||
("dialog-settings", "Settings", "ui/settings_dialog.py:26",
|
||||
lambda: SettingsDialog(ctx, win)),
|
||||
# TaskEditorDialog(task, all_tasks, parent, ctx)
|
||||
("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55",
|
||||
lambda: TaskEditorDialog(None, [], win, ctx)),
|
||||
# SkillsDialog(parent, ctx)
|
||||
("dialog-skills", "Skills manager", "ui/skills_dialog.py:108",
|
||||
lambda: SkillsDialog(win, ctx)),
|
||||
# SkillEditDialog(parent, skill, ctx)
|
||||
("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23",
|
||||
lambda: SkillEditDialog(win, None, ctx)),
|
||||
("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50",
|
||||
lambda: FileEditDialog(ctx, "", win)),
|
||||
# Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real
|
||||
("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23",
|
||||
lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)),
|
||||
# ExtConnectorEditDialog(parent, category, connector)
|
||||
("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23",
|
||||
lambda: ExtConnectorEditDialog(win, "other", None)),
|
||||
# PermissionDialog(action, parent) — `preview` is a dict, not a string
|
||||
("dialog-permission", "Permission request", "ui/permission_dialog.py:13",
|
||||
lambda: PermissionDialog(
|
||||
{"name": "run_command",
|
||||
"preview": {"title": "Run command", "kind": "command",
|
||||
"text": "npm install --save-dev vitest"}}, win)),
|
||||
# AgentEditDialog(parent, ctx, agent, default_model_hint)
|
||||
("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35",
|
||||
lambda: AgentEditDialog(win, ctx, None, "")),
|
||||
("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57",
|
||||
lambda: LoginDialog(ctx, win)),
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Check the Co4E sidebar rearrangement, on the real widget, offscreen.
|
||||
|
||||
Phase D only moved things and added a second door to "new flow". So the test
|
||||
that matters is a subtraction test: every control that existed before must still
|
||||
exist, the flow tab strip (which carries the pinned Runs tab and lets several
|
||||
flows stay open) must be untouched, and the section headings must actually name
|
||||
the list you are looking at — in all three languages.
|
||||
|
||||
Run: python tools/check_co4e.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, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
# Every control the sidebar and the flow area had before these changes.
|
||||
EXPECTED = [
|
||||
"wf_list", "wf_edit_btn", "wf_dup_btn", "wf_del_btn", "wf_runbg_btn",
|
||||
"agent_list", "ag_new_btn", "ag_edit_btn", "ag_del_btn",
|
||||
"skill_list", "sk_manage_btn",
|
||||
# Runs moved off the strip onto a toggle + a back button.
|
||||
"runs_btn", "runs_back_btn", "runs_table", "runs_side_list", "runs_more_btn",
|
||||
"name_edit", "add_step_btn", "save_btn", "save_tpl_btn", "mode_combo", "run_btn",
|
||||
"run_stop_btn", "run_rename_btn", "run_del_btn", "run_clear_btn", "ws_folder_btn",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
|
||||
_apply_theme(app) # measure the styled widget, 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.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.co4e_tab import Co4ETab
|
||||
|
||||
set_language("vi")
|
||||
tab = Co4ETab(AppContext(AppConfig.load()))
|
||||
app.processEvents()
|
||||
|
||||
fails: list[str] = []
|
||||
|
||||
missing = [n for n in EXPECTED if getattr(tab, n, None) is None]
|
||||
print(f"control cu con nguyen : {len(EXPECTED) - len(missing)}/{len(EXPECTED)}")
|
||||
if missing:
|
||||
fails.append(f"mat control: {missing}")
|
||||
|
||||
# The strip is gone from the screen, as the drawing asks.
|
||||
strip_shown = tab.flow_scroll.isVisible() or tab.flow_add_btn.isVisible()
|
||||
print(f"dai tab flow tren man : {strip_shown} (phai la False)")
|
||||
if strip_shown:
|
||||
fails.append("dai tab flow van con hien")
|
||||
|
||||
# What the strip carried must still work. 1) Flow Status, both directions.
|
||||
tab.runs_btn.setChecked(True)
|
||||
app.processEvents()
|
||||
on_runs = tab.center_stack.currentIndex() == 0
|
||||
tab.runs_back_btn.click()
|
||||
app.processEvents()
|
||||
back = tab.center_stack.currentIndex() == 1
|
||||
print(f"Flow Status: mo = {on_runs} · quay ve flow = {back} "
|
||||
f"· nut gat dang bat = {tab.runs_btn.isChecked()}")
|
||||
if not (on_runs and back):
|
||||
fails.append("khong di/ve duoc trang Flow Status")
|
||||
if tab.runs_btn.isChecked():
|
||||
fails.append("nut gat Flow Status khong tra ve trang thai tat")
|
||||
|
||||
# 2) Opening a flow from the list REPLACES the one on the canvas — one at a
|
||||
# time now, which is the part of the old strip that genuinely goes away.
|
||||
from cowork_local.core import co4e as _co4e
|
||||
tab._open_flow(_co4e.new_workflow("Flow A"))
|
||||
app.processEvents()
|
||||
tab._open_flow(_co4e.new_workflow("Flow B"))
|
||||
app.processEvents()
|
||||
print(f"mo 2 flow lien tiep : con {len(tab._flows)} flow tren canvas "
|
||||
f"({tab._wf.name!r})")
|
||||
if len(tab._flows) != 1:
|
||||
fails.append(f"cho 1 flow mo cung luc, thay {len(tab._flows)}")
|
||||
|
||||
# One column, four named sections — no icon tabs left.
|
||||
from PySide6.QtWidgets import QTabWidget
|
||||
heads = [h.text() for h, _b, _s in tab._sections.values()]
|
||||
print(f"cot sidebar : {heads}")
|
||||
if len(heads) != 4:
|
||||
fails.append(f"cho 4 muc trong cot sidebar, thay {len(heads)}")
|
||||
if tab.sidebar.findChildren(QTabWidget):
|
||||
fails.append("van con tab icon trong sidebar")
|
||||
|
||||
# Every list visible at once — that is the point of dropping the tabs.
|
||||
tab.show()
|
||||
app.processEvents()
|
||||
shown = [n for n in ("wf_list", "agent_list", "skill_list", "runs_side_list")
|
||||
if not getattr(tab, n).isHidden()]
|
||||
print(f"danh sach hien cung luc: {shown}")
|
||||
if len(shown) != 4:
|
||||
fails.append(f"chi {len(shown)}/4 danh sach hien cung luc")
|
||||
|
||||
# Headings fold their section, so a short window can still reach everything.
|
||||
head, body, _s = tab._sections["co4e.tab_agents"]
|
||||
head.setChecked(False)
|
||||
app.processEvents()
|
||||
folded = body.isHidden()
|
||||
head.setChecked(True)
|
||||
app.processEvents()
|
||||
print(f"gap/mo muc AGENTS : gap = {folded} · mo lai = {not body.isHidden()}")
|
||||
if not folded:
|
||||
fails.append("bam tieu de khong gap duoc muc")
|
||||
|
||||
# Both new-flow doors must land on the same slot.
|
||||
print(f"'Moi' canh WORKFLOWS : {tab.wf_new_btn.text()!r}")
|
||||
before = tab._wf.name
|
||||
tab.wf_new_btn.click()
|
||||
app.processEvents()
|
||||
print(f"bam 'Moi' -> flow tren canvas {before!r} -> {tab._wf.name!r}")
|
||||
if tab._wf.name == before:
|
||||
fails.append("nut 'Moi' canh WORKFLOWS khong tao flow moi")
|
||||
|
||||
# The action buttons that act on a selection stayed with the list.
|
||||
print(f"nut duoi danh sach : agents = "
|
||||
f"{[b.toolTip() for b in (tab.ag_edit_btn, tab.ag_del_btn)]}")
|
||||
|
||||
# --- small screens ------------------------------------------------------
|
||||
# The complaint that started this: on a laptop the four lists squeezed down
|
||||
# to one row each. Check real geometry at a few window heights.
|
||||
print()
|
||||
for w, h in ((1920, 1080), (1366, 768), (1280, 720)):
|
||||
tab.resize(w, h)
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
heights = {n: getattr(tab, n).height()
|
||||
for n in ("wf_list", "agent_list", "skill_list", "runs_side_list")}
|
||||
rows = {n: (getattr(tab, n).height() // max(1, getattr(tab, n).sizeHintForRow(0) or 18))
|
||||
for n in heights}
|
||||
print(f"{w}x{h}: cao = {heights} · so dong thay duoc = {rows}")
|
||||
thin = [n for n, v in heights.items() if v < 50]
|
||||
if thin:
|
||||
fails.append(f"o {w}x{h}, danh sach qua thap: {thin}")
|
||||
|
||||
# Folding must hand its height to the others, not just hide the body.
|
||||
tab.resize(1280, 720)
|
||||
app.processEvents()
|
||||
before = tab.wf_list.height()
|
||||
for key in ("co4e.tab_skills", "co4e.runs_tab"):
|
||||
tab._sections[key][0].setChecked(False)
|
||||
app.processEvents(); app.processEvents()
|
||||
after = tab.wf_list.height()
|
||||
print(f"gap SKILLS + FLOW STATUS -> WORKFLOWS cao {before} -> {after}px")
|
||||
if after <= before:
|
||||
fails.append("gap muc khac ma WORKFLOWS khong duoc them cho")
|
||||
for key in ("co4e.tab_skills", "co4e.runs_tab"):
|
||||
tab._sections[key][0].setChecked(True)
|
||||
app.processEvents()
|
||||
|
||||
print()
|
||||
for lang in ("vi", "en", "ja"):
|
||||
set_language(lang)
|
||||
tab._retranslate()
|
||||
app.processEvents()
|
||||
texts = [h.text() for h, _b, _s in tab._sections.values()]
|
||||
print(f" {lang}: {texts}")
|
||||
print(f" nut moi = {tab.wf_new_btn.text()!r}"
|
||||
f" · runs = {tab.runs_btn.text()!r} / {tab.runs_back_btn.text()!r}")
|
||||
if any(not t or "CO4E." in t for t in texts):
|
||||
fails.append(f"thieu ban dich tieu de muc cho {lang}")
|
||||
set_language("vi")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print("KET QUA: Co4E sap xep lai, khong mat control nao")
|
||||
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)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""A drop-list must have room for its text and for the tick beside it.
|
||||
|
||||
macOS marks the current row with a checkmark; Windows does not. The language
|
||||
combo is only as wide as "VN", and the popup inherits that width, so on macOS
|
||||
the tick landed on top of the two letters. Reported from a Mac, so this checks
|
||||
the property that made it possible rather than the platform.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication, QStyle
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
|
||||
fails = []
|
||||
from PySide6.QtWidgets import QStyledItemDelegate
|
||||
for name in ("language_combo", "nav_project", "provider_combo"):
|
||||
combo = getattr(win, name, None)
|
||||
if combo is None or not combo.count():
|
||||
continue
|
||||
view = combo.view()
|
||||
fm = view.fontMetrics()
|
||||
longest = max(fm.horizontalAdvance(combo.itemText(i))
|
||||
for i in range(combo.count()))
|
||||
tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo)
|
||||
need = longest + tick
|
||||
have = max(view.minimumWidth(), combo.width())
|
||||
print(f"{name:15}: chu dai nhat {longest:>4}px + dau tick {tick:>3}px "
|
||||
f"= can {need:>4}px | popup rong {have:>4}px")
|
||||
if have < need:
|
||||
fails.append(f"{name}: popup {have}px, khong du {need}px cho chu + dau tick")
|
||||
|
||||
# No tick: the row is already tinted, and the menu-style delegate that
|
||||
# draws one on macOS covered the two letters it was marking.
|
||||
deleg = combo.itemDelegate()
|
||||
plain = type(deleg) is QStyledItemDelegate
|
||||
print(f"{'':15} delegate={type(deleg).__name__} (khong ve dau tick={plain})")
|
||||
if not plain:
|
||||
fails.append(f"{name}: dung delegate kieu menu — macOS se ve dau tick")
|
||||
|
||||
# ...and the current row must still be obvious without one.
|
||||
view = combo.view()
|
||||
sheet = app.styleSheet()
|
||||
if "selection-background-color" not in sheet:
|
||||
fails.append("popup khong to mau muc dang chon")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS moi drop-list du cho chu va dau tick" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
# 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\\monitoring_tab.py": {
|
||||
# e6adfd9 turned one Refresh in the Monitoring header into one per
|
||||
# table (page.title_refresh_btn on Bảo mật / MCP / Hành động / Agent).
|
||||
# Tổng quan gets none because it auto-refreshes every 3s (_REFRESH_MS);
|
||||
# Icon has nothing to refresh.
|
||||
"self.refresh_btn": "→ nút 'Làm mới' riêng trên từng bảng (title_refresh_btn)",
|
||||
},
|
||||
"ui\\schedule_task_tab.py": {
|
||||
"self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)",
|
||||
},
|
||||
"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()
|
||||
|
||||
_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()
|
||||
# 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__":
|
||||
_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)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Workspace ▸ Cowork against its wireframe (section 5).
|
||||
|
||||
The drawing heads the screen with the THREAD's title — "Gom số liệu doanh thu",
|
||||
not the word "Cowork" — with the model beside it, Skills and Cuộc trò chuyện mới
|
||||
on the right, and a caps TỆP ĐẦU RA (n) panel down the side.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
from cowork_local.core.history import list_conversations, load_conversation
|
||||
from cowork_local.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
set_language("vi")
|
||||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||
win.resize(1920, 1000)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx)
|
||||
app.processEvents()
|
||||
c = win.cowork
|
||||
fails = []
|
||||
|
||||
# a new thread has no title yet, so the screen name stands in
|
||||
print(f"chua mo thread: tieu de={c._title_lbl.text()!r}")
|
||||
if not c._title_lbl.text().strip():
|
||||
fails.append("tieu de trong khi chua mo thread")
|
||||
|
||||
convs = list_conversations()
|
||||
if not convs:
|
||||
fails.append("khong co hoi thoai de kiem")
|
||||
else:
|
||||
conv = load_conversation(Path(convs[0]["path"]))
|
||||
c.load_conversation(conv)
|
||||
app.processEvents()
|
||||
want = conv.get("title", "")
|
||||
print(f"sau khi mo thread: tieu de={c._title_lbl.text()!r} (thread={want!r})")
|
||||
if want and c._title_lbl.text() != want:
|
||||
fails.append(f"tieu de khong theo thread: {c._title_lbl.text()!r} != {want!r}")
|
||||
if c._title_lbl.text() == tr("cowork.title") and want:
|
||||
fails.append("tieu de van la ten man hinh")
|
||||
|
||||
# the files panel is a caps section carrying its own count
|
||||
head = c.output_section.header.text()
|
||||
print(f"pane tep dau ra: {head!r}")
|
||||
body = head.lstrip("▾▸ ").split(" (")[0]
|
||||
if body != body.upper():
|
||||
fails.append(f"tieu de pane chua viet hoa: {head!r}")
|
||||
if "(" not in head:
|
||||
fails.append("tieu de pane khong kem so luong")
|
||||
|
||||
# toolbar keeps both actions the drawing shows
|
||||
for name, btn in (("Skills", c.skills_btn), ("chat moi", c._new_btn)):
|
||||
if not btn.isVisible():
|
||||
fails.append(f"thieu nut {name} tren thanh cong cu")
|
||||
print(f"nut tren thanh cong cu: {c.skills_btn.text()!r}, {c._new_btn.text()!r}")
|
||||
|
||||
# the status strip, read left to right, is
|
||||
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
|
||||
from PySide6.QtCore import QPoint as _P
|
||||
|
||||
bar = c._usage_total_lbl.parentWidget()
|
||||
def _x(widget):
|
||||
return widget.mapTo(bar, _P(0, 0)).x()
|
||||
|
||||
usage_text = c._usage_total_lbl.text()
|
||||
print(f"usage tren dai: {usage_text!r}")
|
||||
if not usage_text.strip():
|
||||
fails.append("dai duoi khong hien token/chi phi cua thread da mo")
|
||||
order = [("Agent", _x(c._agent_lbl)), ("Dinh tuyen", _x(c.routing_toggle)),
|
||||
("usage", _x(c._usage_total_lbl))]
|
||||
folder = getattr(c, "folder_lbl", None)
|
||||
if folder is not None:
|
||||
order.append(("thu muc", _x(folder)))
|
||||
print("thu tu: " + " < ".join(f"{n}({x})" for n, x in order))
|
||||
for (n1, x1), (n2, x2) in zip(order, order[1:]):
|
||||
if x1 >= x2:
|
||||
fails.append(f"dai duoi sai thu tu: {n1} khong dung truoc {n2}")
|
||||
|
||||
# the drawing gives Cowork two columns; History lives in the rail's RECENTS
|
||||
# and its pane arrives folded to the strip the drawing keeps
|
||||
w = win.workspace
|
||||
sb = w._sidebar
|
||||
print(f"vao Cowork: History hien={sb.isVisible()}")
|
||||
if sb.isVisible():
|
||||
fails.append("pane Lich su van o tren man Cowork — ban ve chi co 2 cot")
|
||||
w.show_history_pane()
|
||||
app.processEvents()
|
||||
print(f"sau 'Tat ca project…': History hien={sb.isVisible()} rong={sb.width()}px")
|
||||
if not sb.isVisible():
|
||||
fails.append("'Tat ca project…' khong mo duoc pane Lich su")
|
||||
w._on_history_fold(True)
|
||||
app.processEvents()
|
||||
|
||||
# one heading on the files panel, with the chevron at its right — the
|
||||
# drawing has "TỆP ĐẦU RA (3) ›" and nothing above it
|
||||
from PySide6.QtCore import QPoint as _QP
|
||||
|
||||
hdr_w = c.output_section.header
|
||||
chev = c._io_collapse_btn
|
||||
same_row = abs(hdr_w.mapTo(c, _QP(0, 0)).y() - chev.mapTo(c, _QP(0, 0)).y()) <= 8
|
||||
chev_right = chev.mapTo(c, _QP(0, 0)).x() > hdr_w.mapTo(c, _QP(0, 0)).x()
|
||||
# Count what the panel actually shows as headings. Asking whether the old
|
||||
# label is visible proved nothing: unparented, it reports invisible whether
|
||||
# or not the code hides it.
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
heads = [l.text() for l in c._io_widget.findChildren(QLabel)
|
||||
if l.isVisible() and l.text().strip()]
|
||||
print(f"tieu de hien trong pane: {heads} | "
|
||||
f"chevron cung hang={same_row} ben phai={chev_right}")
|
||||
if heads:
|
||||
fails.append(f"pane co tieu de thua ngoai '{hdr_w.text()}': {heads}")
|
||||
if not (same_row and chev_right):
|
||||
fails.append("chevron thu gon khong nam cuoi hang tieu de pane")
|
||||
|
||||
# the composer spans the screen, under BOTH columns — inside the chat
|
||||
# column it stopped at the files panel's edge and shrank when files arrived
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
c._set_io_collapsed(False)
|
||||
app.processEvents()
|
||||
split = c.center_split
|
||||
files_pane = split.widget(1)
|
||||
comp_right = c.composer.mapTo(c, QPoint(c.composer.width(), 0)).x()
|
||||
files_left = files_pane.mapTo(c, QPoint(0, 0)).x()
|
||||
below = c.composer.mapTo(c, QPoint(0, 0)).y() > split.mapTo(c, QPoint(0, 0)).y()
|
||||
spans = comp_right > files_left
|
||||
print(f"o nhap: rong {c.composer.width()} / man {c.width()} | "
|
||||
f"duoi splitter={below} | trai qua duoi pane tep={spans}")
|
||||
if not below:
|
||||
fails.append("o nhap khong nam duoi hang than")
|
||||
if not spans:
|
||||
fails.append("o nhap dung lai o mep pane tep, khong trai het man")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS man Cowork khop ban ve" if not fails else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Check the Dashboard header regroup, on the real widget, offscreen.
|
||||
|
||||
Nine controls were on one row. They are now on two, grouped by what they do —
|
||||
so this asserts that all nine are still present, still wired, and that the
|
||||
header really is two rows now (row 1 = title + Refresh, row 2 = the selectors).
|
||||
|
||||
Run: python tools/check_dashboard.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, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn",
|
||||
"gran_combo", "metric_combo", "currency_lbl", "currency_combo",
|
||||
"refresh_btn"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
|
||||
_apply_theme(app) # measure the styled widget, 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.i18n import set_language
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.dashboard_tab import DashboardTab
|
||||
|
||||
set_language("vi")
|
||||
tab = DashboardTab(AppContext(AppConfig.load()))
|
||||
tab.resize(1100, 800)
|
||||
tab.show()
|
||||
app.processEvents()
|
||||
tab.refresh()
|
||||
app.processEvents()
|
||||
|
||||
fails: list[str] = []
|
||||
missing = [n for n in HEADER if getattr(tab, n, None) is None]
|
||||
print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}")
|
||||
if missing:
|
||||
fails.append(f"mat control: {missing}")
|
||||
|
||||
# Two rows: everything in the header must sit at one of exactly two y bands.
|
||||
tops = {}
|
||||
for n in HEADER:
|
||||
w = getattr(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()):
|
||||
print(f" y~{band * 10:>4}px : {names}")
|
||||
if len(tops) != 2:
|
||||
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())
|
||||
app.processEvents()
|
||||
after = tab.metric_combo.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()
|
||||
app.processEvents()
|
||||
print("bam Lam moi : khong loi")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print("KET QUA: header Dashboard chia 2 hang, du 9 control")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Compare the running app against every proposal on the audit page.
|
||||
|
||||
The checklist is not written here — it is read from build_audit_page.ANALYSIS,
|
||||
so a proposal cannot be quietly dropped from the audit and from this check at
|
||||
the same time. Each item has a probe against a real MainWindow built offscreen.
|
||||
|
||||
Verdicts:
|
||||
OK the probe passes
|
||||
CHUA not implemented
|
||||
KHAC implemented differently on purpose (reason printed)
|
||||
TAY cannot be probed mechanically — inspect by eye
|
||||
|
||||
Run: python tools/check_design_parity.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO.parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
|
||||
def page_proposals():
|
||||
"""The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
|
||||
|
||||
Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
|
||||
sections are hand-written, and for those two the generator's text is NOT
|
||||
what the page shows. Checking against ANALYSIS reported Settings and the
|
||||
Task editor as matching the design when the page asked for something else
|
||||
(and, for the Task editor, the opposite).
|
||||
"""
|
||||
import re
|
||||
html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
|
||||
html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
|
||||
out: dict[str, list[str]] = {}
|
||||
for m in re.finditer(r'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S):
|
||||
block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', m.group(2), re.S)
|
||||
if not block:
|
||||
continue
|
||||
out[m.group(1)] = [
|
||||
re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
|
||||
for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)]
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
|
||||
_apply_theme(app) # measure the styled window, not a bare one
|
||||
from cowork_local.config import AppConfig, CONFIG_DIR
|
||||
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||||
|
||||
from seed_demo_data import seed
|
||||
seed()
|
||||
|
||||
from cowork_local.app import MainWindow
|
||||
from cowork_local.i18n import set_language
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
set_language("vi")
|
||||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||
win.resize(1600, 900)
|
||||
win.show()
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
return app, win
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app, win = build()
|
||||
ws = win.workspace
|
||||
|
||||
def goto(sub):
|
||||
win._goto(win._ROW_WORKSPACE, sub)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
def page(row):
|
||||
win._goto(row, None)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
return win._page_widgets[row]
|
||||
|
||||
import cowork_local.ui.co4e_tab as co4e_mod
|
||||
co4e = win.findChildren(co4e_mod.Co4ETab)[0]
|
||||
dash = page(win._ROW_DASHBOARD)
|
||||
mon = page(win._ROW_MONITORING)
|
||||
sched = page(win._ROW_SCHEDULE)
|
||||
goto(ws._cowork_tab_idx)
|
||||
chat = ws._cowork
|
||||
dock = win.help_agent
|
||||
|
||||
def rows_of(widget, names):
|
||||
"""How many distinct y-bands the named widgets occupy."""
|
||||
bands = set()
|
||||
for n in names:
|
||||
w = getattr(widget, n, None)
|
||||
if w is not None:
|
||||
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
|
||||
return len(bands)
|
||||
|
||||
# (slug, proposal, verdict, evidence)
|
||||
R: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def add(slug, text, ok, ev, other=None):
|
||||
R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
|
||||
|
||||
# --- 1 Dashboard ---
|
||||
n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
|
||||
"currency_combo"])
|
||||
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
|
||||
# Taller than the small tiles AND a bigger number = it reads as the headline.
|
||||
taller = dash.card_cost.height() > dash.card_total.height() * 1.5
|
||||
bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
|
||||
add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
|
||||
f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
|
||||
f"cỡ số {'34px' if bigger else 'như cũ'}")
|
||||
|
||||
# --- 2 Schedule Kanban ---
|
||||
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
|
||||
if not lanes:
|
||||
from cowork_local.core.tasks import STATUSES
|
||||
lanes = len(STATUSES)
|
||||
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
|
||||
has_combo = getattr(sched, "view_combo", None) is not None
|
||||
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
|
||||
"vẫn là combo" if has_combo else "đã thành tab")
|
||||
# The lane is only outlined while it actually holds something — seed data
|
||||
# may leave it empty, so drop a card in and read the style back.
|
||||
run_col = sched.columns.get("running")
|
||||
styled = ""
|
||||
if run_col is not None:
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
run_col.addItem(QListWidgetItem("probe"))
|
||||
sched.column_headers["running"].setStyleSheet("")
|
||||
sched.refresh()
|
||||
app.processEvents()
|
||||
styled = run_col.styleSheet()
|
||||
add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
|
||||
styled or "không có viền")
|
||||
|
||||
# --- 4/5 Workspace ---
|
||||
add("workspace-project", "History lên sidebar thành RECENTS",
|
||||
win.nav_recents.topLevelItemCount() > 0,
|
||||
f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
|
||||
add("workspace-project", "Thanh chọn project dùng chung mọi màn",
|
||||
win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
|
||||
goto(ws._co4e_tab_idx)
|
||||
hdr_off = ws._header.isHidden()
|
||||
goto(ws._project_tab_idx)
|
||||
hdr_on = not ws._header.isHidden()
|
||||
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
|
||||
"chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
|
||||
# The design's own wireframes draw the rail on every screen and a different
|
||||
# in-page pane per screen, so "the fixed left pane" is the rail — which now
|
||||
# carries the project picker and RECENTS on all of them.
|
||||
fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
|
||||
win.nav_recents.topLevelItemCount() > 0
|
||||
add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
|
||||
"rail (project + RECENTS) không đổi theo màn")
|
||||
add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
|
||||
win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
|
||||
add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
|
||||
any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
|
||||
for i in range(win.nav_recents.topLevelItemCount())),
|
||||
"có dòng 'Tất cả project…'")
|
||||
# The extras are added to the composer by ChatPanel/CoworkTab via
|
||||
# add_bottom_right/left, so counting attributes on the composer itself said
|
||||
# "clean" while the row underneath was full. Count the row instead.
|
||||
# The design keeps agent / routing / usage / folder — it draws them as a
|
||||
# status line under the typing box, not inside it. So the test is that the
|
||||
# TYPING row holds only input + attach/send/stop, and the rest sits in its
|
||||
# own strip below. Demanding an empty strip would mean deleting features.
|
||||
composer = getattr(chat, "composer", None)
|
||||
bar = getattr(composer, "extra_bar", None)
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
|
||||
typing = composer.input
|
||||
in_typing_row = typing.parentWidget() is composer
|
||||
below = bar is not None and bar.objectName() == "composerStatus"
|
||||
usage = getattr(chat, "_usage_total_lbl", None)
|
||||
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
|
||||
add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
|
||||
below and usage_in_bar,
|
||||
f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
|
||||
f"{bar.layout().count() if bar else 0} mục")
|
||||
|
||||
# --- 6 Co4E ---
|
||||
add("workspace-co4e", "Bỏ dải tab flow",
|
||||
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
|
||||
add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
|
||||
len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
|
||||
add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
|
||||
not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
|
||||
|
||||
# --- 7 Folder / 8 GraphRAG ---
|
||||
folder = ws.tabs.widget(ws._folder_tab_idx)
|
||||
title_lbl = getattr(folder, "path_lbl", None)
|
||||
add("workspace-folder", "Path bar gộp vào tiêu đề",
|
||||
title_lbl is not None and getattr(folder, "path_edit", None) is None,
|
||||
f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
|
||||
# "Thin bar at the bottom" = the terminal is the last thing in the column
|
||||
# and starts collapsed; the AI panel is a hideable right-hand pane.
|
||||
# Geometry is meaningless for a page that has never been shown, so ask the
|
||||
# widgets what state they are in instead of how tall they currently are.
|
||||
term = getattr(folder, "terminal", None)
|
||||
lay = folder.layout()
|
||||
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
|
||||
collapsed = term is not None and term._body.isHidden()
|
||||
at_bottom = term is not None and last is term
|
||||
add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
|
||||
collapsed and at_bottom,
|
||||
f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
|
||||
graph = ws.tabs.widget(ws._graphrag_tab_idx)
|
||||
# One row = the path box and Export share a y-band.
|
||||
def band(w):
|
||||
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
|
||||
one_row = band(graph.path_edit) == band(graph._export_btn)
|
||||
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
|
||||
f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
|
||||
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
|
||||
# _msg_btn — a stale name); while it exists, this is still one button whose
|
||||
# label flips, not a pair of tabs.
|
||||
toggle = getattr(graph, "_msgs_toggle_btn", None)
|
||||
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
|
||||
"vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
|
||||
|
||||
# --- 9/15 Monitoring ---
|
||||
from PySide6.QtWidgets import QHBoxLayout, QScrollArea
|
||||
from PySide6.QtWidgets import QSpinBox as QSpinBoxT
|
||||
# The Overview column is not simply the first scroll area any more — the
|
||||
# event tables' detail panels are scroll areas too, and are built first
|
||||
# (e6adfd9). Pick the one that actually holds Overview's own sections.
|
||||
ov = None
|
||||
for area in mon.findChildren(QScrollArea):
|
||||
body = area.widget()
|
||||
if body is None or body.layout() is None:
|
||||
continue
|
||||
if body.layout().indexOf(mon.ov_usage_group) >= 0:
|
||||
ov = body
|
||||
break
|
||||
assert ov is not None, "khong tim thay cot Tong quan"
|
||||
one_col = not isinstance(ov.layout(), QHBoxLayout)
|
||||
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
|
||||
"cột dọc" if one_col else "vẫn 2 cột")
|
||||
# Its own section = it is a direct child of the single column, not sharing a
|
||||
# row with the resource meters as it used to.
|
||||
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
|
||||
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
|
||||
f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
|
||||
strip = not mon.tabs.tabBar().isHidden()
|
||||
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
|
||||
strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
|
||||
|
||||
# --- 17/18 dialogs ---
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
s = SettingsDialog(win.ctx)
|
||||
add("dialog-settings", "Thêm cột mục lục bên trái",
|
||||
s.section_list.count() == 5, f"{s.section_list.count()} mục")
|
||||
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
|
||||
# left as its own group — not everything merged together.
|
||||
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
|
||||
in_general = s._general_box.isAncestorOf(s.language_combo) and \
|
||||
s._general_box.isAncestorOf(s.theme_combo)
|
||||
prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
|
||||
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
|
||||
in_general and prov_apart,
|
||||
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
|
||||
n_switch = len(s.findChildren(ToggleSwitch))
|
||||
n_seg = len(s.findChildren(SegmentedControl))
|
||||
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
|
||||
add("dialog-settings",
|
||||
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
|
||||
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
|
||||
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
|
||||
s.close()
|
||||
t = TaskEditorDialog(ctx=win.ctx)
|
||||
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
|
||||
like_settings = (t.section_list.count() == 5
|
||||
and t.section_stack.count() == 5
|
||||
and not hasattr(t, "step_tabs"))
|
||||
add("dialog-task-editor",
|
||||
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
|
||||
like_settings, " · ".join(rows))
|
||||
t.close()
|
||||
|
||||
# --- 27 help dock ---
|
||||
# The page says 26px. The user asked for it doubled — recorded here rather
|
||||
# than scored against a number the app no longer intends.
|
||||
from cowork_local.ui.help_agent_widget import _DOT
|
||||
|
||||
add("overlay-help-panel",
|
||||
f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)",
|
||||
dock.width() == _DOT and not dock.launcher.text().strip(),
|
||||
f"{dock.width()}px")
|
||||
from cowork_local.i18n import tr
|
||||
dock.launcher._set_open(True)
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
|
||||
tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
|
||||
dock.launcher._set_open(False)
|
||||
# The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for
|
||||
# that menu to go: its two entries were "thu nhỏ", which the − button next
|
||||
# to it already does, and "ẩn". Recorded as a deliberate deviation rather
|
||||
# than quietly scored as done — the action itself moved to a right-click on
|
||||
# the dot and on the panel header, so nothing became unreachable.
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
has_menu = hasattr(dock, "more_btn")
|
||||
by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu
|
||||
add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải",
|
||||
(not has_menu) and by_right_click,
|
||||
"menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề")
|
||||
add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
|
||||
dock._hide_to_edge()
|
||||
app.processEvents()
|
||||
add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
|
||||
dock._show_launcher()
|
||||
app.processEvents()
|
||||
goto(ws._cowork_tab_idx)
|
||||
comp = chat.composer
|
||||
dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
|
||||
comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
|
||||
add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
|
||||
dock_top + dock.height() <= comp_top,
|
||||
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
|
||||
|
||||
# --- coverage: is every bullet ON THE PAGE actually probed? -------------
|
||||
proposals = page_proposals()
|
||||
probed = {}
|
||||
for slug, text, _v, _e in R:
|
||||
probed.setdefault(slug, 0)
|
||||
probed[slug] += 1
|
||||
gaps = []
|
||||
for slug, bullets in proposals.items():
|
||||
n_probe = probed.get(slug, 0)
|
||||
if len(bullets) > n_probe:
|
||||
for extra in bullets[n_probe:]:
|
||||
gaps.append((slug, extra))
|
||||
|
||||
# --- report ---
|
||||
order = ["OK", "KHAC", "CHUA", "TAY"]
|
||||
counts = {k: 0 for k in order}
|
||||
cur = None
|
||||
for slug, text, verdict, ev in R:
|
||||
counts[verdict] = counts.get(verdict, 0) + 1
|
||||
if slug != cur:
|
||||
print(f"\n{slug}")
|
||||
cur = slug
|
||||
print(f" [{verdict:4}] {text}")
|
||||
print(f" {ev}")
|
||||
if gaps:
|
||||
print()
|
||||
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
|
||||
for slug, text in gaps:
|
||||
print(f" {slug}")
|
||||
print(f" {text[:160]}")
|
||||
print()
|
||||
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
|
||||
f" · da co phep do : {len(R)}")
|
||||
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
|
||||
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
|
||||
print(f" KHAC = co y lam khac, da ghi ly do")
|
||||
print(f" CHUA = chua lam")
|
||||
# This used to return 0 unconditionally — a report, not a check. Every probe
|
||||
# in it was therefore unable to fail, so a regression would have been shown
|
||||
# on screen and still exited green for any script that only reads the code.
|
||||
return 1 if counts.get("CHUA") else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_rc = main()
|
||||
# Qt (WebEngine especially) crashes during interpreter teardown with
|
||||
# 0xC0000409 AFTER the work is done, which would mask the real result —
|
||||
# and check_probes_bite reads these exit codes to decide whether a probe
|
||||
# caught its mutation. Leave immediately with the verdict instead.
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os._exit(_rc)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Check the left-list + right-panel navigation in Settings and Task editor.
|
||||
|
||||
Both dialogs are navigated the same way, as the audit page asks: a list of the
|
||||
real group boxes on the left, one panel shown at a time on the right. So the
|
||||
test is that picking a row swaps the panel, that the rows match the groups, and
|
||||
— since this is a rearrangement — that no input control went missing.
|
||||
|
||||
Run: python tools/check_dialogs.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, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
# Every field each dialog must still offer after the move.
|
||||
SETTINGS_FIELDS = [
|
||||
"language_combo", "theme_combo", "tray_chk", "notify_chk",
|
||||
"provider_combo", "prov_base", "prov_key", "prov_model",
|
||||
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
|
||||
"sandbox_block_network", "sec_enabled", "ai_check",
|
||||
]
|
||||
TASK_FIELDS = [
|
||||
"title_edit", "desc_edit", "gen_desc_btn", "priority_combo", "status_combo",
|
||||
"workspace_combo", "provider_combo", "model_combo", "skill_combo",
|
||||
"sched_enabled", "run_at_edit", "files_list", "files_add_btn", "links_list",
|
||||
"links_add_btn", "next_combo", "run_next_combo", "pass_output_chk",
|
||||
"depends_list", "retry_spin", "timeout_spin", "approval_chk",
|
||||
]
|
||||
|
||||
|
||||
def check(name, dlg, app, expect_rows, fields):
|
||||
fails = []
|
||||
print(f"--- {name} ---")
|
||||
idx, stack = dlg.section_list, dlg.section_stack
|
||||
rows = [idx.item(i).text() for i in range(idx.count())]
|
||||
print(f"muc : {rows}")
|
||||
if len(rows) != expect_rows:
|
||||
fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}")
|
||||
if idx.count() != stack.count():
|
||||
fails.append(f"{name}: {idx.count()} muc nhung {stack.count()} panel")
|
||||
|
||||
# Picking a row must swap the panel — and each panel must hold something.
|
||||
swapped, empty = 0, []
|
||||
for i in range(idx.count()):
|
||||
idx.setCurrentRow(i)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
if stack.currentIndex() == i:
|
||||
swapped += 1
|
||||
page = stack.widget(i).widget()
|
||||
if not page.findChildren(type(page)):
|
||||
empty.append(rows[i])
|
||||
print(f"chon muc -> doi panel : {swapped}/{idx.count()}")
|
||||
if swapped != idx.count():
|
||||
fails.append(f"{name}: chon muc khong doi panel")
|
||||
if empty:
|
||||
fails.append(f"{name}: panel rong {empty}")
|
||||
|
||||
missing = [f for f in fields if getattr(dlg, f, None) is None]
|
||||
print(f"field con nguyen : {len(fields) - len(missing)}/{len(fields)}")
|
||||
if missing:
|
||||
fails.append(f"{name}: mat field {missing}")
|
||||
return fails
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
|
||||
_apply_theme(app) # measure the styled widget, 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 cowork_local.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
set_language("vi")
|
||||
ctx = AppContext(AppConfig.load())
|
||||
fails = []
|
||||
|
||||
s = SettingsDialog(ctx)
|
||||
s.resize(900, 640)
|
||||
s.show()
|
||||
app.processEvents()
|
||||
fails += check("Cai dat", s, app, 5, SETTINGS_FIELDS)
|
||||
|
||||
t = TaskEditorDialog(ctx=ctx)
|
||||
t.resize(900, 640)
|
||||
t.show()
|
||||
app.processEvents()
|
||||
fails += check("Task editor", t, app, 5, TASK_FIELDS)
|
||||
|
||||
# Both dialogs must be navigated the SAME way — that is the stated point.
|
||||
same = (type(s.section_list) is type(t.section_list)
|
||||
and type(s.section_stack) is type(t.section_stack))
|
||||
print()
|
||||
print(f"hai hop thoai cung kieu dieu huong: {same}")
|
||||
if not same:
|
||||
fails.append("hai hop thoai dieu huong khac kieu")
|
||||
|
||||
for lang in ("vi", "en", "ja"):
|
||||
set_language(lang)
|
||||
print(f" {lang}: general={tr('settings.group.general')!r} "
|
||||
f"basic={tr('schedtask.g_basic')!r}")
|
||||
set_language("vi")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for x in fails:
|
||||
print(" " + x)
|
||||
return 1
|
||||
print("KET QUA: hai hop thoai dung danh sach trai + panel phai, khong mat field")
|
||||
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)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""The assistant dot sits in the bottom-right corner — unless the composer is
|
||||
genuinely underneath it.
|
||||
|
||||
It used to lift on Cowork whenever a composer existed, measured only on the
|
||||
vertical axis. On a wide window the composer stops at the chat column's right
|
||||
edge, far short of the dot, so the dot rose 156px for nothing and Cowork was
|
||||
the one screen where it was not in the corner.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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.show()
|
||||
dock = win.help_agent
|
||||
fails = []
|
||||
|
||||
def gap_and_overlap(width, height):
|
||||
win.resize(width, height)
|
||||
app.processEvents()
|
||||
win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx)
|
||||
app.processEvents()
|
||||
comp = win.cowork.composer
|
||||
origin = comp.mapTo(win, comp.rect().topLeft())
|
||||
dleft = dock.x() - win.mapToGlobal(win.rect().topLeft()).x()
|
||||
overlaps = (dleft + dock.width() > origin.x()
|
||||
and dleft < origin.x() + comp.width())
|
||||
gap = win.height() - (dock.y() + dock.height())
|
||||
return gap, overlaps, origin.x() + comp.width(), dleft
|
||||
|
||||
# baseline: every other screen
|
||||
win.resize(1936, 1048)
|
||||
app.processEvents()
|
||||
win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx)
|
||||
app.processEvents()
|
||||
corner = win.height() - (dock.y() + dock.height())
|
||||
print(f"man thuong : cach day {corner}px")
|
||||
|
||||
for w, h in ((1936, 1048), (1200, 800), (900, 700)):
|
||||
gap, over, comp_right, dleft = gap_and_overlap(w, h)
|
||||
print(f"Cowork {w}x{h:<5}: cach day {gap:>4}px | composer het o x={comp_right} "
|
||||
f"| cham o x={dleft} | chong nhau={over}")
|
||||
if over and gap <= corner:
|
||||
fails.append(f"{w}x{h}: composer nam duoi cham ma cham khong duoc nang")
|
||||
if not over and gap != corner:
|
||||
fails.append(f"{w}x{h}: khong chong nhau ma cham van lech "
|
||||
f"({gap}px thay vi {corner}px)")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS cham o goc, chi nang khi that su bi che" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""GraphRAG rebuilds when it is opened, not every time a project is picked.
|
||||
|
||||
Switching project used to rescan the folder, redo the force layout and setHtml
|
||||
the whole D3 page immediately — for a tab usually not on screen. With the rail's
|
||||
picker one click away from anywhere, that fired constantly, and the rebuild you
|
||||
did see on opening GraphRAG read as the page reloading itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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()
|
||||
app.processEvents()
|
||||
st, w = win.structure, win.workspace
|
||||
fails: list[str] = []
|
||||
|
||||
# startup itself must not build any of it
|
||||
if st.web is not None or st._graph is not None:
|
||||
fails.append("dung san do thi ngay luc khoi dong — startup phai nhe")
|
||||
|
||||
# the idle warm-up builds the browser view and the first graph off the
|
||||
# click path (MainWindow fires this on a 3s timer; call it directly here)
|
||||
win._prewarm_graph()
|
||||
deadline = time.monotonic() + 30
|
||||
while st._graph is None and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.02)
|
||||
if st._graph is None:
|
||||
print("FAIL lam nong khong dung duoc do thi")
|
||||
sys.stdout.flush()
|
||||
os._exit(1)
|
||||
print(f"sau khi lam nong: web={'co' if st.web else 'chua'} do thi={'co' if st._graph else 'chua'}")
|
||||
|
||||
calls = {"scan": 0, "html": 0}
|
||||
real_scan, real_html = st._scan, st._render_d3
|
||||
st._scan = lambda: (calls.__setitem__("scan", calls["scan"] + 1), real_scan())[1]
|
||||
st._render_d3 = lambda: (calls.__setitem__("html", calls["html"] + 1), real_html())[1]
|
||||
|
||||
# ...so the click itself does nothing but show it — this is the flash
|
||||
t0 = time.monotonic()
|
||||
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
|
||||
app.processEvents()
|
||||
print(f"bam vao GraphRAG: {(time.monotonic() - t0) * 1000:.0f}ms "
|
||||
f"_scan={calls['scan']} setHtml={calls['html']}")
|
||||
if calls["scan"] or calls["html"]:
|
||||
fails.append("bam vao GraphRAG van phai quet/nap lai trang — con chop trang")
|
||||
|
||||
# re-entering an unchanged graph must not rebuild anything
|
||||
for _ in range(3):
|
||||
win._goto(win._ROW_WORKSPACE, w._project_tab_idx)
|
||||
app.processEvents()
|
||||
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
|
||||
app.processEvents()
|
||||
print(f"vao lai 3 lan (khong doi gi): _scan={calls['scan']} setHtml={calls['html']}")
|
||||
if calls["scan"] or calls["html"]:
|
||||
fails.append("vao lai GraphRAG van quet/reload du khong co gi doi")
|
||||
|
||||
# switching project off-screen defers the work
|
||||
win._goto(win._ROW_WORKSPACE, w._cowork_tab_idx)
|
||||
app.processEvents()
|
||||
ids = [pid for _n, pid in w.project_choices()]
|
||||
for pid in ids[1:3]:
|
||||
w.choose_project(pid)
|
||||
app.processEvents()
|
||||
print(f"doi {len(ids[1:3])} project khi dang o Cowork: _scan={calls['scan']} "
|
||||
f"setHtml={calls['html']} | can quet lan toi={st._needs_scan}")
|
||||
if calls["scan"]:
|
||||
fails.append("doi project van quet ngay du GraphRAG khong tren man")
|
||||
if len(ids) > 1 and not st._needs_scan:
|
||||
fails.append("doi project ma khong danh dau can quet lai")
|
||||
|
||||
# ...and opening it does the work once
|
||||
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
|
||||
app.processEvents()
|
||||
print(f"mo GraphRAG: _scan={calls['scan']}")
|
||||
if len(ids) > 1 and calls["scan"] != 1:
|
||||
fails.append(f"mo GraphRAG phai quet dung 1 lan, dang {calls['scan']}")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS GraphRAG chi dung lai do thi khi can" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Check the redesigned help dock on the real widget, offscreen.
|
||||
|
||||
The claim being made is a size claim ("84×64 → 26×26"), so this measures the
|
||||
widget instead of trusting the constants, and confirms that nothing the old
|
||||
three-button layout could do has gone missing — hiding to the edge just moved
|
||||
into the panel's ⋯ menu.
|
||||
|
||||
Run: python tools/check_help_dock.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, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
|
||||
_apply_theme(app) # measure the styled widget, 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 cowork_local.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.help_agent_widget import HelpAgentWidget
|
||||
|
||||
set_language("vi")
|
||||
host = QWidget()
|
||||
host.resize(1200, 800)
|
||||
dock = HelpAgentWidget(AppContext(AppConfig.load()), host, user_name="local")
|
||||
app.processEvents()
|
||||
|
||||
fails: list[str] = []
|
||||
OLD_W, OLD_H = 84, 64 # 64px badge + 2px gap + 18px chevron
|
||||
|
||||
closed = dock.size()
|
||||
print(f"dong : {closed.width()}x{closed.height()}px "
|
||||
f"(cu {OLD_W}x{OLD_H})")
|
||||
area_new, area_old = closed.width() * closed.height(), OLD_W * OLD_H
|
||||
print(f"dien tich : {area_new} vs {area_old}px2 "
|
||||
f"({100 - round(area_new / area_old * 100)}% nho hon)")
|
||||
# The page draws 26px; the user asked for double. Read the size the module
|
||||
# declares rather than a number written here, so the two cannot drift, and
|
||||
# keep the two things that actually matter: it is a square chip, and it is
|
||||
# still far smaller than the button it replaced.
|
||||
if closed.width() != closed.height():
|
||||
fails.append(f"nut dong khong vuong: {closed.width()}x{closed.height()}")
|
||||
if area_new >= area_old:
|
||||
fails.append(f"khong con nho hon nut cu: {area_new} vs {area_old}px2")
|
||||
# clears the ~24px comfortable-tap floor the old 18px chevron missed
|
||||
if min(closed.width(), closed.height()) < 24:
|
||||
fails.append("vung bam nho hon 24px")
|
||||
|
||||
# Hover: the name appears, and only then.
|
||||
print(f"chu luc dong : {dock.launcher.text()!r} (phai rong)")
|
||||
if dock.launcher.text().strip():
|
||||
fails.append("nut dong ma van hien chu")
|
||||
dock.launcher._set_open(True)
|
||||
app.processEvents()
|
||||
hovered = dock.size()
|
||||
print(f"re chuot : {hovered.width()}x{hovered.height()}px · "
|
||||
f"chu = {dock.launcher.text().strip()!r}")
|
||||
if tr("help_agent.badge") not in dock.launcher.text():
|
||||
fails.append("re chuot khong hien 'AI Assistant'")
|
||||
if hovered.width() <= closed.width():
|
||||
fails.append("re chuot ma nut khong no ra")
|
||||
dock.launcher._set_open(False)
|
||||
app.processEvents()
|
||||
if dock.size().width() != closed.width():
|
||||
fails.append("roi chuot ma nut khong thu lai")
|
||||
|
||||
# Every state still reachable, and the corner anchor still holds.
|
||||
for state, call in (("panel", dock._expand), ("launcher", dock._collapse),
|
||||
("hidden", dock._hide_to_edge), ("launcher", dock._show_launcher)):
|
||||
call()
|
||||
app.processEvents()
|
||||
got = dock._state
|
||||
inside = (dock.x() + dock.width() <= host.width()
|
||||
and dock.y() + dock.height() <= host.height())
|
||||
print(f"trang thai {state:9}: {got:9} {dock.width():3}x{dock.height():3} "
|
||||
f"goc phai duoi = {inside}")
|
||||
if got != state:
|
||||
fails.append(f"khong vao duoc trang thai {state}")
|
||||
if not inside:
|
||||
fails.append(f"trang thai {state} tran ra ngoai cua so")
|
||||
|
||||
# The edge tab was 16px — below anything comfortable to hit.
|
||||
dock._hide_to_edge()
|
||||
app.processEvents()
|
||||
print(f"tab mep : {dock.width()}px (cu 16px)")
|
||||
if dock.width() < 24:
|
||||
fails.append(f"tab mep {dock.width()}px, van duoi 24px")
|
||||
dock._show_launcher()
|
||||
|
||||
# The ⋯ menu is gone (user's call — its two entries were "collapse", which
|
||||
# the − button beside it already did, and "hide"). Nothing was lost with it:
|
||||
# collapse is the − button and the dot, hide is a right-click on either the
|
||||
# dot or the open panel's header. Check the routes, not the menu.
|
||||
from PySide6.QtCore import Qt as _Qt
|
||||
|
||||
if hasattr(dock, "more_btn"):
|
||||
fails.append("menu ⋯ van con tren panel")
|
||||
dock._collapse()
|
||||
app.processEvents()
|
||||
if dock._state != "launcher":
|
||||
fails.append("nut − khong thu nho duoc ve cham")
|
||||
if dock.launcher.contextMenuPolicy() != _Qt.CustomContextMenu:
|
||||
fails.append("cham khong co menu chuot phai de an")
|
||||
dock._hide_to_edge()
|
||||
app.processEvents()
|
||||
if dock._state != "hidden":
|
||||
fails.append("khong an duoc vao canh phai")
|
||||
print(f"thu nho + an : ca hai duong deu chay (trang thai cuoi={dock._state!r})")
|
||||
dock._show_launcher()
|
||||
app.processEvents()
|
||||
print(f"nut thu nho : {dock.min_btn.toolTip()!r}")
|
||||
print(f"nut gui / o nhap: {dock.send_btn is not None} / {dock.input is not None}")
|
||||
|
||||
# All three languages must have the new strings.
|
||||
for lang in ("vi", "en", "ja"):
|
||||
set_language(lang)
|
||||
dock.retranslate()
|
||||
# more_tooltip went with the ⋯ menu; dot_hint replaces it as the
|
||||
# string that tells you the right-click is there.
|
||||
vals = [dock.launcher.toolTip(), tr("help_agent.badge"),
|
||||
tr("help_agent.dot_hint")]
|
||||
print(f" {lang}: badge={vals[1]!r} goi y chuot phai={vals[2]!r}")
|
||||
if any(not v or v.startswith("help_agent.") for v in vals):
|
||||
fails.append(f"thieu ban dich cho {lang}")
|
||||
set_language("vi")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print("KET QUA: nut tro ly gon lai, khong mat chuc nang nao")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The help panel must follow a language switch — transcript included.
|
||||
|
||||
retranslate() re-labelled the chrome but not the rendered HTML transcript, so
|
||||
the greeting and the "AI Assistant" speaker label stayed in the language the
|
||||
panel happened to be built in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
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 ( # noqa: E402
|
||||
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8") # ja/vi text on a cp932 console
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
|
||||
panel = win.help_agent
|
||||
fails = []
|
||||
for lang in ("en", "ja", "vi"):
|
||||
win._set_language(lang) if hasattr(win, "_set_language") else set_language(lang)
|
||||
if not hasattr(win, "_set_language"):
|
||||
panel.retranslate()
|
||||
app.processEvents()
|
||||
|
||||
# The panel greets by the signed-in name, not the placeholder.
|
||||
want = panel._greeting()
|
||||
shown = re.sub("<[^>]+>", " ", panel.log.toHtml())
|
||||
shown = " ".join(shown.split())
|
||||
# compare on the stable half of the sentence, the name is substituted
|
||||
probe = " ".join(want.split())[:28]
|
||||
state = "ok" if probe and probe in shown else "MISSING"
|
||||
print(f"{lang}: title={panel.title.text()!r} greeting={state}")
|
||||
if state != "ok":
|
||||
print(f" muon: {probe!r}")
|
||||
print(f" thay: {shown[:160]!r}")
|
||||
if state != "ok":
|
||||
fails.append(f"{lang}: transcript still shows another language")
|
||||
|
||||
print()
|
||||
print("PASS panel follows the language" if not fails
|
||||
else "\n".join(f"FAIL {f}" for f in fails))
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
"""The Icon library screen against its wireframe.
|
||||
|
||||
The drawing (section 16) puts the three actions on the title row, a magnifier
|
||||
in the search box, caps section headings, and — its stated complaint — a
|
||||
visible edge on an icon cell when you hover or select it, so you can tell what
|
||||
you are about to pick.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||||
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.i18n import set_language
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons_admin_tab import IconsAdminTab
|
||||
|
||||
set_language("vi")
|
||||
tab = IconsAdminTab(AppContext(AppConfig.load()))
|
||||
tab.resize(1100, 700)
|
||||
tab.show()
|
||||
app.processEvents()
|
||||
|
||||
fails = []
|
||||
|
||||
# 1. actions on the title row, to its right — not in a strip below the
|
||||
# grids. Comparing y alone was not enough: a button left out of the
|
||||
# layout sits at (0,0), which is "the same row" by accident.
|
||||
title = tab._title
|
||||
t_pos = title.mapTo(tab, QPoint(0, 0))
|
||||
t_mid = t_pos.y() + title.height() // 2
|
||||
t_right = t_pos.x() + title.width()
|
||||
for name, btn in (("Thêm", tab.add_btn), ("Dán", tab.paste_btn),
|
||||
("Xóa", tab.del_btn)):
|
||||
pos = btn.mapTo(tab, QPoint(0, 0))
|
||||
mid = pos.y() + btn.height() // 2
|
||||
aligned = abs(mid - t_mid) <= 6
|
||||
after = pos.x() >= t_right
|
||||
print(f"nut {name:5}: tam y={mid} (tieu de {t_mid}) thang hang={aligned} "
|
||||
f"| x={pos.x()} (sau tieu de {t_right})={after}")
|
||||
if not (aligned and after):
|
||||
fails.append(f"nut {name} khong nam cung hang, ben phai tieu de")
|
||||
|
||||
# 2. magnifier in the search box
|
||||
lead = tab.search.actions()
|
||||
print(f"o tim co icon kinh lup: {bool(lead)}")
|
||||
if not lead:
|
||||
fails.append("o tim thieu icon kinh lup")
|
||||
|
||||
# 3. caps headings
|
||||
for name, lbl in (("tich hop", tab._builtin_lbl), ("tuy chinh", tab._custom_lbl)):
|
||||
text = lbl.text()
|
||||
print(f"tieu de {name}: {text!r}")
|
||||
if text != text.upper():
|
||||
fails.append(f"tieu de {name} chua viet hoa: {text!r}")
|
||||
|
||||
# 4. the cell has an edge to see — compare the painted cell hovered vs not
|
||||
grid = tab.builtin_grid
|
||||
if grid.count():
|
||||
rect = grid.visualItemRect(grid.item(0))
|
||||
plain = grid.grab(rect).toImage()
|
||||
grid.setCurrentRow(0)
|
||||
app.processEvents()
|
||||
picked = grid.grab(rect).toImage()
|
||||
diff = sum(1 for x in range(plain.width()) for y in range(plain.height())
|
||||
if plain.pixelColor(x, y) != picked.pixelColor(x, y))
|
||||
print(f"o icon doi {diff} diem anh khi duoc chon")
|
||||
if diff == 0:
|
||||
fails.append("o icon khong doi gi khi duoc chon — khong thay minh dang chon cai nao")
|
||||
else:
|
||||
fails.append("luoi icon rong")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS man Icon khop ban ve" if not fails else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Schedule Task must not scroll sideways on a screen the app supports.
|
||||
|
||||
Two separate causes, both reported as "some screens scroll, some don't":
|
||||
· each lane is a QListWidget whose column hint runs a few px past its own
|
||||
viewport, so individual lanes grew a scrollbar at most window widths;
|
||||
· the seven lanes together wanted 1242px where a 1280 window leaves 1091.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Smallest screen the app is expected to run on, and the rail at both extremes.
|
||||
SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (1936, 1048)]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QAbstractScrollArea, QApplication, QScrollArea
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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.show()
|
||||
app.processEvents()
|
||||
|
||||
fails = []
|
||||
for w, h in SIZES:
|
||||
# 150 is the default and 240 a realistic widening. 360 (the maximum)
|
||||
# on a 1280 screen leaves 920px for seven lanes that need 1067 — that
|
||||
# scroll is the user's own trade, so it is reported, not failed.
|
||||
for rail in (150, 240, 360):
|
||||
win.resize(w, h)
|
||||
app.processEvents()
|
||||
win.split.setSizes([rail, max(1, w - rail)])
|
||||
win._goto(win._ROW_SCHEDULE, None)
|
||||
app.processEvents()
|
||||
|
||||
page = [p for p in win._page_widgets
|
||||
if p is not None and hasattr(p, "counts_lbl")][0]
|
||||
outer = page.findChild(QScrollArea)
|
||||
lanes = [s for s in win.findChildren(QAbstractScrollArea)
|
||||
if s.isVisible() and s.__class__.__name__ == "_KanbanColumn"]
|
||||
spill = outer.horizontalScrollBar().maximum()
|
||||
lane_spill = [s.horizontalScrollBar().maximum() for s in lanes]
|
||||
worst = max(lane_spill) if lane_spill else 0
|
||||
print(f"{w}x{h} rail={rail:<4}: {len(lanes)} lan rong "
|
||||
f"{lanes[0].width() if lanes else 0:>4} | vung ngoai thua {spill:>4}px "
|
||||
f"| lan thua toi da {worst}px")
|
||||
if spill and rail < 360:
|
||||
fails.append(f"{w}x{h} rail={rail}: 7 lan tran {spill}px")
|
||||
elif spill:
|
||||
print(f" (rail keo het co: nguoi dung tu chon, thua {spill}px)")
|
||||
if worst:
|
||||
fails.append(f"{w}x{h} rail={rail}: mot lan tu tran {worst}px")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS khong cuon ngang o moi co man hinh da thu" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,213 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Does the layout adapt across screen sizes AND display scalings?
|
||||
|
||||
Two things change between machines, and only one of them is width:
|
||||
|
||||
* the screen is bigger or smaller — more or fewer pixels to lay out in;
|
||||
* the display scale is 100/125/150% — the SAME number of logical pixels
|
||||
holds less, because every label and margin is taller.
|
||||
|
||||
A breakpoint written as a raw pixel number only holds on the machine it was
|
||||
tuned on. This walks a grid of (window size × font scale) and, for each cell,
|
||||
checks that no screen is clipped and that the panes folded when they had to.
|
||||
|
||||
Run: python tools/check_multi_screen.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
|
||||
|
||||
# Real-world panels, from a small laptop up to 4K-at-150%-effective.
|
||||
SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)]
|
||||
# 9pt ≈ 100%, 11pt ≈ 125%, 14pt ≈ 150% of the design baseline.
|
||||
POINTS = [9, 11, 14]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtGui import QFont
|
||||
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
|
||||
from cowork_local.ui.widgets import ui_scale
|
||||
|
||||
set_language("vi")
|
||||
fails: list[str] = []
|
||||
print(f"{'co chu':>7} {'cua so':>11} {'thang do':>9} {'man bi bo':>10} panel da gap")
|
||||
print("-" * 86)
|
||||
|
||||
for pt in POINTS:
|
||||
f = QFont(app.font())
|
||||
f.setPointSize(pt)
|
||||
app.setFont(f)
|
||||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||
win.show()
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
ws = win.workspace
|
||||
dests = [("Project", win._ROW_WORKSPACE, ws._project_tab_idx),
|
||||
("Cowork", win._ROW_WORKSPACE, ws._cowork_tab_idx),
|
||||
("Co4E", win._ROW_WORKSPACE, ws._co4e_tab_idx),
|
||||
("Folder", win._ROW_WORKSPACE, ws._folder_tab_idx),
|
||||
("GraphRAG", win._ROW_WORKSPACE, ws._graphrag_tab_idx),
|
||||
("Schedule", win._ROW_SCHEDULE, None),
|
||||
("Dashboard", win._ROW_DASHBOARD, None),
|
||||
("Monitoring", win._ROW_MONITORING, None)]
|
||||
|
||||
for w, h in SIZES:
|
||||
win.resize(w, h)
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
clipped = []
|
||||
for name, page, sub in dests:
|
||||
win._goto(page, sub)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
widget = win._page_widgets[page]
|
||||
if widget.minimumSizeHint().width() > widget.width() + 1:
|
||||
clipped.append(name)
|
||||
folded = []
|
||||
if getattr(ws, "_is_narrow", False):
|
||||
folded.append("pane Project/History")
|
||||
import cowork_local.ui.co4e_tab as co4e_mod
|
||||
c4 = win.findChildren(co4e_mod.Co4ETab)[0]
|
||||
if c4._config_collapsed:
|
||||
folded.append("panel cau hinh Co4E")
|
||||
scale = ui_scale(win)
|
||||
print(f"{pt:>5}pt {w:>5}x{h:<5} {scale:>8.2f} "
|
||||
f"{(', '.join(clipped) or 'khong'):>10} {', '.join(folded) or '-'}")
|
||||
if clipped:
|
||||
fails.append(f"{pt}pt {w}x{h}: bi bo — {clipped}")
|
||||
# The window must never demand more than the smallest panel we support.
|
||||
need = win.minimumSizeHint().width()
|
||||
if need > SIZES[0][0]:
|
||||
fails.append(f"{pt}pt: cua so doi toi thieu {need}px, "
|
||||
f"rong hon man nho nhat ({SIZES[0][0]}px)")
|
||||
print(f"{'':>7} {'':>11} {'':>9} cua so doi toi thieu: {need}px")
|
||||
win.close()
|
||||
del win
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** KHONG THICH UNG DUOC ***")
|
||||
for x in fails:
|
||||
print(" " + x)
|
||||
return 1
|
||||
print("KET QUA: bo cuc thich ung o moi co man hinh va muc phong chu da thu")
|
||||
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)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Smoke-test the flat nav rail against a real MainWindow.
|
||||
|
||||
Builds the window offscreen on a COPY of ~/.cowork_local (schedulers no-oped, so
|
||||
nothing scheduled can fire) and answers the questions the redesign has to get
|
||||
right:
|
||||
|
||||
* is every destination that used to be reachable still reachable?
|
||||
* does the rail highlight follow the content, from clicks AND from _goto?
|
||||
* do the project-gated rows stay listed (greyed) instead of disappearing?
|
||||
* does Monitoring still expose all eight sub-views, now via its own tab strip?
|
||||
|
||||
Run: python tools/check_nav.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)) # `import cowork_local`
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
|
||||
def rows(tree):
|
||||
from PySide6.QtCore import Qt
|
||||
out = []
|
||||
for i in range(tree.topLevelItemCount()):
|
||||
it = tree.topLevelItem(i)
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
out.append((it.text(0), data.get("page"), data.get("sub"),
|
||||
not it.isDisabled()))
|
||||
return out
|
||||
|
||||
|
||||
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 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.config import AppConfig
|
||||
from cowork_local.i18n import set_language
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
set_language("vi")
|
||||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||
app.processEvents()
|
||||
|
||||
fails: list[str] = []
|
||||
print("THANH MENU CHINH")
|
||||
for label, page, sub, on in rows(win.nav):
|
||||
print(f" {label:22} page={page} sub={sub} {'' if on else '(mo — chua chon project)'}")
|
||||
print("NHOM GHIM DAY")
|
||||
for label, page, sub, on in rows(win.nav_bottom):
|
||||
print(f" {label:22} page={page} sub={sub}")
|
||||
print(f"NUT: {win._nav_settings_btn.text()}")
|
||||
print()
|
||||
|
||||
main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom)
|
||||
n_total = len(main_rows) + len(bottom_rows)
|
||||
# Five Workspace sub-views + Schedule, then Dashboard + Monitoring.
|
||||
if len(main_rows) != 6:
|
||||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6")
|
||||
if len(bottom_rows) != 2:
|
||||
fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2")
|
||||
if any(sub is not None for _l, _p, sub, _o in bottom_rows):
|
||||
fails.append("nhom day khong duoc mang sub-tab")
|
||||
|
||||
# The two gated rows must be PRESENT (that is the point) — greyed is fine.
|
||||
labels = [r[0] for r in main_rows]
|
||||
ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()]
|
||||
for lab in ws_labels:
|
||||
if lab not in labels:
|
||||
fails.append(f"mat dong Workspace: {lab}")
|
||||
print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}"
|
||||
f" ({', '.join(ws_labels)})")
|
||||
|
||||
# Highlight must follow the content for every row, both ways round.
|
||||
# Re-fetch items by index every time: navigating can rebuild the rail, which
|
||||
# deletes the C++ objects a held reference points at.
|
||||
ok_click = ok_goto = 0
|
||||
for which, name in ((win.nav, "chinh"), (win.nav_bottom, "day")):
|
||||
for i in range(which.topLevelItemCount()):
|
||||
label, page, sub, on = rows(which)[i]
|
||||
if not on:
|
||||
continue
|
||||
which.setCurrentItem(which.topLevelItem(i)) # as if clicked
|
||||
app.processEvents()
|
||||
if win.pages.currentIndex() == page:
|
||||
ok_click += 1
|
||||
else:
|
||||
fails.append(f"bam '{label}' ({name}) khong mo dung trang")
|
||||
win._goto(page, sub) # programmatic
|
||||
app.processEvents()
|
||||
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
|
||||
if t.currentItem() is not None and t.currentItem().isSelected()), None)
|
||||
if cur is not None and cur.text(0) == label:
|
||||
ok_goto += 1
|
||||
else:
|
||||
fails.append(f"_goto toi '{label}' nhung vet sang o "
|
||||
f"'{cur.text(0) if cur else 'khong dau'}'")
|
||||
n_live = sum(1 for r in main_rows + bottom_rows if r[3])
|
||||
print(f"bam mo dung trang : {ok_click}/{n_live}")
|
||||
print(f"vet sang theo _goto : {ok_goto}/{n_live}")
|
||||
|
||||
# Only one row may look active across the two lists.
|
||||
lit = sum(1 for t in (win.nav, win.nav_bottom) for i in range(t.topLevelItemCount())
|
||||
if t.topLevelItem(i).isSelected())
|
||||
print(f"so dong dang sang : {lit} (phai la 1)")
|
||||
if lit != 1:
|
||||
fails.append(f"{lit} dong cung sang")
|
||||
|
||||
# Monitoring's eight sub-views moved to its own tab strip — check it is shown.
|
||||
win._ensure_page(win._ROW_MONITORING)
|
||||
mon = win._page_widgets[win._ROW_MONITORING]
|
||||
# isVisible() is False for everything while the window has never been shown;
|
||||
# isHidden() asks the question that actually matters here.
|
||||
strip_visible = not mon.tabs.tabBar().isHidden() if hasattr(mon, "tabs") else False
|
||||
n_sub = len(mon.nav_subtabs())
|
||||
print(f"Monitoring: {n_sub} man, dai tab hien = {strip_visible}")
|
||||
if n_sub != 8:
|
||||
fails.append(f"Monitoring chi con {n_sub} man")
|
||||
if not strip_visible:
|
||||
fails.append("dai tab Monitoring van bi an — 8 man khong toi duoc")
|
||||
|
||||
# Workspace's own strip stays hidden: the rail lists those five instead.
|
||||
ws_strip = not win.workspace.tabs.tabBar().isHidden()
|
||||
print(f"Workspace: dai tab hien = {ws_strip} (phai la False — thanh menu lo roi)")
|
||||
if ws_strip:
|
||||
fails.append("dai tab Workspace hien lai — trung voi thanh menu")
|
||||
|
||||
# The whole point of the change: with no project selected the two gated rows
|
||||
# must stay in place, greyed — not vanish and resize the menu.
|
||||
win.workspace._update_tab_visibility(False)
|
||||
app.processEvents()
|
||||
gated = rows(win.nav)
|
||||
off = [lab for lab, _p, _s, on in gated if not on]
|
||||
print()
|
||||
print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}")
|
||||
if len(gated) != len(main_rows):
|
||||
fails.append(f"chua chon project thi thanh menu con {len(gated)} dong "
|
||||
f"(truoc {len(main_rows)}) — item van bien mat")
|
||||
if len(off) != 2:
|
||||
fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}")
|
||||
|
||||
# --- rail header: project picker + new chat (Phase A) ------------------
|
||||
print()
|
||||
n_proj = win.nav_project.count()
|
||||
print(f"bo chon project : {n_proj} muc · dang chon "
|
||||
f"{win.nav_project.currentText()!r}")
|
||||
print(f"nut chat moi : {win.nav_new_chat.text()!r} "
|
||||
f"(bat = {win.nav_new_chat.isEnabled()})")
|
||||
if win.nav_project.currentData() != win.workspace.selected_project_id():
|
||||
fails.append("bo chon project khong khop voi project dang chon")
|
||||
|
||||
# Picking in the rail must move the real selection, not just the combo.
|
||||
if n_proj > 1:
|
||||
other = next(i for i in range(n_proj)
|
||||
if win.nav_project.itemData(i) != win.workspace.selected_project_id())
|
||||
want = win.nav_project.itemData(other)
|
||||
win.nav_project.setCurrentIndex(other)
|
||||
app.processEvents()
|
||||
got = win.workspace.selected_project_id()
|
||||
print(f"doi project tu rail: chon {want} -> workspace dang o {got}")
|
||||
if got != want:
|
||||
fails.append("doi project tren rail khong doi project that")
|
||||
if win.nav_project.currentData() != got:
|
||||
fails.append("bo chon khong dong bo nguoc lai")
|
||||
|
||||
# New chat from any screen: lands on Cowork with an empty thread, and the
|
||||
# old toolbar button must still be there.
|
||||
win._goto(win._ROW_DASHBOARD, None)
|
||||
app.processEvents()
|
||||
before = win.cowork.current_session_id() if hasattr(win.cowork, "current_session_id") else None
|
||||
win._on_rail_new_chat()
|
||||
app.processEvents()
|
||||
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
|
||||
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
|
||||
print(f"bam '+ chat moi' tu Dashboard -> dung o Cowork: {on_cowork}")
|
||||
if not on_cowork:
|
||||
fails.append("nut chat moi khong dua toi Cowork")
|
||||
old_btn = getattr(win.cowork, "_new_btn", None)
|
||||
print(f"nut cu tren thanh Cowork con nguyen: {old_btn is not None} "
|
||||
f"({old_btn.text()!r})" if old_btn is not None else "MAT NUT CU")
|
||||
if old_btn is None:
|
||||
fails.append("nut 'Cuoc tro chuyen moi' cu tren Cowork bi mat")
|
||||
|
||||
# --- rail RECENTS (Phase B) --------------------------------------------
|
||||
from PySide6.QtCore import Qt as _Qt
|
||||
win._refresh_rail_recents()
|
||||
app.processEvents()
|
||||
rec = win.nav_recents
|
||||
items = [(rec.topLevelItem(i).text(0), rec.topLevelItem(i).data(0, _Qt.UserRole) or {})
|
||||
for i in range(rec.topLevelItemCount())]
|
||||
threads = [t for t, d in items if d.get("path")]
|
||||
print()
|
||||
print(f"GAN DAY ({win.nav_recents_hdr.text()}): {len(threads)} thread"
|
||||
f" + dong '{items[-1][0]}'")
|
||||
for t in threads:
|
||||
print(f" {t}")
|
||||
if not items[-1][1].get("all"):
|
||||
fails.append("thieu dong 'Tat ca project…'")
|
||||
if len(threads) > win._RAIL_RECENTS:
|
||||
fails.append(f"GAN DAY liet ke {len(threads)} thread, toi da {win._RAIL_RECENTS}")
|
||||
|
||||
# Scoped to the active project — a flat cross-project list would lose that.
|
||||
pid = win.workspace.selected_project_id()
|
||||
all_titles = {t["title"] for t in win.workspace.recent_threads(99)}
|
||||
other_pid = next((p for _n, p in win.workspace.project_choices() if p != pid), "")
|
||||
if other_pid:
|
||||
win.workspace.choose_project(other_pid)
|
||||
app.processEvents()
|
||||
win._refresh_rail_recents()
|
||||
other_titles = {t["title"] for t in win.workspace.recent_threads(99)}
|
||||
print(f"doi sang project khac: danh sach doi = {other_titles != all_titles}")
|
||||
if other_titles & all_titles and other_titles == all_titles:
|
||||
fails.append("GAN DAY khong gom theo project — hai project cung mot danh sach")
|
||||
win.workspace.choose_project(pid)
|
||||
app.processEvents()
|
||||
win._refresh_rail_recents()
|
||||
|
||||
# Clicking a thread must open it through the normal route.
|
||||
if threads:
|
||||
win._goto(win._ROW_DASHBOARD, None)
|
||||
app.processEvents()
|
||||
# Re-fetch: the project switch above rebuilt this list, deleting the
|
||||
# items a held reference would point at.
|
||||
win._on_rail_recent(win.nav_recents.topLevelItem(0))
|
||||
app.processEvents()
|
||||
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
|
||||
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
|
||||
print(f"bam thread gan day -> mo o Cowork: {on_cowork}")
|
||||
if not on_cowork:
|
||||
fails.append("bam thread trong GAN DAY khong mo duoc")
|
||||
|
||||
# The full History panel must still exist, with all its controls.
|
||||
sb = win.sidebar
|
||||
kept = [n for n in ("search_box", "search_btn", "tree", "_collapse_btn")
|
||||
if getattr(sb, n, None) is not None]
|
||||
print(f"khung History day du van con: {len(kept)}/4 control goc {kept}")
|
||||
if len(kept) != 4:
|
||||
fails.append("khung History bi mat control")
|
||||
|
||||
# --- account row moved off the top bar (Phase C) -----------------------
|
||||
print()
|
||||
from PySide6.QtWidgets import QWidget as _QWidget
|
||||
top_kids = {w.objectName() or type(w).__name__
|
||||
for w in win.findChildren(_QWidget)
|
||||
if w.parent() is not None and w.parent().objectName() == "topbar"}
|
||||
print(f"top bar con lai : {sorted(top_kids)}")
|
||||
for name in ("provider_combo", "language_combo", "theme_btn"):
|
||||
w = getattr(win, name, None)
|
||||
if w is None:
|
||||
fails.append(f"mat control {name}")
|
||||
continue
|
||||
in_rail = win._nav_wrap.isAncestorOf(w)
|
||||
print(f" {name:16} nam trong rail = {in_rail}")
|
||||
if not in_rail:
|
||||
fails.append(f"{name} chua chuyen xuong rail")
|
||||
# They must still work, not just exist: flipping the language must retranslate.
|
||||
from cowork_local.i18n import get_language
|
||||
before_lang = get_language()
|
||||
other = next(i for i in range(win.language_combo.count())
|
||||
if win.language_combo.itemData(i) != before_lang)
|
||||
win.language_combo.setCurrentIndex(other)
|
||||
app.processEvents()
|
||||
after_lang = get_language()
|
||||
print(f"doi ngon ngu tu rail: {before_lang} -> {after_lang}")
|
||||
if after_lang == before_lang:
|
||||
fails.append("combo ngon ngu o rail khong doi duoc ngon ngu")
|
||||
win.language_combo.setCurrentIndex(win.language_combo.findData(before_lang))
|
||||
app.processEvents()
|
||||
print(f"provider dang chon : {win.provider_combo.currentText()!r}")
|
||||
print(f"tai khoan : {win.account_lbl.text()!r}")
|
||||
|
||||
# Collapsing the rail must not take the project picker away with it: at
|
||||
# 54px the combo cannot show a name, so a folder button stands in for it.
|
||||
if not win._nav_collapsed:
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
mini = getattr(win, "nav_project_btn", None)
|
||||
if mini is None or mini.isHidden():
|
||||
fails.append("thu gon rail xong khong con cach nao doi project")
|
||||
else:
|
||||
mini.menu().aboutToShow.emit()
|
||||
app.processEvents()
|
||||
menu_items = [a.text() for a in mini.menu().actions()]
|
||||
combo_items = [win.nav_project.itemText(i)
|
||||
for i in range(win.nav_project.count())]
|
||||
if menu_items != combo_items:
|
||||
fails.append(f"menu project khi thu gon lech voi combo: "
|
||||
f"{menu_items} vs {combo_items}")
|
||||
elif len(menu_items) > 1:
|
||||
before = win.workspace.selected_project_id()
|
||||
# Any row but the one already selected, or nothing would change.
|
||||
row = (win.nav_project.currentIndex() + 1) % len(menu_items)
|
||||
mini.menu().actions()[row].trigger()
|
||||
app.processEvents()
|
||||
after = win.workspace.selected_project_id()
|
||||
if after == before:
|
||||
fails.append("chon project tu menu thu gon khong doi project")
|
||||
else:
|
||||
print(f"doi project khi thu gon: {before} -> {after}")
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
|
||||
print()
|
||||
print(f"tong dong dieu huong: {n_total} + nut Cai dat")
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print("KET QUA: thanh menu phang chay dung")
|
||||
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)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Prove the long dialogs never scroll sideways — including at large fonts.
|
||||
|
||||
The report that started this came from a display at 125–150% scaling, where
|
||||
every label is wider than on a 100% screen. Rather than trusting one font size,
|
||||
this runs each dialog at several point sizes and several widths and fails if any
|
||||
horizontal scrollbar turns up, in the scroll area or in the section index.
|
||||
|
||||
Run: python tools/check_no_hscroll.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, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
WIDTHS = (1100, 964, 820, 700)
|
||||
POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
|
||||
|
||||
|
||||
def hscroll(dlg, app):
|
||||
"""(scroll-area overflow, index overflow) — each True means content is
|
||||
wider than the space it is given.
|
||||
|
||||
A dialog built from step tabs has one scroll area per page, and a page that
|
||||
is not current has stale geometry — so each tab is brought to the front
|
||||
before its page is measured.
|
||||
"""
|
||||
from PySide6.QtWidgets import QListWidget, QScrollArea
|
||||
over_area = False
|
||||
stack = getattr(dlg, "section_stack", None)
|
||||
if stack is not None:
|
||||
# One scroll area per section; a page that is not current has stale
|
||||
# geometry, so bring each to the front before measuring it.
|
||||
idx = dlg.section_list
|
||||
keep = idx.currentRow()
|
||||
for i in range(stack.count()):
|
||||
idx.setCurrentRow(i)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
sa = stack.widget(i)
|
||||
if sa.widget().sizeHint().width() > sa.viewport().width():
|
||||
over_area = True
|
||||
idx.setCurrentRow(keep)
|
||||
else:
|
||||
sa = dlg.findChildren(QScrollArea)[0]
|
||||
over_area = sa.widget().sizeHint().width() > sa.viewport().width()
|
||||
idx = dlg.findChild(QListWidget, "sectionIndex")
|
||||
over_idx = False
|
||||
if idx is not None:
|
||||
over_idx = _index_elides(idx)
|
||||
return over_area, over_idx
|
||||
|
||||
|
||||
def _index_elides(idx) -> bool:
|
||||
"""True when a section name does not fit the visible width of the list.
|
||||
|
||||
Two earlier attempts got this wrong:
|
||||
· `sizeHintForColumn(0) > viewport().width()` returns 182px at 9pt, 11pt
|
||||
and 14pt alike — it does not track the font, so it called the 9pt
|
||||
dialog broken while nothing on screen was clipped.
|
||||
· Asking the delegate whether it elides. It does not: the view lays each
|
||||
row out at its natural width and the viewport simply clips what runs
|
||||
past it, so a list squeezed to 90px still reported "no elision".
|
||||
|
||||
So compare the painted text against the width that is actually on screen.
|
||||
"""
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||||
|
||||
for i in range(idx.count()):
|
||||
row = idx.indexFromItem(idx.item(i))
|
||||
opt = QStyleOptionViewItem()
|
||||
idx.initViewItemOption(opt)
|
||||
opt.rect = idx.visualRect(row)
|
||||
idx.itemDelegate().initStyleOption(opt, row)
|
||||
box = idx.style().subElementRect(QStyle.SE_ItemViewItemText, opt, idx)
|
||||
label = idx.item(i).text()
|
||||
visible = idx.viewport().width() - box.left()
|
||||
if QFontMetrics(opt.font).horizontalAdvance(label) > visible:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
|
||||
_apply_theme(app) # measure the styled widget, 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 cowork_local.i18n import set_language
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
set_language("vi")
|
||||
ctx = AppContext(AppConfig.load())
|
||||
fails: list[str] = []
|
||||
|
||||
for pt in POINTS:
|
||||
f = QFont(app.font())
|
||||
f.setPointSize(pt)
|
||||
app.setFont(f)
|
||||
for name, make in (("Cai dat", lambda: SettingsDialog(ctx)),
|
||||
("Task editor", lambda: TaskEditorDialog(ctx=ctx))):
|
||||
dlg = make()
|
||||
dlg.show()
|
||||
row = []
|
||||
for w in WIDTHS:
|
||||
dlg.resize(w, 900)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
over_area, over_idx = hscroll(dlg, app)
|
||||
row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}")
|
||||
if over_area:
|
||||
fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang")
|
||||
if over_idx:
|
||||
fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang")
|
||||
print(f" {pt:>2}pt {name:12} {' '.join(row)}")
|
||||
dlg.close()
|
||||
print()
|
||||
|
||||
print("A = vung cuon tran · I = muc luc tran · . = khong tran")
|
||||
print()
|
||||
if fails:
|
||||
print("*** LOI ***")
|
||||
for x in fails:
|
||||
print(" " + x)
|
||||
return 1
|
||||
print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu")
|
||||
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)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Catch controls orphaned by a neighbouring container being removed.
|
||||
|
||||
The audit page defaults every control to "giữ nguyên tại chỗ" and lists only the
|
||||
ones that move. That default is unsafe when the thing a control sits *with* is
|
||||
removed — then "unchanged" is impossible and the control has quietly lost its
|
||||
home. This is how the Co4E "+ new workflow" button vanished from the proposal:
|
||||
it lives in the same layout row as the flow tab strip, and the strip was proposed
|
||||
for removal.
|
||||
|
||||
Note the relationship is SIBLING, not parent/child: `flow_row` holds both the
|
||||
scroller (wrapping `flow_bar`) and `flow_add_btn`. An earlier version of this
|
||||
check looked only for `container.addWidget(child)` and therefore found nothing —
|
||||
it passed while the bug was live. Verify any change here with --selftest.
|
||||
|
||||
Run: python tools/check_orphans.py [--selftest]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
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 / "tools"))
|
||||
|
||||
# A MOVES note containing one of these means the thing is going away, so anything
|
||||
# that only existed alongside it needs a new home.
|
||||
REMOVAL_WORDS = ("bỏ;", "bỏ ", "gộp", "thay thế")
|
||||
|
||||
|
||||
def layout_map(path: Path) -> tuple[dict[str, list[str]], dict[str, str]]:
|
||||
"""(layout var -> widget vars added to it, wrapper var -> widget it wraps)."""
|
||||
members: dict[str, list[str]] = {}
|
||||
alias: dict[str, str] = {}
|
||||
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
||||
continue
|
||||
try:
|
||||
owner = ast.unparse(node.func.value)
|
||||
args = [ast.unparse(a) for a in node.args]
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not args:
|
||||
continue
|
||||
if node.func.attr in ("addWidget", "addLayout"):
|
||||
members.setdefault(owner, []).append(args[0])
|
||||
elif node.func.attr == "setWidget":
|
||||
# QScrollArea(inner): the scroller stands in for what it holds.
|
||||
alias[owner] = args[0]
|
||||
return members, alias
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import build_audit_page as B
|
||||
|
||||
selftest = "--selftest" in argv
|
||||
moves = dict(B.MOVES)
|
||||
if selftest:
|
||||
# Re-create the original bug and prove the check reports it.
|
||||
moves.pop("self.flow_add_btn", None)
|
||||
|
||||
removed = {k for k, v in moves.items()
|
||||
if any(w in v.lower() for w in REMOVAL_WORDS)}
|
||||
ctl = json.loads((REPO / "docs" / "screens" / "controls.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
|
||||
problems: list[tuple[str, str, str, str]] = []
|
||||
n_sib = 0
|
||||
for rec in ctl:
|
||||
path = REPO / rec["file"]
|
||||
if not path.exists():
|
||||
continue
|
||||
members, alias = layout_map(path)
|
||||
labels = {c["var"]: (c.get("label_vi") or c.get("label") or "?")
|
||||
for c in rec["controls"]}
|
||||
for layout, kids in members.items():
|
||||
# Resolve wrappers so a scroller counts as the widget it holds.
|
||||
resolved = {k: alias.get(k, k) for k in kids}
|
||||
gone = [k for k, r in resolved.items() if r in removed]
|
||||
if not gone:
|
||||
continue
|
||||
for kid in kids:
|
||||
if resolved[kid] in removed or kid not in labels:
|
||||
continue
|
||||
n_sib += 1
|
||||
if kid not in moves:
|
||||
problems.append((rec["file"], kid, labels[kid],
|
||||
f"cung hang voi {resolved[gone[0]]}"))
|
||||
|
||||
print(f"control nam canh mot thanh phan bi bo : {n_sib}")
|
||||
print(f"thanh phan bi bo trong MOVES : {len(removed)}"
|
||||
f" {sorted(removed) if removed else ''}")
|
||||
print()
|
||||
if problems:
|
||||
print("*** CONTROL MO COI ***")
|
||||
for f, var, label, why in problems:
|
||||
print(f" {f}: {var} ({label}) — {why}")
|
||||
print()
|
||||
print(f"KET QUA: {len(problems)} control mat cho, can khai bao trong MOVES")
|
||||
return 0 if selftest else 1
|
||||
if selftest:
|
||||
print("KET QUA SELFTEST: *** THAT BAI — phep kiem KHONG bat duoc loi da biet ***")
|
||||
return 1
|
||||
print("KET QUA: khong co control nao bi mo coi")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""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
|
||||
|
||||
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")
|
||||
|
||||
# (name, file, find, replace, checker that must FAIL because of it)
|
||||
MUTATIONS = [
|
||||
("phong to cham tro ly gap doi khai bao",
|
||||
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
|
||||
"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())
|
||||
@@ -0,0 +1,116 @@
|
||||
"""With no project chosen, the gated screens must stay shut on every route.
|
||||
|
||||
The redesign shows Cowork and GraphRAG at all times instead of making them
|
||||
appear and disappear. That is a presentation change only — the gate is the same
|
||||
`isTabVisible` state as before — so this asserts the gate still actually holds,
|
||||
and holds for programmatic jumps too, not just for the greyed rail rows.
|
||||
|
||||
Runs against an EMPTY home, not a copy of the real one: with any project on
|
||||
disk the gate is open and the test proves nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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")
|
||||
|
||||
sandbox = Path(tempfile.mkdtemp(prefix="cowork-gate-"))
|
||||
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
|
||||
for _var in ("USERPROFILE", "HOME"):
|
||||
os.environ[_var] = str(sandbox)
|
||||
os.environ.pop("HOMEDRIVE", None)
|
||||
os.environ.pop("HOMEPATH", None)
|
||||
|
||||
from capture_screens import _apply_theme, _freeze_schedulers, _load_fonts # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||||
|
||||
from cowork_local.app import MainWindow
|
||||
from cowork_local.config import AppConfig
|
||||
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(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
|
||||
fails = []
|
||||
n_projects = len(win.workspace.project_choices())
|
||||
print(f"project tren dia: {n_projects}")
|
||||
if n_projects:
|
||||
print("FAIL home khong rong — phep thu vo nghia")
|
||||
sys.stdout.flush()
|
||||
os._exit(1)
|
||||
|
||||
tree = win.nav
|
||||
rows = [(i, tree.topLevelItem(i)) for i in range(tree.topLevelItemCount())]
|
||||
locked = [(i, it) for i, it in rows if it.isDisabled()]
|
||||
print("dong bi khoa :", [it.text(0) for _i, it in locked])
|
||||
print("dong mo binh thuong:", [it.text(0) for _i, it in rows if not it.isDisabled()])
|
||||
if not locked:
|
||||
fails.append("khong project nao ma khong dong nao bi khoa")
|
||||
|
||||
for _i, it in locked:
|
||||
if not it.toolTip(0):
|
||||
fails.append(f"dong khoa '{it.text(0)}' khong noi ly do")
|
||||
|
||||
for i, it in locked:
|
||||
label = it.text(0)
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
before = win.workspace.current_subtab()
|
||||
|
||||
tree.setCurrentItem(it)
|
||||
app.processEvents()
|
||||
if win.workspace.current_subtab() != before:
|
||||
fails.append(f"bam duoc vao '{label}' du dang khoa")
|
||||
|
||||
# the route a greyed row does not guard: a jump from code
|
||||
win._goto(data.get("page", 0), data.get("sub"))
|
||||
app.processEvents()
|
||||
landed = win.workspace.current_subtab()
|
||||
if landed == data.get("sub"):
|
||||
fails.append(f"_goto mo duoc '{label}' trong khi cong dang dong")
|
||||
print(f" '{label}': bam -> {before}, _goto -> {landed} "
|
||||
f"(tab hien = {win.workspace.tabs.isTabVisible(data.get('sub'))})")
|
||||
|
||||
# controls that would act on a project must be off too
|
||||
for name, widget in (("+ chat moi", win.nav_new_chat),
|
||||
("chon project", win.nav_project_btn)):
|
||||
if widget.isEnabled():
|
||||
fails.append(f"'{name}' van bam duoc khi chua co project")
|
||||
print(f"+ chat moi bat={win.nav_new_chat.isEnabled()} "
|
||||
f"tooltip={win.nav_new_chat.toolTip()!r}")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS cong project van giu, ca khi bam lan khi goi tu code" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Workspace ▸ Project against its wireframe (section 4).
|
||||
|
||||
The drawing: a "Quản lý project" title with + Project mới on its right, a caps
|
||||
PROJECT heading over the list, every row carrying "N đoạn chat · M task", and
|
||||
the form reading Tên / Mô tả / Instructions / Thư mục làm việc.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
from cowork_local.i18n import set_language, tr
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
set_language("vi")
|
||||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||
win.resize(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx)
|
||||
app.processEvents()
|
||||
|
||||
w = win.workspace
|
||||
fails = []
|
||||
|
||||
# 1. title, and the create button on its row, to its right
|
||||
print(f"tieu de: {w._header.text()!r}")
|
||||
if w._header.text() != tr("workspace.header"):
|
||||
fails.append("tieu de khong phai workspace.header")
|
||||
h_pos = w._header.mapTo(w, QPoint(0, 0))
|
||||
h_mid = h_pos.y() + w._header.height() // 2
|
||||
b_pos = w._new_btn.mapTo(w, QPoint(0, 0))
|
||||
b_mid = b_pos.y() + w._new_btn.height() // 2
|
||||
aligned = abs(b_mid - h_mid) <= 8
|
||||
after = b_pos.x() >= h_pos.x() + w._header.width()
|
||||
print(f"nut '+ Project mới': tam y={b_mid} (tieu de {h_mid}) thang hang={aligned} "
|
||||
f"| x={b_pos.x()} sau tieu de={after}")
|
||||
if not (aligned and after):
|
||||
fails.append("nut tao project khong nam cung hang, ben phai tieu de")
|
||||
|
||||
# 2. caps heading over the list
|
||||
hdr = w._projects_hdr.text()
|
||||
print(f"tieu de pane trai: {hdr!r}")
|
||||
if not hdr or hdr != hdr.upper():
|
||||
fails.append(f"tieu de pane trai chua viet hoa: {hdr!r}")
|
||||
|
||||
# 3. every row says how much is in the project
|
||||
lst = w.project_list
|
||||
if not lst.count():
|
||||
fails.append("khong co project nao de kiem")
|
||||
shown = 0
|
||||
for i in range(lst.count()):
|
||||
row = lst.itemWidget(lst.item(i))
|
||||
labels = [l.text() for l in row.findChildren(QLabel)] if row else []
|
||||
if len(labels) < 2:
|
||||
fails.append(f"hang {i} khong co dong dem chat/task")
|
||||
continue
|
||||
shown += 1
|
||||
if i < 2:
|
||||
print(f" hang {i}: {labels[0]!r} / {labels[1]!r}")
|
||||
# the sub-line must be the counts string, not the name repeated
|
||||
if labels[1] == labels[0] or not any(ch.isdigit() for ch in labels[1]):
|
||||
fails.append(f"hang {i}: dong phu khong phai so dem: {labels[1]!r}")
|
||||
print(f"so hang co dong dem: {shown}/{lst.count()}")
|
||||
|
||||
# 4. the form reads as the drawing labels it
|
||||
want = [tr("workspace.name"), tr("workspace.description"),
|
||||
tr("workspace.instructions"), tr("workspace.folder_label")]
|
||||
seen = [l.text() for l in w.findChildren(QLabel) if l.isVisible() and l.text()]
|
||||
for label in want:
|
||||
if label not in seen:
|
||||
fails.append(f"thieu nhan {label!r}")
|
||||
print(f"nhan form: {want}")
|
||||
|
||||
# 5. the drawing heads a populated screen with the title alone; the
|
||||
# explanation belongs to an empty one.
|
||||
print(f"hint hien voi {lst.count()} project: {w._hint.isVisible()}")
|
||||
if lst.count() and w._hint.isVisible():
|
||||
fails.append("doan giai thich van hien du da co project")
|
||||
|
||||
# 6. the path is a field in the drawing, not caption text
|
||||
from PySide6.QtWidgets import QLineEdit
|
||||
is_field = isinstance(w.folder_lbl, QLineEdit) and w.folder_lbl.isReadOnly()
|
||||
print(f"o thu muc: {type(w.folder_lbl).__name__} (o nhap chi doc={is_field})")
|
||||
if not is_field:
|
||||
fails.append("duong dan thu muc khong phai o nhap chi doc")
|
||||
|
||||
# 7. Lưu project floats at the foot of the panel, not right under the form
|
||||
save_y = w._save_btn.mapTo(w, QPoint(0, 0)).y()
|
||||
folder_y = w.folder_lbl.mapTo(w, QPoint(0, 0)).y()
|
||||
print(f"nut Luu y={save_y}, o thu muc y={folder_y}, cach {save_y - folder_y}px")
|
||||
if save_y - folder_y < 80:
|
||||
fails.append("nut Luu khong bi day xuong day panel")
|
||||
|
||||
# 8. the rail's picker must name the projects. It reads project_choices(),
|
||||
# which used to read item.text() — and when rows became widgets the item
|
||||
# text went empty, so every entry showed as a bare folder glyph. Creating
|
||||
# a project is when a user notices, so create one here.
|
||||
before = [n for n, _pid in w.project_choices()]
|
||||
w._create()
|
||||
app.processEvents()
|
||||
choices = w.project_choices()
|
||||
picker = [win.nav_project.itemText(i) for i in range(win.nav_project.count())]
|
||||
print(f"project_choices: {[n for n, _p in choices][:4]}")
|
||||
print(f"picker hien thi: {picker[:4]}")
|
||||
if any(not name.strip() for name, _pid in choices):
|
||||
fails.append("project_choices tra ve ten rong")
|
||||
if len(choices) <= len(before):
|
||||
fails.append("tao project moi khong vao danh sach")
|
||||
for name, _pid in choices:
|
||||
if not any(name in text for text in picker):
|
||||
fails.append(f"picker khong hien ten {name!r}")
|
||||
break
|
||||
|
||||
# 9. the title row sits above the sub-tabs, so what is on it must follow
|
||||
# the title — + Project mới turned up in the corner of every other one.
|
||||
for idx, name in ((w._cowork_tab_idx, "Cowork"), (w._co4e_tab_idx, "Co4E"),
|
||||
(w._folder_tab_idx, "Thu muc"),
|
||||
(w._graphrag_tab_idx, "GraphRAG")):
|
||||
if idx < 0:
|
||||
continue
|
||||
win._goto(win._ROW_WORKSPACE, idx)
|
||||
app.processEvents()
|
||||
if w._new_btn.isVisible():
|
||||
fails.append(f"nut '+ Project moi' hien ca o man {name}")
|
||||
win._goto(win._ROW_WORKSPACE, w._project_tab_idx)
|
||||
app.processEvents()
|
||||
print(f"nut tao chi hien o Project: {not any('Project moi' in f for f in fails)}")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS man Project khop ban ve" if not fails else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Rail icons must sit on one vertical line, and stay there when it collapses.
|
||||
|
||||
Two bugs this catches:
|
||||
· "Cài đặt" sat 42px right of "Dashboard"/"Giám sát" — its QSS margin pushed
|
||||
the button in while the tree rows above start at the rail edge.
|
||||
· Collapsing re-placed the icon of every label-less button, sliding + to the
|
||||
middle of the 54px rail.
|
||||
|
||||
Runs with the app's real stylesheet loaded. Without it the window has no
|
||||
padding, margins or borders and neither bug is visible — see _apply_theme.
|
||||
"""
|
||||
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)
|
||||
|
||||
TOL = 2 # px; anti-aliasing on an icon edge
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
theme_name = _apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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.config import AppConfig
|
||||
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(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
rail = win._nav_wrap
|
||||
|
||||
def ink_x(w):
|
||||
"""Leftmost painted pixel of a widget, in rail coordinates."""
|
||||
img = w.grab().toImage()
|
||||
bg = img.pixelColor(w.width() - 3, 2)
|
||||
for x in range(img.width()):
|
||||
for y in range(2, img.height() - 2):
|
||||
c = img.pixelColor(x, y)
|
||||
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
|
||||
+ abs(c.blue() - bg.blue())) > 60:
|
||||
return w.mapTo(rail, QPoint(x, 0)).x()
|
||||
return None
|
||||
|
||||
def tree_text_x(tree):
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||||
opt = QStyleOptionViewItem()
|
||||
tree.initViewItemOption(opt)
|
||||
opt.rect = tree.visualRect(index)
|
||||
tree.itemDelegate().initStyleOption(opt, index)
|
||||
txt = tree.style().subElementRect(QStyle.SE_ItemViewItemText, opt, tree)
|
||||
return tree.mapTo(rail, QPoint(txt.left(), 0)).x()
|
||||
|
||||
def btn_text_x(w):
|
||||
"""Left edge of the label: first ink past the icon's gap."""
|
||||
# Settings lays its own row out now, so read the label widget directly
|
||||
# rather than hunting for a gap in the painted pixels.
|
||||
lbl = getattr(win, "_nav_settings_text", None)
|
||||
if w is getattr(win, "_nav_settings_btn", None) and lbl is not None:
|
||||
return lbl.mapTo(rail, QPoint(0, 0)).x() if lbl.isVisible() else None
|
||||
if not w.text():
|
||||
return None
|
||||
img = w.grab().toImage()
|
||||
bg = img.pixelColor(w.width() - 3, 2)
|
||||
ink = []
|
||||
for x in range(img.width()):
|
||||
for y in range(2, img.height() - 2):
|
||||
c = img.pixelColor(x, y)
|
||||
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
|
||||
+ abs(c.blue() - bg.blue())) > 60:
|
||||
ink.append(x)
|
||||
break
|
||||
if not ink:
|
||||
return None
|
||||
for a, b in zip(ink, ink[1:]): # first gap = icon/label spacing
|
||||
if b - a > 2:
|
||||
return w.mapTo(rail, QPoint(b, 0)).x()
|
||||
return None
|
||||
|
||||
def tree_icon_x(tree):
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||||
opt = QStyleOptionViewItem()
|
||||
tree.initViewItemOption(opt)
|
||||
opt.rect = tree.visualRect(index)
|
||||
tree.itemDelegate().initStyleOption(opt, index)
|
||||
deco = tree.style().subElementRect(
|
||||
QStyle.SE_ItemViewItemDecoration, opt, tree)
|
||||
return tree.mapTo(rail, QPoint(deco.left(), 0)).x()
|
||||
|
||||
def snapshot():
|
||||
app.processEvents()
|
||||
out = {"nav rows": tree_icon_x(win.nav),
|
||||
"bottom rows": tree_icon_x(win.nav_bottom),
|
||||
"bottom rows text": tree_text_x(win.nav_bottom)}
|
||||
for label, attr in (("MENU", "_nav_toggle_btn"),
|
||||
("new chat", "nav_new_chat"),
|
||||
("settings", "_nav_settings_btn")):
|
||||
w = getattr(win, attr, None)
|
||||
if w is not None and w.isVisible():
|
||||
out[label] = ink_x(w)
|
||||
if label == "settings":
|
||||
out["settings text"] = btn_text_x(w)
|
||||
return out
|
||||
|
||||
fails = []
|
||||
for theme_name in ("dark", "light"):
|
||||
_apply_theme(app, theme_name)
|
||||
app.processEvents()
|
||||
if win._nav_collapsed:
|
||||
win._toggle_nav()
|
||||
opened = snapshot()
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
closed = snapshot()
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
fails += compare(theme_name, rail, opened, closed)
|
||||
fails += column_widths_do_not_move_icons(win, app, theme_name)
|
||||
fails += rows_stay_under_the_header(win, app, theme_name)
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print(f"FAIL {f}")
|
||||
print("PASS every rail icon holds its line" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
def rows_stay_under_the_header(win, app, theme_name):
|
||||
"""The destinations start just below the + button in both states.
|
||||
|
||||
Collapsing hides RECENTS, the one item in the scroll body with a stretch
|
||||
factor. With nothing left to expand, a box layout centres what remains, and
|
||||
the whole group slid ~300px down the rail.
|
||||
"""
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
rail = win._nav_wrap
|
||||
gaps = {}
|
||||
for state in ("open", "collapsed"):
|
||||
if (state == "collapsed") != win._nav_collapsed:
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
btn = win.nav_new_chat
|
||||
below = btn.mapTo(rail, QPoint(0, btn.height())).y()
|
||||
top = win.nav.mapTo(rail, QPoint(0, 0)).y() + win.nav.visualItemRect(win.nav.topLevelItem(0)).top()
|
||||
gaps[state] = top - below
|
||||
if win._nav_collapsed:
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
print(f" gap under +: open={gaps['open']}px collapsed={gaps['collapsed']}px")
|
||||
if abs(gaps["collapsed"] - gaps["open"]) > 4:
|
||||
return [f"{theme_name}: the destinations sit {gaps['collapsed']}px below "
|
||||
f"the + button when collapsed but {gaps['open']}px when open"]
|
||||
return []
|
||||
|
||||
|
||||
def column_widths_do_not_move_icons(win, app, theme_name):
|
||||
"""The icon must not care how wide the column is.
|
||||
|
||||
On the machine that reported this the column matched the rail and the icons
|
||||
sat in the middle; in a test render the column stayed wider than the view
|
||||
and the same code drew them at the left. So sweep the width and require the
|
||||
icon to hold still.
|
||||
"""
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||||
|
||||
if not win._nav_collapsed:
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
fails, seen = [], {}
|
||||
# The invariant that matters. A centred decoration is the only way Qt can
|
||||
# put a label-less row's icon anywhere but the left edge, and how far it
|
||||
# travels depends on the box the column hands it — which is why this
|
||||
# reproduces on one machine and not another. Require the instruction
|
||||
# itself, not just the pixel it happens to produce here.
|
||||
from PySide6.QtCore import Qt as _Qt
|
||||
for tree_name in ("nav", "nav_bottom"):
|
||||
tree = getattr(win, tree_name)
|
||||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||||
opt = QStyleOptionViewItem()
|
||||
tree.initViewItemOption(opt)
|
||||
tree.itemDelegate().initStyleOption(opt, index)
|
||||
align = int(opt.decorationAlignment)
|
||||
if align & int(_Qt.AlignHCenter) or not align & int(_Qt.AlignLeft):
|
||||
fails.append(f"{theme_name} {tree_name}: decorationAlignment={align}"
|
||||
f" — icon is free to drift off the left edge")
|
||||
for tree_name in ("nav", "nav_bottom"):
|
||||
tree = getattr(win, tree_name)
|
||||
keep = tree.columnWidth(0)
|
||||
for width in (tree.viewport().width(), 70, 100, 140):
|
||||
tree.setColumnWidth(0, width)
|
||||
app.processEvents()
|
||||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||||
opt = QStyleOptionViewItem()
|
||||
tree.initViewItemOption(opt)
|
||||
opt.rect = tree.visualRect(index)
|
||||
tree.itemDelegate().initStyleOption(opt, index)
|
||||
x = tree.style().subElementRect(
|
||||
QStyle.SE_ItemViewItemDecoration, opt, tree).left()
|
||||
seen.setdefault(tree_name, []).append((width, x))
|
||||
tree.setColumnWidth(0, keep)
|
||||
app.processEvents()
|
||||
xs = {x for _w, x in seen[tree_name]}
|
||||
if len(xs) > 1:
|
||||
fails.append(f"{theme_name} {tree_name}: icon x changes with the "
|
||||
f"column width — {seen[tree_name]}")
|
||||
print(f" column sweep: " + " ".join(
|
||||
f"{n}={[x for _w, x in v]}" for n, v in seen.items()))
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
return fails
|
||||
|
||||
|
||||
def compare(theme_name, rail, opened, closed):
|
||||
print()
|
||||
print(f"theme={theme_name}")
|
||||
print(f"{'element':<12}{'open':>7}{'collapsed':>11}{'drift':>8}")
|
||||
fails = []
|
||||
for key in opened:
|
||||
a, b = opened[key], closed.get(key)
|
||||
drift = "-" if a is None or b is None else f"{b - a:+d}"
|
||||
print(f"{key:<12}{str(a):>7}{str(b):>11}{drift:>8}")
|
||||
if a is not None and b is not None and abs(b - a) > TOL:
|
||||
fails.append(f"{key}: icon moves {b - a:+d}px when the rail collapses")
|
||||
|
||||
# Settings is a button but reads as one more row in the bottom list, so
|
||||
# both its icon and its label have to start where theirs do.
|
||||
for state, snap in (("open", opened), ("collapsed", closed)):
|
||||
for what in ("", " text"):
|
||||
ref, got = snap.get("bottom rows" + what), snap.get("settings" + what)
|
||||
if ref is not None and got is not None and abs(got - ref) > TOL:
|
||||
fails.append(f"{theme_name} {state}: settings{what} x={got} but "
|
||||
f"the rows above it start at x={ref}")
|
||||
return fails
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The splitter handle beside the rail has to actually move the rail.
|
||||
|
||||
setFixedWidth left it drawn but inert: it looked draggable and did nothing.
|
||||
Also checks that a width the user drags to survives a collapse/expand, and
|
||||
that collapsing still pins the rail at 54px.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sandbox = _isolate_home()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
_load_fonts()
|
||||
_freeze_schedulers()
|
||||
_apply_theme(app)
|
||||
|
||||
from cowork_local.config import 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 (
|
||||
_NAV_COLLAPSED_WIDTH, _NAV_MIN_WIDTH, MainWindow)
|
||||
from cowork_local.config import AppConfig
|
||||
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(1400, 900)
|
||||
win.show()
|
||||
app.processEvents()
|
||||
|
||||
fails = []
|
||||
rail, split = win._nav_wrap, win.split
|
||||
|
||||
def drag_to(px):
|
||||
"""What the splitter does when the handle is dragged."""
|
||||
total = sum(split.sizes())
|
||||
split.setSizes([px, max(1, total - px)])
|
||||
app.processEvents()
|
||||
win._on_split_moved(px, 1)
|
||||
app.processEvents()
|
||||
return rail.width()
|
||||
|
||||
start = rail.width()
|
||||
wide = drag_to(300)
|
||||
print(f"keo rong : {start} -> {wide}px")
|
||||
if wide <= start:
|
||||
fails.append(f"keo tay nam ra 300px ma rail van {wide}px")
|
||||
|
||||
narrow = drag_to(_NAV_MIN_WIDTH)
|
||||
print(f"keo hep : {wide} -> {narrow}px")
|
||||
if narrow >= wide:
|
||||
fails.append(f"keo hep lai khong an: {narrow}px")
|
||||
|
||||
# The ceiling is a share of the window now, so ask the window for it.
|
||||
ceiling = win._nav_max_width()
|
||||
over = drag_to(ceiling + 200)
|
||||
print(f"keo qua max: {over}px (tran {ceiling} = {ceiling * 100 // win.width()}% cua so)")
|
||||
if over > ceiling:
|
||||
fails.append(f"rail vuot tran: {over} > {ceiling}")
|
||||
|
||||
under = drag_to(20)
|
||||
print(f"keo duoi min: {under}px (san {_NAV_MIN_WIDTH})")
|
||||
if under < _NAV_MIN_WIDTH:
|
||||
fails.append(f"rail thap hon san: {under} < {_NAV_MIN_WIDTH}")
|
||||
|
||||
# a dragged width has to come back after a fold
|
||||
chosen = drag_to(min(280, win._nav_max_width()))
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
folded = rail.width()
|
||||
print(f"thu gon : {folded}px")
|
||||
if folded != _NAV_COLLAPSED_WIDTH:
|
||||
fails.append(f"thu gon phai la {_NAV_COLLAPSED_WIDTH}px, dang {folded}px")
|
||||
win._toggle_nav()
|
||||
app.processEvents()
|
||||
back = rail.width()
|
||||
print(f"mo lai : {back}px (da chon {chosen}px)")
|
||||
if abs(back - chosen) > 4:
|
||||
fails.append(f"mo lai quen be rong da keo: {back} thay vi {chosen}")
|
||||
|
||||
# The ceiling is a share, so it has to move with the window — it was read
|
||||
# once at construction and stuck at 162px on every monitor.
|
||||
seen = {}
|
||||
for w in (1280, 1600, 1936):
|
||||
win.resize(w, 900)
|
||||
app.processEvents()
|
||||
split.setSizes([2000, 1]) # drag the handle as far right as it goes
|
||||
app.processEvents()
|
||||
seen[w] = (rail.width(), win._nav_max_width())
|
||||
print(f"cua so {w}: keo het co -> {seen[w][0]}px (tran {seen[w][1]}px)")
|
||||
for w, (got, ceiling) in seen.items():
|
||||
if abs(got - ceiling) > 4:
|
||||
fails.append(f"cua so {w}: keo het chi duoc {got}px, tran la {ceiling}px")
|
||||
if len({c for _g, c in seen.values()}) == 1:
|
||||
fails.append("tran khong doi theo be rong cua so — dang la px co dinh")
|
||||
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print("PASS tay nam keo duoc, nho be rong qua lan gap" if not fails
|
||||
else f"{len(fails)} problem(s)")
|
||||
sys.stdout.flush()
|
||||
os._exit(1 if fails else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Measure what actually breaks on a small screen, screen by screen.
|
||||
|
||||
A pane is "clipped" when the width it is given is smaller than the width it says
|
||||
it needs (minimumSizeHint): Qt then cuts content off instead of shrinking it,
|
||||
which is what shows up as half-drawn buttons and cut-off labels.
|
||||
|
||||
Reports per destination, at a few window sizes, and lists the widest offenders
|
||||
so a fix can be aimed at the right widget instead of guessed at.
|
||||
|
||||
Run: python tools/check_responsive.py [width height ...]
|
||||
"""
|
||||
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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
|
||||
|
||||
SIZES = [(1920, 1080), (1366, 768), (1280, 720)]
|
||||
|
||||
|
||||
def panes(widget):
|
||||
"""Direct children worth measuring: splitter panes and page-level boxes."""
|
||||
from PySide6.QtWidgets import QSplitter
|
||||
out = []
|
||||
for sp in widget.findChildren(QSplitter):
|
||||
for i in range(sp.count()):
|
||||
w = sp.widget(i)
|
||||
if w is not None and not w.isHidden():
|
||||
out.append((f"{sp.objectName() or 'splitter'}[{i}] {type(w).__name__}", w))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv) -> int:
|
||||
sizes = SIZES
|
||||
if len(argv) >= 2:
|
||||
sizes = [(int(argv[i]), int(argv[i + 1])) for i in range(0, len(argv) - 1, 2)]
|
||||
|
||||
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
|
||||
from cowork_local.theme import set_active_theme, stylesheet
|
||||
|
||||
set_language("vi")
|
||||
cfg = AppConfig.load()
|
||||
set_active_theme(cfg.theme)
|
||||
app.setStyleSheet(stylesheet(cfg.theme))
|
||||
win = MainWindow(AppContext(cfg), user_name="local")
|
||||
win.show()
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
dests = [("Project", win._ROW_WORKSPACE, win.workspace._project_tab_idx),
|
||||
("Cowork", win._ROW_WORKSPACE, win.workspace._cowork_tab_idx),
|
||||
("Co4E", win._ROW_WORKSPACE, win.workspace._co4e_tab_idx),
|
||||
("Folder", win._ROW_WORKSPACE, win.workspace._folder_tab_idx),
|
||||
("GraphRAG", win._ROW_WORKSPACE, win.workspace._graphrag_tab_idx),
|
||||
("Schedule", win._ROW_SCHEDULE, None),
|
||||
("Dashboard", win._ROW_DASHBOARD, None),
|
||||
("Monitoring", win._ROW_MONITORING, None)]
|
||||
|
||||
print(f"cua so: minimumSizeHint = {win.minimumSizeHint().width()}"
|
||||
f"x{win.minimumSizeHint().height()}px")
|
||||
print()
|
||||
worst: dict[str, int] = {}
|
||||
for w, h in sizes:
|
||||
win.resize(w, h)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
print(f"=== {w}x{h} ===")
|
||||
for name, page, sub in dests:
|
||||
win._goto(page, sub)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
widget = win._page_widgets[page]
|
||||
need = widget.minimumSizeHint().width()
|
||||
have = widget.width()
|
||||
tight = [(n, p.minimumSizeHint().width(), p.width())
|
||||
for n, p in panes(widget)
|
||||
if p.minimumSizeHint().width() > p.width() + 1]
|
||||
flag = "" if need <= have else f" <-- THIEU {need - have}px"
|
||||
print(f" {name:11} can {need:5}px · duoc {have:5}px{flag}")
|
||||
for n, nd, hv in tight:
|
||||
print(f" · {n:34} can {nd:4} duoc {hv:4}")
|
||||
worst[f"{name}/{n}"] = max(worst.get(f"{name}/{n}", 0), nd - hv)
|
||||
print()
|
||||
|
||||
if worst:
|
||||
print("BO BO NHIEU NHAT:")
|
||||
for k, v in sorted(worst.items(), key=lambda kv: -kv[1])[:12]:
|
||||
print(f" {v:5}px {k}")
|
||||
else:
|
||||
print("KET QUA: khong pane nao bi bo o cac co da thu")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Extract every interactive control from the UI source, mechanically.
|
||||
|
||||
Reading the files by hand and listing what I notice is exactly how functionality
|
||||
gets dropped from a redesign. This walks the AST instead, so the inventory is
|
||||
exhaustive by construction: if a widget is constructed in the file, it appears.
|
||||
|
||||
For each control it reports the variable it is bound to, its widget type, the
|
||||
label expression (usually a ``tr("...")`` key), the signal handlers wired to it,
|
||||
and the source line — enough to check "did the new design keep this?".
|
||||
|
||||
Run: python tools/extract_controls.py [ui/file.py ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
UI = REPO / "ui"
|
||||
|
||||
# Widget types that represent something the user can click, type in or toggle.
|
||||
WIDGETS = {
|
||||
"QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist",
|
||||
"QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập",
|
||||
"QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng",
|
||||
"QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ",
|
||||
"QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt",
|
||||
"QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng",
|
||||
"QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại",
|
||||
}
|
||||
# Signals worth recording — these are the "it does something" wires.
|
||||
SIGNALS = {
|
||||
"clicked", "toggled", "currentIndexChanged", "currentTextChanged",
|
||||
"textChanged", "returnPressed", "valueChanged", "itemClicked",
|
||||
"itemDoubleClicked", "currentItemChanged", "currentChanged",
|
||||
"customContextMenuRequested", "tabCloseRequested", "linkActivated",
|
||||
"stateChanged", "activated", "triggered", "editingFinished",
|
||||
}
|
||||
|
||||
|
||||
def _txt(node) -> str:
|
||||
"""Best-effort source text for a label expression."""
|
||||
try:
|
||||
return ast.unparse(node)
|
||||
except Exception: # noqa: BLE001
|
||||
return "?"
|
||||
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.controls: dict[str, dict] = {} # var name -> record
|
||||
self.menu_actions: list[dict] = []
|
||||
|
||||
# ---- self.btn = QPushButton(...) / btn = QComboBox() -------------------
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
if isinstance(node.value, ast.Call):
|
||||
fn = node.value.func
|
||||
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
|
||||
if name in WIDGETS:
|
||||
for tgt in node.targets:
|
||||
var = _txt(tgt)
|
||||
args = [_txt(a) for a in node.value.args]
|
||||
self.controls.setdefault(var, {
|
||||
"var": var, "type": name, "kind": WIDGETS[name],
|
||||
"label": args[0] if args else "",
|
||||
"line": node.lineno, "signals": [], "object_name": "",
|
||||
})
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
fn = node.func
|
||||
# ---- x.clicked.connect(handler) ----------------------------------
|
||||
if isinstance(fn, ast.Attribute) and fn.attr == "connect":
|
||||
sig = fn.value
|
||||
if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS:
|
||||
var = _txt(sig.value)
|
||||
rec = self.controls.get(var)
|
||||
if rec is not None and node.args:
|
||||
rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}")
|
||||
# ---- x.setText(tr("...")) / setObjectName / setToolTip -----------
|
||||
if isinstance(fn, ast.Attribute) and node.args:
|
||||
var = _txt(fn.value)
|
||||
rec = self.controls.get(var)
|
||||
if rec is not None:
|
||||
if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]:
|
||||
rec["label"] = _txt(node.args[0])
|
||||
elif fn.attr == "setObjectName":
|
||||
rec["object_name"] = _txt(node.args[0]).strip("'\"")
|
||||
elif fn.attr == "setToolTip" and not rec["label"]:
|
||||
rec["label"] = _txt(node.args[0])
|
||||
# ---- menu.addAction("Xoá") — context menus are real features -----
|
||||
if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args:
|
||||
self.menu_actions.append({
|
||||
"menu": _txt(fn.value), "label": _txt(node.args[0]),
|
||||
"line": node.lineno,
|
||||
})
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def scan(path: Path) -> dict:
|
||||
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
|
||||
v = Visitor(path)
|
||||
v.visit(tree)
|
||||
# Drop pure containers with no wiring and no label — they are layout, not
|
||||
# controls the user acts on directly.
|
||||
controls = [c for c in v.controls.values()
|
||||
if c["signals"] or c["label"] or c["object_name"]]
|
||||
controls.sort(key=lambda c: c["line"])
|
||||
return {"file": str(path.relative_to(REPO)),
|
||||
"controls": controls, "menu_actions": v.menu_actions}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
targets = [Path(a) for a in argv] or sorted(UI.glob("*.py"))
|
||||
out = []
|
||||
for t in targets:
|
||||
if t.name == "__init__.py":
|
||||
continue
|
||||
p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t)
|
||||
try:
|
||||
out.append(scan(p))
|
||||
except SyntaxError as exc: # noqa: PERF203
|
||||
print(f" SKIP {p.name}: {exc}", file=sys.stderr)
|
||||
dest = REPO / "docs" / "screens" / "controls.json"
|
||||
dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8")
|
||||
n_ctl = sum(len(f["controls"]) for f in out)
|
||||
n_act = sum(len(f["menu_actions"]) for f in out)
|
||||
print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Lift the hand-written audit sections out of 10-18.ui-audit.html.
|
||||
|
||||
That file was edited by hand: eight sections carry richer wireframes, prose and
|
||||
interactive tables than the generator produces, plus the CSS and scripts they
|
||||
need. Keeping two HTML files around means they drift, so this pulls the
|
||||
hand-written parts into ``tools/audit_handwritten.py`` — a data module the
|
||||
builder merges back in, making ``docs/ui-audit.html`` the single output again.
|
||||
|
||||
Two things are tokenised out before storing, so they stay generated rather than
|
||||
frozen at extraction time:
|
||||
{{SHOT}} the screenshot block (keeps ~8 MB of base64 out of the module)
|
||||
{{CONTROLS}} the AST-derived control inventory (must track controls.json)
|
||||
|
||||
Workflow when you hand-edit one of those sections directly in the page:
|
||||
1. edit docs/ui-audit.html
|
||||
2. python tools/extract_handwritten.py (reads it back into the module)
|
||||
3. python tools/build_audit_page.py (regenerates, edits preserved)
|
||||
Pass another filename to import sections from a different copy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
DOCS = REPO / "docs"
|
||||
OUT = REPO / "tools" / "audit_handwritten.py"
|
||||
|
||||
# Hand-written sections are DETECTED, not listed: every section whose text
|
||||
# differs from what the generator alone would emit is stored.
|
||||
#
|
||||
# There used to be a fixed list here, and it cost a section — "Monitoring ▸ Công
|
||||
# cụ" was hand-written but missing from the list, so each rebuild quietly put
|
||||
# the generated version back. Diffing against the live output cannot work (it
|
||||
# already contains the merged result and would find nothing), so the reference
|
||||
# is a generator-only render produced in-process, with the merge disabled.
|
||||
#
|
||||
# These are the ones known so far; anything else detected is added on top.
|
||||
KNOWN = [
|
||||
"monitoring-sự-kiện-bảo-mật", "monitoring-lịch-sử-gọi-mcp",
|
||||
"monitoring-nhật-ký-hành-động", "monitoring-trạng-thái-agent",
|
||||
"monitoring-agents-admin", "monitoring-icon", "monitoring-công-cụ",
|
||||
"dialog-settings", "dialog-task-editor",
|
||||
]
|
||||
|
||||
SECTION = re.compile(r'<section class="sec" id="([^"]+)">(.*?)</section>', re.S)
|
||||
BODY = re.compile(r'(<div class="bd">.*)', re.S)
|
||||
SHOT = re.compile(r'<div class="shot">.*?</div>', re.S)
|
||||
CONTROLS = re.compile(r'<details class="ctl">.*?</details>', re.S)
|
||||
STYLE = re.compile(r"<style>(.*?)</style>", re.S)
|
||||
SCRIPT = re.compile(r"<script>(.*?)</script>", re.S)
|
||||
|
||||
|
||||
def bodies(html: str) -> dict[str, str]:
|
||||
"""slug -> the section's <div class="bd"> … </div>, header excluded."""
|
||||
out = {}
|
||||
for m in SECTION.finditer(html):
|
||||
b = BODY.search(m.group(2))
|
||||
if b:
|
||||
out[m.group(1)] = b.group(1).strip()
|
||||
return out
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
src_path = DOCS / (argv[0] if argv else "ui-audit.html")
|
||||
if not src_path.exists():
|
||||
print(f"khong thay {src_path}")
|
||||
return 1
|
||||
|
||||
src = src_path.read_text(encoding="utf-8")
|
||||
# Screenshots are re-embedded by the builder; keep the base64 out of here.
|
||||
hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>", src))
|
||||
|
||||
sys.path.insert(0, str(REPO / "tools"))
|
||||
import build_audit_page as B
|
||||
|
||||
# Render what the generator ALONE would produce, into a temp file, and treat
|
||||
# every section that differs from it as hand-written.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
keep_out, keep_hand = B.OUT, B.HAND_SECTIONS
|
||||
B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {}
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
B.main()
|
||||
made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>",
|
||||
B.OUT.read_text(encoding="utf-8")))
|
||||
finally:
|
||||
B.OUT, B.HAND_SECTIONS = keep_out, keep_hand
|
||||
|
||||
detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, "")))
|
||||
slugs = [s for s in hand if s in set(detected) | set(KNOWN)]
|
||||
new = [s for s in detected if s not in KNOWN]
|
||||
gone = [s for s in KNOWN if s in hand and s not in detected]
|
||||
if new:
|
||||
print(f"phat hien them section viet tay: {new}")
|
||||
if gone:
|
||||
# Not an error: a hand section can be edited back to match the generator.
|
||||
print(f"section trong KNOWN nay giong ban sinh: {gone}")
|
||||
|
||||
stored = {}
|
||||
for slug in slugs:
|
||||
body = hand[slug]
|
||||
body = SHOT.sub("{{SHOT}}", body, count=1)
|
||||
body = CONTROLS.sub("{{CONTROLS}}", body, count=1)
|
||||
stored[slug] = body
|
||||
|
||||
# CSS rules and scripts the hand edits added. Compared against the builder's
|
||||
# OWN constants, not its output — the output already carries the merge.
|
||||
|
||||
extra_css = "\n".join(
|
||||
ln for ln in STYLE.search(src).group(1).splitlines()
|
||||
if ln.strip() and ln not in B.CSS)
|
||||
# Scripts already sitting INSIDE a stored section travel with it — collecting
|
||||
# them again would bind every listener twice (the +/- steppers would then
|
||||
# count by two). Only page-level scripts belong in EXTRA_JS.
|
||||
gen_js = {norm(B.JS)}
|
||||
in_section = "".join(stored.values())
|
||||
extra_js = [j for j in SCRIPT.findall(src)
|
||||
if norm(j) not in gen_js and j not in in_section]
|
||||
|
||||
parts = [
|
||||
'"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n'
|
||||
"GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n"
|
||||
"source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n"
|
||||
'{{CONTROLS}} so those stay generated.\n"""\n',
|
||||
"SECTIONS = {",
|
||||
]
|
||||
for slug, body in stored.items():
|
||||
parts.append(f" {slug!r}: {body!r},")
|
||||
parts.append("}\n")
|
||||
parts.append(f"EXTRA_CSS = {extra_css!r}\n")
|
||||
parts.append("EXTRA_JS = [")
|
||||
for j in extra_js:
|
||||
parts.append(f" {j!r},")
|
||||
parts.append("]\n")
|
||||
OUT.write_text("\n".join(parts), encoding="utf-8")
|
||||
|
||||
print(f"section viet tay : {len(stored)}")
|
||||
for slug, body in stored.items():
|
||||
print(f" {slug:32} {len(body):>7,} ky tu"
|
||||
f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}")
|
||||
print(f"CSS them : {len(extra_css.splitlines())} dong")
|
||||
print(f"script them : {len(extra_js)}")
|
||||
print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Populate a CoworkLocal config dir with realistic demo data, so the audit
|
||||
screenshots show a working app instead of empty lists.
|
||||
|
||||
MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a
|
||||
sandbox — every store below resolves its path from ``CONFIG_DIR``, which is
|
||||
``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this.
|
||||
|
||||
Where the app exposes a write API we call it (projects, history, tasks, skills,
|
||||
workflows, agents). Two stores are written as raw files on purpose:
|
||||
|
||||
* **usage** and **audit** — their ``record()`` helpers always stamp
|
||||
``datetime.now()``, so they cannot backdate. A one-day spike makes a useless
|
||||
chart, so the day files are written directly.
|
||||
* **co4e/run_history.json** — the manager only persists from a Qt signal
|
||||
handler; there is no public save.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
rnd = random.Random(20260808) # fixed seed → identical screenshots every run
|
||||
|
||||
PROJECTS = [
|
||||
("Trạm sạc EV — Cổng vận hành",
|
||||
"Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.",
|
||||
"Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n"
|
||||
"Luôn viết test trước khi sửa logic thanh toán."),
|
||||
("Báo cáo tài chính Q3",
|
||||
"Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.",
|
||||
"Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n"
|
||||
"Mọi con số phải truy được về file nguồn."),
|
||||
("Cổng tra cứu tài liệu ISO",
|
||||
"Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.",
|
||||
"Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."),
|
||||
]
|
||||
|
||||
CONVERSATIONS = [
|
||||
(0, "Dựng API trạng thái trạm sạc", True, [
|
||||
("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."),
|
||||
("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n"
|
||||
"Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n"
|
||||
"Mình tạo `app/api/stations.py` và test đi kèm nhé?"),
|
||||
("user", "Ừ làm đi."),
|
||||
("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n"
|
||||
"- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n"
|
||||
"Chạy `pytest tests/test_stations.py` → 4 passed."),
|
||||
]),
|
||||
(0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [
|
||||
("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."),
|
||||
("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, "
|
||||
"`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n"
|
||||
"Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"),
|
||||
]),
|
||||
(1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [
|
||||
("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."),
|
||||
("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác "
|
||||
"(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n"
|
||||
"Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."),
|
||||
]),
|
||||
(1, "Dựng slide trình bày kết quả Q3", False, [
|
||||
("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."),
|
||||
("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → "
|
||||
"top 5 tăng trưởng → rủi ro → kế hoạch Q4."),
|
||||
]),
|
||||
(2, "Đánh chỉ mục kho tài liệu ISO", False, [
|
||||
("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."),
|
||||
("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. "
|
||||
"Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."),
|
||||
]),
|
||||
(0, "Rà soát bảo mật trước khi lên production", False, [
|
||||
("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."),
|
||||
("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n"
|
||||
"2. Endpoint `/admin/export` không kiểm tra quyền\n"
|
||||
"3. CORS đang để `allow_origins=[\"*\"]`"),
|
||||
]),
|
||||
]
|
||||
|
||||
TASKS = [
|
||||
# (title, status, priority, ai, enabled, offset_days, last_status)
|
||||
("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"),
|
||||
("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"),
|
||||
("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"),
|
||||
("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"),
|
||||
("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"),
|
||||
("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None),
|
||||
("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"),
|
||||
("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None),
|
||||
("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None),
|
||||
("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"),
|
||||
]
|
||||
|
||||
SKILLS = [
|
||||
("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.",
|
||||
"Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n"
|
||||
"2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n"
|
||||
"4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."),
|
||||
("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.",
|
||||
"Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n"
|
||||
"Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."),
|
||||
("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.",
|
||||
"Trước khi sửa logic, viết test mô tả hành vi đúng.\n"
|
||||
"Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."),
|
||||
("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.",
|
||||
"Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n"
|
||||
"Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."),
|
||||
("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.",
|
||||
"Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n"
|
||||
"Slide cuối luôn là hành động tiếp theo."),
|
||||
]
|
||||
|
||||
CO4E_AGENTS = [
|
||||
("Phân tích yêu cầu", "ANALYST", "search",
|
||||
"Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.",
|
||||
["Rà soát bảo mật"]),
|
||||
("Thiết kế giải pháp", "ARCHITECT", "flow",
|
||||
"Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []),
|
||||
("Lập trình viên", "CODER", "code",
|
||||
"Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]),
|
||||
("Kiểm thử", "TESTER", "shield",
|
||||
"Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]),
|
||||
("Soạn tài liệu", "WRITER", "book",
|
||||
"Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]),
|
||||
]
|
||||
|
||||
WORKFLOWS = [
|
||||
("Quy trình phát triển tính năng",
|
||||
["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]),
|
||||
("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]),
|
||||
("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]),
|
||||
]
|
||||
|
||||
AUDIT_EVENTS = [
|
||||
("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"),
|
||||
("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"),
|
||||
("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"),
|
||||
("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"),
|
||||
("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"),
|
||||
("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"),
|
||||
("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"),
|
||||
("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"),
|
||||
("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"),
|
||||
("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"),
|
||||
("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"),
|
||||
("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"),
|
||||
("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"),
|
||||
("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"),
|
||||
("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"),
|
||||
]
|
||||
|
||||
MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")]
|
||||
LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu",
|
||||
"Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"]
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def seed(days: int = 45) -> dict:
|
||||
"""Fill the (sandboxed) config dir. Returns a per-store count summary."""
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
home = str(Path.home())
|
||||
assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME"
|
||||
assert "cowork-capture-" in home or "cowork-seed-" in home, (
|
||||
f"HOME ({home}) does not look like a capture sandbox — refusing to seed")
|
||||
|
||||
from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks
|
||||
|
||||
out: dict[str, int] = {}
|
||||
now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0)
|
||||
|
||||
# ---- projects ---------------------------------------------------------
|
||||
made = []
|
||||
for name, desc, instr in PROJECTS:
|
||||
p = projects.new_project(name, description=desc, instructions=instr)
|
||||
p.workspace_dir().mkdir(parents=True, exist_ok=True)
|
||||
# a few files so the Folder tab's tree isn't bare
|
||||
for rel in ("README.md", "src/main.py", "src/billing/session.py",
|
||||
"tests/test_stations.py", "docs/ghi-chu.md"):
|
||||
f = p.workspace_dir() / rel
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not f.exists():
|
||||
f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8")
|
||||
made.append(p)
|
||||
out["projects"] = len(made)
|
||||
|
||||
# ---- conversations ----------------------------------------------------
|
||||
hist_root = CONFIG_DIR / "history"
|
||||
hist_root.mkdir(parents=True, exist_ok=True)
|
||||
n_conv = 0
|
||||
for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS):
|
||||
proj = made[pi]
|
||||
created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3))
|
||||
sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}"
|
||||
payload = [{"role": r, "content": c} for r, c in msgs]
|
||||
for directory in (hist_root, proj.workspace_dir() / ".cowork_history"):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = history.save_conversation(
|
||||
directory, "cowork", sid, payload, title=title,
|
||||
created=created, project_id=proj.project_id)
|
||||
if pinned:
|
||||
history.set_pinned(path, True)
|
||||
# stagger mtime so the sidebar's newest-first order looks real
|
||||
ts = (now - timedelta(days=i * 2 + 1)).timestamp()
|
||||
os.utime(path, (ts, ts))
|
||||
n_conv += 1
|
||||
out["conversations"] = n_conv
|
||||
|
||||
# ---- scheduled tasks --------------------------------------------------
|
||||
for title, status, prio, ai, enabled, off, last in TASKS:
|
||||
t = tasks.new_task(
|
||||
title=title, status=status, priority=prio, is_ai_generated=ai,
|
||||
project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b",
|
||||
description=f"Tác vụ tự động: {title.lower()}.",
|
||||
schedule={"enabled": enabled,
|
||||
"run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"),
|
||||
"repeat_type": "daily" if enabled else "none"},
|
||||
logs={"last_status": last or "", "last_run_id": "run-demo" if last else "",
|
||||
"last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""},
|
||||
)
|
||||
if last:
|
||||
t["runs"] = [{"run_id": f"r{n}", "status": last,
|
||||
"finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"),
|
||||
"error": "Chứng chỉ hết hạn" if last == "failed" else None}
|
||||
for n in range(1, 4)]
|
||||
tasks.save_task(t)
|
||||
out["tasks"] = len(TASKS)
|
||||
|
||||
# ---- skills -----------------------------------------------------------
|
||||
for name, desc, instr in SKILLS:
|
||||
skills.save_skill(skills.Skill(name=name, description=desc,
|
||||
instructions=instr, enabled=True))
|
||||
out["skills"] = len(SKILLS)
|
||||
|
||||
# ---- Co4E agents ------------------------------------------------------
|
||||
for name, role, icon, instr, sk in CO4E_AGENTS:
|
||||
a = co4e.new_custom_agent(name)
|
||||
a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk
|
||||
a.model = "qwen2.5-coder:7b"
|
||||
co4e.save_custom_agent(a)
|
||||
out["co4e_agents"] = len(CO4E_AGENTS)
|
||||
|
||||
# ---- Co4E workflows ---------------------------------------------------
|
||||
wfs = []
|
||||
for name, steps in WORKFLOWS:
|
||||
wf = co4e.new_workflow(name)
|
||||
prev = None
|
||||
for j, label in enumerate(steps):
|
||||
node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120,
|
||||
data=co4e.Step(label=label, role="AGENT",
|
||||
instructions=f"{label}: thực hiện phần việc của mình "
|
||||
f"rồi chuyển kết quả cho bước sau.",
|
||||
model="qwen2.5-coder:7b"))
|
||||
wf.nodes.append(node)
|
||||
if prev:
|
||||
wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id),
|
||||
source=prev, target=node.id))
|
||||
prev = node.id
|
||||
co4e.save_workflow(wf)
|
||||
wfs.append(wf)
|
||||
out["workflows"] = len(wfs)
|
||||
|
||||
# ---- Co4E run history (no public save — written directly) -------------
|
||||
runs = []
|
||||
specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2),
|
||||
("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)]
|
||||
for k, (status, done, total, ago) in enumerate(specs, 1):
|
||||
wf = wfs[k % len(wfs)]
|
||||
runs.append({
|
||||
"id": f"run{k}", "wf_id": wf.id, "name": wf.name,
|
||||
"total": total, "done": done, "status": status,
|
||||
"plan_mode": False, "manual": False, "created_by": "local",
|
||||
"created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"),
|
||||
"error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "",
|
||||
"node_status": {n.id: ("done" if i < done else
|
||||
("error" if status == "error" and i == done else "idle"))
|
||||
for i, n in enumerate(wf.nodes)},
|
||||
"wf": co4e.workflow_to_dict(wf),
|
||||
"out_dir": str(made[0].workspace_dir()),
|
||||
"project_id": made[0].project_id,
|
||||
})
|
||||
hp = CONFIG_DIR / "co4e" / "run_history.json"
|
||||
hp.parent.mkdir(parents=True, exist_ok=True)
|
||||
hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
out["co4e_runs"] = len(runs)
|
||||
|
||||
# ---- usage day files (record() cannot backdate) -----------------------
|
||||
usage_dir = CONFIG_DIR / "usage"
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
n_usage = 0
|
||||
for d in range(days):
|
||||
day = now - timedelta(days=days - 1 - d)
|
||||
# a workday rhythm: quiet weekends, a gentle upward trend
|
||||
weekend = day.weekday() >= 5
|
||||
turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12
|
||||
lines = []
|
||||
for _ in range(turns):
|
||||
prov, model = rnd.choice(MODELS)
|
||||
lines.append(json.dumps({
|
||||
"ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))),
|
||||
"source": rnd.choice(["cowork", "cowork", "task", "co4e"]),
|
||||
"label": rnd.choice(LABELS), "provider": prov, "model": model,
|
||||
"in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200),
|
||||
"cache": rnd.randint(0, 4200), "estimated": False,
|
||||
"account": "local", "machine": "DESKTOP-DEMO",
|
||||
}, ensure_ascii=False))
|
||||
n_usage += 1
|
||||
(usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
out["usage_events"] = n_usage
|
||||
|
||||
# ---- audit day files (drives Security / MCP / Action tables) ----------
|
||||
audit_dir = CONFIG_DIR / "audit"
|
||||
audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
n_audit = 0
|
||||
roles = ["cowork", "code", "schedule", "graphrag", "security"]
|
||||
for d in range(14):
|
||||
day = now - timedelta(days=13 - d)
|
||||
lines = []
|
||||
for _ in range(rnd.randint(4, 9)):
|
||||
kind, name, ok, detail = rnd.choice(AUDIT_EVENTS)
|
||||
lines.append(json.dumps({
|
||||
"ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))),
|
||||
"kind": kind, "agent_role": rnd.choice(roles), "name": name,
|
||||
"ok": ok, "detail": detail,
|
||||
"account": "local", "role": "admin", "machine": "DESKTOP-DEMO",
|
||||
}, ensure_ascii=False))
|
||||
n_audit += 1
|
||||
(audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
out["audit_events"] = n_audit
|
||||
|
||||
# ---- admin agents -----------------------------------------------------
|
||||
admin_dir = admin_agents.agents_admin_dir("")
|
||||
admin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"),
|
||||
("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"),
|
||||
("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"),
|
||||
("Kiểm tra lệnh nguy hiểm", "security")]:
|
||||
a = admin_agents.new_agent(name, task_kind=kind, provider="ollama",
|
||||
model="qwen2.5-coder:7b", updated_by="local",
|
||||
prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.")
|
||||
admin_agents.save_agent(a, admin_dir)
|
||||
out["admin_agents"] = 7
|
||||
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.")
|
||||
Reference in New Issue
Block a user