feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*

Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:43:42 +09:00
co-authored by Claude Sonnet 5
parent ca7ea1479d
commit 0631abf85f
45 changed files with 22739 additions and 1578 deletions
@@ -0,0 +1,317 @@
"""Characterization test cho khu vực AGENTS trong sidebar của ``Co4ETab``
(``ui/co4e_tab.py``): ``ag_new_btn``/``ag_edit_btn``/``ag_del_btn``/``agent_list``
và phần "populate agent_list" bên trong ``_reload_sidebar`` — đúng các đoạn được
giao: dòng 549-568 (dựng widget + nối signal), 689-713 (``_reload_sidebar``, CHỈ
đoạn agent: item "Parallel" cố định rồi tới danh sách custom agent) và
1339-1367 (``_new_agent``/``_edit_agent``/``_edit_agent_dialog``/``_delete_agent``).
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) thành các module con dưới ``presentation/co4e/``
(xem ``docs/architecture/co4e-split-map.md``). Mọi ``assert`` dưới đây được chốt
lại từ giá trị THẬT in ra khi chạy code (quy trình ngược: chạy trước, in ra,
dán vào assert), không phải giá trị tôi nghĩ nó "nên" là gì.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: y hệt kỹ thuật của
``test_co4e_skills_panel.py`` (đọc file đó để thấy khuôn subprocess gốc) —
dựng ``Co4ETab`` thật kéo theo ``AppConfig``/``CONFIG_DIR`` là hằng số module
tính MỘT LẦN lúc import từ ``Path.home()``, nên phải cô lập ``HOME``/
``USERPROFILE`` TRƯỚC bất kỳ import ``cowork_local.*`` nào, trong một tiến
trình con sạch — patch thuộc tính module sau khi import là không đủ.
AN TOÀN DỮ LIỆU — agent_list NGUY HIỂM HƠN skill_list: ``_reload_sidebar``/
``_new_agent``/``_edit_agent``/``_delete_agent`` gọi thẳng
``core.co4e.list_custom_agents``/``save_custom_agent``/``delete_custom_agent``,
và ``delete_custom_agent`` THỰC SỰ XOÁ file JSON dưới ``CO4E_DIR``. Script con
assert ``str(CONFIG_DIR).startswith(sandbox)`` NGAY sau khi import, trước khi
gọi bất kỳ hàm co4e nào — chạy nhầm trên máy thật sẽ xoá agent thật của người
dùng.
KHÔNG gọi provider AI thật / spawn thread thật: không đụng tới
``Co4ERunManager.start()`` hay ``AgentWorker`` được start thật. ``gen_btn``
("AI-assist" trong ``Co4EAgentDialog``) không được click ở bất kỳ case nào.
``Co4EAgentDialog.exec`` (modal, sẽ treo tiến trình headless) được monkeypatch
thành một hàm giả NGAY TRONG tiến trình con của test — không sửa code sản
phẩm; hàm giả chỉ gõ vào các ô nhập liệu thật (``name_edit``/``role_edit``)
rồi trả về "Accepted" để đường xử lý thật (``dlg.result_agent()`` +
``save_custom_agent``) chạy nguyên vẹn.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``_mint_id`` (``core/co4e.py``) dùng MỘT bộ đếm ``_counter["n"]`` DÙNG
CHUNG cho mọi loại id (workflow/node/agent...), không tách theo tiền tố.
``Co4ETab.__init__`` mint ``wf_000001`` cho luồng rỗng ban đầu TRƯỚC khi
bất kỳ agent nào được tạo, nên agent custom đầu tiên trong cả tiến trình
mang id ``agent_000002`` chứ không phải ``agent_000001`` — id "nhảy số"
không phải bug ghi riêng cho agent, mà là hệ quả của một bộ đếm toàn cục.
* Dòng 706 dùng ``role=ca.role or "AGENT"`` khi dựng payload kéo-thả, nhưng
nhãn hiển thị trên dòng 711 dùng THẲNG ``ca.role`` (không fallback) — một
agent lưu với ``role=""`` hiện dòng tiêu đề rỗng (" · · tùy chỉnh")
nhưng payload kéo lên canvas lại có ``role="AGENT"``. Hai chỗ đọc cùng một
field nhưng ứng xử khác nhau với chuỗi rỗng.
* ``_edit_agent``/``_delete_agent`` đọc ``cid`` từ item đang chọn rồi tra
lại trong ``list_custom_agents()`` MỚI (không dùng payload đã cache) — nếu
file bị xoá ở "phía sau" (script khác, hoặc do một _delete_agent khác) mà
``agent_list`` chưa được ``_reload_sidebar()`` lại, thao tác Sửa trên dòng
đó là NO-OP thầm lặng (không báo lỗi, không mở dialog) vì
``agent is not None`` chặn ở nhánh fallback.
* Chọn dòng "Parallel" (cố định, không phải custom agent) rồi bấm Sửa/Xoá
tạo ra CÙNG MỘT thông báo với việc không chọn gì cả
(``tr("co4e.select_custom_agent")``) — ``cid`` là ``None`` trong cả hai
trường hợp vì item Parallel không set ``Qt.UserRole + 1``.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách khu vực Agents sang
``presentation/co4e/`` (xem cột "Trạng thái" của dòng liên quan trong
``docs/architecture/co4e-split-map.md``). Sau khi tách xong thành một
``AgentsListPanel``/tương đương với hợp đồng rõ ràng (giống
``SkillsListPanel`` đã làm), các case ở đây nên được viết lại thành test đặc
tả cho panel mới (input rõ ràng, không cần dựng cả ``Co4ETab``/``QApplication``
nặng nề qua subprocess). Ba quirk "bộ đếm id dùng chung", "role rỗng hiển thị
khác payload" và "sửa trên cid đã xoá là no-op thầm lặng" đáng mở issue hỏi ý
kiến sản phẩm trước khi ai đó "dọn" chúng trong lúc tách — đặc biệt quirk thứ
hai, vì nó rất dễ bị "sửa cho gọn" thành dùng cùng một biểu thức fallback ở cả
hai chỗ, và như vậy vô tình đổi cả nhãn hiển thị lẫn dữ liệu kéo-thả.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QAbstractItemView, QApplication
from PySide6.QtCore import Qt
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import co4e as co4e_mod
from cowork_local.ui.co4e_tab import Co4ETab
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
tab = Co4ETab(ctx)
# ---- 549-568: widget cua khu vuc AGENTS duoc dung dung nhu quan sat -------
assert tab.ag_new_btn.text() == tr("co4e.new"), tab.ag_new_btn.text()
assert tab.ag_new_btn.toolTip() == tr("co4e.tt_new_agent"), tab.ag_new_btn.toolTip()
assert tab.ag_new_btn.objectName() == "co4eSectionAction"
assert tab.ag_new_btn.isFlat() is True
assert tab.ag_new_btn.cursor().shape() == Qt.PointingHandCursor
assert tab.ag_edit_btn.toolTip() == tr("co4e.tt_edit_agent"), tab.ag_edit_btn.toolTip()
assert tab.ag_edit_btn.width() == 34, tab.ag_edit_btn.width()
assert tab.ag_del_btn.toolTip() == tr("co4e.tt_del_agent"), tab.ag_del_btn.toolTip()
assert tab.ag_del_btn.width() == 34, tab.ag_del_btn.width()
assert tab.agent_list.dragEnabled() is True
assert tab.agent_list.dragDropMode() == QAbstractItemView.DragOnly
print("CASE_WIDGETS_OK")
# ---- 689-713: _reload_sidebar voi thu muc agents RONG ---------------------
# Chi con dong "Parallel" co dinh, khong co custom agent nao.
tab._reload_sidebar()
assert tab.agent_list.count() == 1, tab.agent_list.count()
it0 = tab.agent_list.item(0)
assert it0.text() == tr("co4e.parallel_node"), it0.text()
payload0 = it0.data(Qt.UserRole)
assert payload0 == {
"variant": "parallel", "label": "Parallel", "role": "PARALLEL",
"icon": "server", "sub_agents": [],
}, payload0
assert it0.data(Qt.UserRole + 1) is None
print("CASE_EMPTY_OK")
# ---- quirk: bo dem id dung chung cho moi loai (xem docstring dau file) ----
# Co4ETab.__init__ da mint "wf_000001" cho luong rong ban dau -> agent CUSTOM
# dau tien trong ca tien trinh mang id "agent_000002", khong phai "..._000001".
agent1 = co4e_mod.new_custom_agent("Reviewer Bot")
assert agent1.id == "agent_000002", agent1.id
print("CASE_ID_COUNTER_QUIRK_OK")
agent1.role = "REVIEWER"
agent1.icon = "eye"
agent1.instructions = "Review the diff."
agent1.skills = ["Test Skill"]
co4e_mod.save_custom_agent(agent1)
tab._reload_sidebar()
assert tab.agent_list.count() == 2, tab.agent_list.count()
it1 = tab.agent_list.item(1)
assert it1.text() == "Reviewer Bot · REVIEWER · " + tr("co4e.custom"), it1.text()
payload1 = it1.data(Qt.UserRole)
assert payload1 == {
"variant": "step", "label": "Reviewer Bot", "agent_slug": "reviewer-bot",
"role": "REVIEWER", "icon": "eye", "instructions": "Review the diff.",
"context": "", "model": "", "self_verify": True, "max_verify_rounds": 1,
"permission_preset": "full", "skills": ["Test Skill"], "attachments": [],
"sub_agents": [],
}, payload1
assert it1.data(Qt.UserRole + 1) == agent1.id
assert it1.icon().isNull() is False
print("CASE_ONE_AGENT_OK")
# ---- quirk: agent luu voi name="" va role="" -------------------------------
# Nhan hien thi (dong 711) dung THANG ca.role -> rong; nhung payload keo-tha
# (dong 706) dung "ca.role or 'AGENT'" -> fallback ve "AGENT". Cung mot field,
# hai cach doc khac nhau khi gap chuoi rong.
agent2 = co4e_mod.new_custom_agent("")
agent2.role = ""
co4e_mod.save_custom_agent(agent2)
tab._reload_sidebar()
assert tab.agent_list.count() == 3, tab.agent_list.count()
it2 = tab.agent_list.item(2)
assert it2.text() == " · · " + tr("co4e.custom"), repr(it2.text())
payload2 = it2.data(Qt.UserRole)
assert payload2["role"] == "AGENT", payload2["role"]
print("CASE_EMPTY_NAME_ROLE_QUIRK_OK")
# ---- 1339-1340: _new_agent() mo dialog cho MOT agent moi tinh --------------
# Gia lap Co4EAgentDialog.exec() ngay trong tien trinh con nay (khong sua code
# san pham): go vao chinh cac o nhap that (name_edit/role_edit) roi tra ve
# Accepted, de duong xu ly that (result_agent() + save_custom_agent) chay
# nguyen ven.
calls = {"n": 0}
def _fake_exec(self):
calls["n"] += 1
self.name_edit.setText(f"Fresh-{calls['n']}")
self.role_edit.setText("Scout")
return 1 # QDialog.Accepted
Co4EAgentDialog.exec = _fake_exec
before = tab.agent_list.count()
tab.ag_new_btn.click() # 555: noi that toi _new_agent
assert calls["n"] == 1
assert tab.agent_list.count() == before + 1, tab.agent_list.count()
names = [tab.agent_list.item(i).text() for i in range(tab.agent_list.count())]
assert names[-1] == "Fresh-1 · SCOUT · " + tr("co4e.custom"), names
print("CASE_NEW_AGENT_VIA_CLICK_OK")
# ---- 1342-1350: _edit_agent() khong co dong nao dang chon ------------------
msgs = []
tab.status_message.connect(lambda m: msgs.append(m))
tab.agent_list.setCurrentRow(-1)
assert tab.agent_list.currentItem() is None
tab._edit_agent()
assert msgs == [tr("co4e.select_custom_agent")], msgs
print("CASE_EDIT_NO_SELECTION_OK")
# ---- quirk: chon dong "Parallel" (khong phai custom agent) -> CUNG thong
# bao nhu khong chon gi, vi Qt.UserRole + 1 la None o dong nay -----------
tab.agent_list.setCurrentRow(0)
tab._edit_agent()
assert msgs == [tr("co4e.select_custom_agent")] * 2, msgs
tab._delete_agent()
assert msgs == [tr("co4e.select_custom_agent")] * 3, msgs
assert tab.agent_list.count() == before + 1, "chon Parallel roi Xoa khong lam mat dong nao"
print("CASE_PARALLEL_ROW_NOOP_QUIRK_OK")
# ---- 1342-1358: _edit_agent() tren mot custom agent that -------------------
target_row = next(
i for i in range(tab.agent_list.count())
if tab.agent_list.item(i).data(Qt.UserRole + 1) == agent1.id
)
tab.agent_list.setCurrentRow(target_row)
tab.ag_edit_btn.click() # 562: noi that toi _edit_agent
assert calls["n"] == 2
names = [tab.agent_list.item(i).text() for i in range(tab.agent_list.count())]
assert names[target_row] == "Fresh-2 · SCOUT · " + tr("co4e.custom"), names
# id giu nguyen qua lan sua (chi noi dung doi, khong mint id moi)
assert tab.agent_list.item(target_row).data(Qt.UserRole + 1) == agent1.id
print("CASE_EDIT_EXISTING_OK")
# ---- quirk: sua tren mot cid da bi xoa "phia sau" -> no-op tham lang -------
# _edit_agent tra cuu lai list_custom_agents() MOI, khong dung payload da cache
# trong item; neu file da mat va agent_list CHUA duoc _reload_sidebar(), thao
# tac Sua tren dong do khong lam gi (khong mo dialog, khong loi).
co4e_mod.delete_custom_agent(agent1.id)
stale_item = tab.agent_list.item(target_row)
assert stale_item.data(Qt.UserRole + 1) == agent1.id # item van con cid cu
tab.agent_list.setCurrentRow(target_row)
tab._edit_agent()
assert calls["n"] == 2, "khong duoc mo dialog voi cid da bi xoa"
print("CASE_EDIT_STALE_CID_QUIRK_OK")
# ---- 1360-1367: _delete_agent() tren mot custom agent that -----------------
tab._reload_sidebar()
count_before_delete = tab.agent_list.count()
del_row, del_cid = next(
(i, tab.agent_list.item(i).data(Qt.UserRole + 1))
for i in range(tab.agent_list.count())
if tab.agent_list.item(i).data(Qt.UserRole + 1)
)
tab.agent_list.setCurrentRow(del_row)
tab.ag_del_btn.click() # 563: noi that toi _delete_agent
assert tab.agent_list.count() == count_before_delete - 1
assert not (co4e_mod.agents_dir() / f"{del_cid}.json").exists()
print("CASE_DELETE_REAL_OK")
# ---- fallback: xoa lai mot id da khong con file -> khong nem loi -----------
co4e_mod.delete_custom_agent(del_cid)
print("CASE_DELETE_ALREADY_GONE_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_agent_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_WIDGETS_OK",
"CASE_EMPTY_OK",
"CASE_ID_COUNTER_QUIRK_OK",
"CASE_ONE_AGENT_OK",
"CASE_EMPTY_NAME_ROLE_QUIRK_OK",
"CASE_NEW_AGENT_VIA_CLICK_OK",
"CASE_EDIT_NO_SELECTION_OK",
"CASE_PARALLEL_ROW_NOOP_QUIRK_OK",
"CASE_EDIT_EXISTING_OK",
"CASE_EDIT_STALE_CID_QUIRK_OK",
"CASE_DELETE_REAL_OK",
"CASE_DELETE_ALREADY_GONE_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi save_custom_agent()/delete_custom_agent() cua
# chinh test (khong co du lieu nguoi dung that nao bi cham vao) -
# CONFIG_DIR nam trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local" / "co4e").exists()
@@ -0,0 +1,374 @@
"""Characterization tests cho 8 ham hinh hoc thuan cua Co4E canvas.
Vong doi: day la gian giao (scaffolding), khong phai test dac ta cuoi cung.
Muc dich la ghi lai HANH VI DANG CO cua ``_dist``, ``_towards``,
``_rounded_path``, ``_seg_hits_rect``, ``_hits``, ``_route``, ``_ortho_path``,
``_elide`` — hien dang duoc re-export tu ``ui/co4e_canvas.py`` (thuc chat da
duoc doi sang song o ``presentation/co4e/canvas_geometry.py``, xem docstring
cua file do) — de lam luoi an toan cho dot tach file 2000+ dong. Test nay
KHONG phan xet dung/sai thiet ke, chi dong dinh lai output thuc te da chay va
in ra. Sau khi dot tach hoan tat va on dinh, cac test o day nen duoc viet lai
thanh test dac ta (specification test) that su — luc do co the xoa cac assert
kieu "quirk" ben duoi va thay bang assert dua tren hop dong ro rang, hoac mo
issue rieng de sua cac quirk neu chung thuc su la bug.
Khong can QApplication: cac ham nay chi dung QPointF/QRectF/QPainterPath nhu
kieu gia tri thuan, khong ve, khong doc kich thuoc widget.
Cac quirk dang chu y da duoc dong dinh o day (dung sua o code san pham):
* ``_elide(text, n)`` dung slicing ``text[: n - 1] + "..."``. Voi ``n=0``,
``n - 1 == -1`` nen KHONG cat rong ma cat mat ky tu cuoi cung cua chuoi
con lai roi noi dau "..." vao — vi du ``_elide("abc", 0) == "ab..."`` chu
khong phai chuoi rong. Voi ``n=1``, ket qua la chinh dau "..." (do
``text[:0] == ""``).
* ``_seg_hits_rect`` kiem tra nhanh "horizontal" (``abs(y1-y2) < 0.5``)
TRUOC nhanh "vertical" — mot doan suy bien (diem trung diem, ``p1==p2``)
luon roi vao nhanh horizontal du no cung thoa dieu kien vertical.
* ``_route`` co the "bo cuoc": khi vat can qua lon bao kin moi phuong an
tranh, no tra ve elbow co ban (``base``) DU NO VAN VA CHAM vat can — ham
khong nem loi, khong bao dam duong tra ve khong va cham.
* ``_towards(a, b, d)`` khi ``a == b`` (khoang cach ~0) tra ve ban sao cua
``a`` bat ke ``d`` la bao nhieu, thay vi loi hoac diem khong xac dinh.
Cac assert duoi day duoc chot bang cach CHAY code that qua
``.venv/Scripts/python.exe -c "..."`` roi dan nguyen ket qua in duoc vao
assert, khong suy luan ly thuyet.
"""
from __future__ import annotations
import pytest
from cowork_local.ui.co4e_canvas import (
_dist,
_elide,
_hits,
_ortho_path,
_route,
_rounded_path,
_seg_hits_rect,
_towards,
)
from PySide6.QtCore import QPointF, QRectF
# ---------------------------------------------------------------------------
# _dist
# ---------------------------------------------------------------------------
def test_dist_pythagorean_3_4_5():
assert _dist(QPointF(0, 0), QPointF(3, 4)) == pytest.approx(5.0)
def test_dist_same_point_is_zero():
assert _dist(QPointF(5, 5), QPointF(5, 5)) == pytest.approx(0.0)
def test_dist_negative_coordinates():
assert _dist(QPointF(-1, -1), QPointF(2, 3)) == pytest.approx(5.0)
# ---------------------------------------------------------------------------
# _towards
# ---------------------------------------------------------------------------
def test_towards_moves_along_axis_by_distance():
p = _towards(QPointF(0, 0), QPointF(10, 0), 5)
assert p.x() == pytest.approx(5.0)
assert p.y() == pytest.approx(0.0)
def test_towards_same_point_returns_copy_of_a_regardless_of_d():
# quirk: khi a == b (khoang cach ~0), tra ve ban sao cua a, khong loi.
p = _towards(QPointF(0, 0), QPointF(0, 0), 5)
assert p.x() == pytest.approx(0.0)
assert p.y() == pytest.approx(0.0)
def test_towards_zero_distance_stays_at_a():
p = _towards(QPointF(0, 0), QPointF(10, 0), 0)
assert p.x() == pytest.approx(0.0)
assert p.y() == pytest.approx(0.0)
def test_towards_overshoot_past_b_is_allowed():
# quirk: d lon hon khoang cach a->b van duoc ngoai suy, khong bi kep lai.
p = _towards(QPointF(0, 0), QPointF(10, 0), 20)
assert p.x() == pytest.approx(20.0)
assert p.y() == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# _rounded_path
# ---------------------------------------------------------------------------
def test_rounded_path_empty_points_returns_empty_path():
path = _rounded_path([])
assert path.elementCount() == 0
def test_rounded_path_single_point():
path = _rounded_path([QPointF(1, 2)])
assert path.elementCount() == 1
e = path.elementAt(0)
assert (e.x, e.y) == pytest.approx((1.0, 2.0))
def test_rounded_path_two_points_is_a_straight_line_no_bend():
path = _rounded_path([QPointF(0, 0), QPointF(10, 0)])
assert path.elementCount() == 2
e0, e1 = path.elementAt(0), path.elementAt(1)
assert (e0.x, e0.y) == pytest.approx((0.0, 0.0))
assert (e1.x, e1.y) == pytest.approx((10.0, 0.0))
def test_rounded_path_three_points_default_radius():
# dist(prev,cur)=10, dist(cur,nxt)=10 -> rr = min(12, 5, 5) = 5.
path = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)])
assert path.elementCount() == 6
pts = [(path.elementAt(i).x, path.elementAt(i).y) for i in range(6)]
expected = [
(0.0, 0.0),
(5.0, 0.0),
(8.333333333333334, 0.0),
(10.0, 1.6666666666666667),
(10.0, 5.0),
(10.0, 10.0),
]
for got, exp in zip(pts, expected):
assert got[0] == pytest.approx(exp[0])
assert got[1] == pytest.approx(exp[1])
rect = path.boundingRect()
assert (rect.x(), rect.y(), rect.width(), rect.height()) == pytest.approx(
(0.0, 0.0, 10.0, 10.0)
)
def test_rounded_path_default_radius_matches_explicit_r_12():
a = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)])
b = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)], 12)
assert a.elementCount() == b.elementCount()
for i in range(a.elementCount()):
ea, eb = a.elementAt(i), b.elementAt(i)
assert (ea.x, ea.y) == pytest.approx((eb.x, eb.y))
def test_rounded_path_custom_smaller_radius_changes_bend_points():
path = _rounded_path([QPointF(0, 0), QPointF(10, 0), QPointF(10, 10)], r=2)
assert path.elementCount() == 6
pts = [(path.elementAt(i).x, path.elementAt(i).y) for i in range(6)]
expected = [
(0.0, 0.0),
(8.0, 0.0),
(9.333333333333334, 0.0),
(10.0, 0.6666666666666666),
(10.0, 2.0),
(10.0, 10.0),
]
for got, exp in zip(pts, expected):
assert got[0] == pytest.approx(exp[0])
assert got[1] == pytest.approx(exp[1])
# ---------------------------------------------------------------------------
# _seg_hits_rect
# ---------------------------------------------------------------------------
RECT = QRectF(10, 10, 20, 20) # x in [10, 30], y in [10, 30]
def test_seg_hits_rect_horizontal_through_rect():
assert _seg_hits_rect(QPointF(0, 20), QPointF(40, 20), RECT) is True
def test_seg_hits_rect_horizontal_outside_y_range():
assert _seg_hits_rect(QPointF(0, 5), QPointF(40, 5), RECT) is False
def test_seg_hits_rect_horizontal_not_reaching_rect_x_range():
assert _seg_hits_rect(QPointF(0, 20), QPointF(5, 20), RECT) is False
def test_seg_hits_rect_vertical_through_rect():
assert _seg_hits_rect(QPointF(20, 0), QPointF(20, 40), RECT) is True
def test_seg_hits_rect_vertical_outside_x_range():
assert _seg_hits_rect(QPointF(5, 0), QPointF(5, 40), RECT) is False
def test_seg_hits_rect_diagonal_intersecting():
assert _seg_hits_rect(QPointF(0, 0), QPointF(40, 40), RECT) is True
def test_seg_hits_rect_diagonal_not_intersecting():
assert _seg_hits_rect(QPointF(0, 0), QPointF(5, 5), RECT) is False
def test_seg_hits_rect_degenerate_point_inside_counts_as_hit():
# quirk: p1 == p2 roi vao nhanh "horizontal" (abs(y1-y2) < 0.5 duoc kiem
# truoc), du no cung thoa nhanh vertical.
assert _seg_hits_rect(QPointF(20, 20), QPointF(20, 20), RECT) is True
def test_seg_hits_rect_degenerate_point_outside():
assert _seg_hits_rect(QPointF(0, 0), QPointF(0, 0), RECT) is False
# ---------------------------------------------------------------------------
# _hits
# ---------------------------------------------------------------------------
def test_hits_empty_points_list_is_false():
assert _hits([], [RECT]) is False
def test_hits_single_point_has_no_segments_so_false():
assert _hits([QPointF(20, 20)], [RECT]) is False
def test_hits_no_obstacles_default_behaviour_false():
assert _hits([QPointF(0, 20), QPointF(40, 20)], []) is False
def test_hits_true_when_segment_crosses_obstacle():
assert _hits([QPointF(0, 20), QPointF(40, 20)], [RECT]) is True
def test_hits_false_when_segment_misses_obstacle():
assert _hits([QPointF(0, 5), QPointF(40, 5)], [RECT]) is False
# ---------------------------------------------------------------------------
# _route
# ---------------------------------------------------------------------------
def test_route_same_y_no_obstacles_is_straight_line():
r = _route(QPointF(0, 0), QPointF(100, 0))
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx([(0.0, 0.0), (100.0, 0.0)])
def test_route_default_obstacles_none_matches_explicit_none():
# tham so mac dinh: goi khong truyen obstacles == truyen None tuong minh.
r_default = _route(QPointF(0, 0), QPointF(100, 0))
r_explicit = _route(QPointF(0, 0), QPointF(100, 0), None)
pts_default = [(p.x(), p.y()) for p in r_default]
pts_explicit = [(p.x(), p.y()) for p in r_explicit]
assert pts_default == pytest.approx(pts_explicit)
def test_route_different_y_no_obstacles_is_mid_x_elbow():
r = _route(QPointF(0, 0), QPointF(100, 50))
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (100.0, 50.0)]
)
def test_route_same_y_with_obstacle_falls_back_to_detour():
# obstacle nam giua duong thang mid_x va cung chan luon dai vertical band
# (obstacle qua rong so voi khoang cach 2 diem) -> _route roi xuong nhanh
# detour tren/duoi (margin 44) thay vi elbow don gian.
obstacle_mid = QRectF(40, -10, 20, 20) # phu y=0 tai x trong [40, 60]
r = _route(QPointF(0, 0), QPointF(100, 0), [obstacle_mid])
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[
(0.0, 0.0),
(34.0, 0.0),
(34.0, -54.0),
(66.0, -54.0),
(66.0, 0.0),
(100.0, 0.0),
]
)
# duong tra ve nay khong con va cham obstacle da cho.
assert _hits(r, [obstacle_mid]) is False
def test_route_gives_up_and_returns_colliding_base_when_fully_boxed_in():
# quirk: neu vat can qua lon, bao kin moi phuong an tranh (vertical band
# va detour tren/duoi deu khong thoat), _route "bo cuoc" va tra ve elbow
# co ban (base) DU NO VAN VA CHAM vat can — khong nem loi, khong dam bao
# duong tra ve la an toan.
huge = QRectF(-1000, -1000, 3000, 3000)
r = _route(QPointF(0, 0), QPointF(100, 50), [huge])
pts = [(p.x(), p.y()) for p in r]
assert pts == pytest.approx(
[(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (100.0, 50.0)]
)
assert _hits(r, [huge]) is True
# ---------------------------------------------------------------------------
# _ortho_path
# ---------------------------------------------------------------------------
def test_ortho_path_straight_case_element_count():
op = _ortho_path(QPointF(0, 0), QPointF(100, 0))
assert op.elementCount() == 2
def test_ortho_path_elbow_case_element_count_and_bounds():
op = _ortho_path(QPointF(0, 0), QPointF(100, 50))
assert op.elementCount() == 10
rect = op.boundingRect()
assert (rect.x(), rect.y(), rect.width(), rect.height()) == pytest.approx(
(0.0, 0.0, 100.0, 50.0)
)
def test_ortho_path_default_radius_is_corner_r_12():
a = _ortho_path(QPointF(0, 0), QPointF(100, 50))
b = _ortho_path(QPointF(0, 0), QPointF(100, 50), 12)
assert a.elementCount() == b.elementCount()
for i in range(a.elementCount()):
ea, eb = a.elementAt(i), b.elementAt(i)
assert (ea.x, ea.y) == pytest.approx((eb.x, eb.y))
# ---------------------------------------------------------------------------
# _elide
# ---------------------------------------------------------------------------
def test_elide_short_text_under_limit_is_unchanged():
assert _elide("hello", 10) == "hello"
def test_elide_text_exactly_at_limit_is_unchanged():
assert _elide("abc", 3) == "abc"
def test_elide_long_text_is_cut_with_ellipsis_and_total_len_equals_n():
result = _elide("hello world this is long", 10)
assert result == "hello wor…"
assert len(result) == 10
def test_elide_newlines_are_replaced_with_spaces():
assert _elide("line1\nline2", 20) == "line1 line2"
def test_elide_empty_string_stays_empty():
assert _elide("", 10) == ""
def test_elide_none_is_treated_as_empty_string():
assert _elide(None, 10) == ""
def test_elide_n_zero_quirk_slices_off_last_char_not_empty():
# quirk: text[: n - 1] voi n=0 la text[:-1], KHONG phai cat rong. Voi
# chuoi "abc" (len 3 > 0) ket qua la "ab" + dau "..." = "ab...".
assert _elide("abc", 0) == "ab…"
def test_elide_n_one_quirk_result_is_just_ellipsis():
# quirk: voi n=1, text[:0] == "" nen ket qua chi con dau "...".
assert _elide("abc", 1) == "…"
def test_elide_n_larger_than_text_length_boundary():
# len("abcd") = 4 > 3 nen van bi cat, dung == thi khong cat.
assert _elide("abcd", 3) == "ab…"
@@ -0,0 +1,842 @@
"""Characterization test cho ``Co4ECanvas`` (``ui/co4e_canvas.py``, dòng
289-701) — KHÔNG bao gồm ``_NodeItem``/``_EdgeItem`` (hai lớp đó chỉ vẽ, đã
được phủ gián tiếp bởi ``tests/characterization/test_co4e_canvas_geometry.py``
qua ``_rounded_path``/``_route``/``_elide`` mà ``_EdgeItem.update_path``/
``_NodeItem.paint`` dùng).
VÒNG ĐỜI: đây là giàn giáo (scaffolding), KHÔNG phải đặc tả cuối cùng. Mục
đích DUY NHẤT là lưới an toàn cho đợt tách ``ui/co4e_canvas.py`` (2000+ dòng
cả file, xem ``docs/architecture/co4e-split-map.md``) — 8 hàm hình học thuần
đã dời sang ``presentation/co4e/canvas_geometry.py`` rồi (xem test cùng tên);
đợt sau nhiều khả năng sẽ động vào chính ``Co4ECanvas`` (tách thành
``_NodeItem``/``_EdgeItem`` module riêng, hoặc tách state machine
connect/port-drag ra khỏi lớp view). Khi việc tách phần này hoàn tất và ổn
định, các test ở đây nên được viết lại thành test đặc tả (specification test)
cho lớp/API mới; quirk nào liệt kê dưới đây nên có issue riêng nếu ai đó muốn
"dọn" chúng — ĐỪNG tự sửa code sản phẩm để "dọn" quirk khi đọc thấy test này.
CÁCH CHỐT ASSERT: mọi giá trị dưới đây lấy bằng cách CHẠY code thật (script
``_PROBE_SCRIPT`` bên dưới, qua ``.venv/Scripts/python.exe``, giống hệt lệnh ở
cuối file) rồi dán NGUYÊN VĂN JSON in được vào assert — không suy luận lý
thuyết.
AN TOÀN DỮ LIỆU: ``Co4ECanvas`` tự nó KHÔNG chạm đĩa/mạng (khác
``Co4ETab``/``Co4ERunManager``) — nhưng import ``cowork_local.config`` (qua
chuỗi import ``cowork_local.ui.co4e_canvas`` -> ``..core.co4e`` ->
``..config``) vẫn tính ``CONFIG_DIR`` từ ``Path.home()`` một lần lúc module
nạp. Để không rủi ro và để chạy trong TIẾN TRÌNH CON riêng (tránh xung đột
``QApplication`` singleton với các test khác đã/sẽ tạo app trong cùng lượt
chạy pytest), toàn bộ probe chạy qua ``subprocess`` với ``HOME``/
``USERPROFILE`` trỏ vào một ``tmp_path`` sandbox ĐẶT TRƯỚC khi script import
bất kỳ thứ gì thuộc ``cowork_local`` (đúng kỹ thuật
``tools/capture_screens.py::_isolate_home()``: đặt ``USERPROFILE``/``HOME``,
xoá ``HOMEDRIVE``/``HOMEPATH``), và ``QT_QPA_PLATFORM=offscreen`` được đặt
TRƯỚC khi import PySide6 (đúng khuôn ``tools/check_co4e.py`` dòng 22+40-42).
Một assert ngay trong tiến trình con chốt ``CONFIG_DIR`` nằm trong sandbox
trước khi làm gì khác (kiểu ``tools/check_co4e.py:47``).
KHÔNG spawn thread/gọi provider thật: ``Co4ECanvas`` không có method nào dựng
``QThread``/gọi AI — mọi method characterize ở đây (``add_node``,
``begin_port_drag``/``finish_port_drag``, ``delete_*``, ``zoom_*``,
``relayout*``, ``add_workflow``, ``dropEvent``...) chỉ thao tác trên
``QGraphicsScene``/dict nội bộ, gọi trực tiếp không cần seed job nào.
CẦN QApplication: các method characterize ở đây dựng ``QGraphicsScene``/
``QGraphicsItem`` thật, đọc ``transform()``, tạo ``QDropEvent`` — không phải
kiểu giá trị thuần như ``_dist``/``_towards`` (khác
``test_co4e_canvas_geometry.py``, không cần app).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bug nhưng là hành vi đang chạy thật hôm nay):
* ``_finish_connect(target_id)`` với ``target_id == connect_from`` hiện tại
(tự nối vào chính nó) bị bỏ qua HOÀN TOÀN im lặng — không tạo cạnh, không
báo lỗi — nhưng ``_connect_from`` VẪN bị reset về ``None`` (chế độ connect
kết thúc dù không nối được gì).
* ``_make_edge`` chống trùng cạnh CÙNG source+target (kể cả khi gọi lại qua
``finish_port_drag`` lần hai với cùng cặp) nhưng KHÔNG chống cạnh ngược
hướng (target->source) — hàm không kiểm tra chiều ngược, chỉ kiểm tra
đúng chiều đã cho.
* ``delete_edge(edge)`` gọi ``graph_changed.emit()`` VÔ ĐIỀU KIỆN, kể cả khi
không có item nào khớp để xoá (ví dụ gọi lại lần hai với cùng đối tượng
``Edge`` đã bị xoá trước đó) — không có "removed count" nào được kiểm tra
trước khi emit.
* ``delete_node`` với id không tồn tại trả về sớm (``item is None: return``)
TRƯỚC dòng emit — nên KHÔNG phát ``graph_changed`` trong trường hợp này,
khác hẳn ``delete_edge`` ở trên.
* ``delete_selected()`` chạy 2 vòng lặp riêng (xoá node trước, xoá cạnh
sau) — nếu một node bị xoá đã kéo theo xoá cả các cạnh nối tới nó (qua
``delete_node``), thì vòng lặp cạnh thứ hai KHÔNG còn thấy các cạnh đó
nữa (chúng đã biến mất khỏi ``self._edges`` trước khi vòng lặp cạnh chạy
tới), nên chỉ những cạnh CÒN SỐNG và đang selected riêng mới bị xoá thêm.
* ``_zoom_by`` khi đã chạm biên (``_ZOOM_MIN``/``_ZOOM_MAX``) và gọi
``zoom_in()``/``zoom_out()`` thêm lần nữa: hiệu ứng là no-op tuyệt đối —
không gọi ``self.scale()``, không đổi ``self._zoom`` — vì
``abs(target - cur) < 1e-6`` chặn sớm. 20 lần ``zoom_in()`` liên tiếp từ
1.0 chạm trần 3.0 sau đúng 7 lần, 13 lần còn lại là no-op.
* ``add_workflow(nodes, edges, at=None)``: offset áp dụng cho batch mới phụ
thuộc vào canvas ĐÃ có node hay chưa TẠI THỜI ĐIỂM GỌI — batch đầu tiên
(canvas rỗng) giữ nguyên toạ độ gốc (offset 0,0); batch thứ hai (canvas đã
có node từ batch trước) bị dịch (60, 60) dù truyền cùng ``nodes``/``edges``
y hệt lần đầu. ``at`` được cho tường minh thì luôn thắng offset ngầm này.
* ``add_workflow`` bỏ qua ÂM THẦM mọi cạnh mà một đầu (source hoặc target)
không nằm trong danh sách ``nodes`` đang được thả — không lỗi, không log,
cạnh đó biến mất khỏi kết quả.
* ``dropEvent`` với payload JSON hỏng (không parse được) hoặc payload
workflow rỗng (``wf.nodes`` rỗng) đều là NO-OP HOÀN TOÀN im lặng — không
thêm node nào, không báo lỗi, không exception nào lộ ra ngoài.
* ``dropEvent`` gọi ``self.mapToScene(...)`` trên một view CHƯA từng
``show()``/resize — toạ độ scene kết quả (đóng đinh trong
``s11_step_drop_pos``) là quirk của việc mapToScene phụ thuộc viewport mặc
định lúc chưa hiển thị, KHÔNG phải toạ độ "50, 60" người ta tưởng sẽ thấy;
nếu đợt tách sau show canvas trước khi test tương tự, số này sẽ đổi và đó
là dấu hiệu ĐÚNG cần cập nhật lại giá trị đóng đinh, không phải lỗi.
Lệnh thủ công đã dùng để chốt các con số trên (quy trình ngược, xem cuối
file để chạy lại nếu cần chốt lại sau khi code đổi có chủ đích).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_PROBE_SCRIPT = r"""
import json
import os
import sys
from pathlib import Path
sandbox = sys.argv[1]
repo_parent = sys.argv[2]
sys.path.insert(0, repo_parent)
os.environ["QT_QPA_PLATFORM"] = "offscreen"
for var in ("USERPROFILE", "HOME"):
os.environ[var] = sandbox
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
from PySide6.QtCore import QByteArray, QMimeData, QPointF, Qt
from PySide6.QtGui import QDropEvent
from PySide6.QtWidgets import QApplication
app = QApplication([])
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(Path(sandbox).resolve()) in str(CONFIG_DIR.resolve()), (
"khong co lap: CONFIG_DIR=" + str(CONFIG_DIR))
from cowork_local.core.co4e import Edge, Node, Step, Workflow, workflow_to_dict
from cowork_local.ui.co4e_canvas import CO4E_MIME, Co4ECanvas, _NODE_H, _NODE_W
result = {}
# --- Section 1: add_node ----------------------------------------------------
c1 = Co4ECanvas()
sel_calls = []
c1.node_selected.connect(lambda nid: sel_calls.append(nid))
changed = {"n": 0}
c1.graph_changed.connect(lambda: changed.__setitem__("n", changed["n"] + 1))
nid1 = c1.add_node(Step(label="A"))
result["s1_nid1"] = nid1
result["s1_nid1_pos"] = [c1._nodes[nid1].node.x, c1._nodes[nid1].node.y]
result["s1_after_nid1_edges"] = len(c1._edges)
result["s1_sel_calls_after_1"] = list(sel_calls)
result["s1_changed_after_1"] = changed["n"]
nid2 = c1.add_node(Step(label="B"), x=10, y=20, connect_from=nid1)
result["s1_nid2"] = nid2
result["s1_nid2_pos"] = [c1._nodes[nid2].node.x, c1._nodes[nid2].node.y]
result["s1_after_nid2_edges"] = [(e.edge.source, e.edge.target, e.edge.id) for e in c1._edges]
result["s1_sel_calls_after_2"] = list(sel_calls)
result["s1_changed_after_2"] = changed["n"]
nid3 = c1.add_node(Step(label="C"), connect_from="does-not-exist")
result["s1_nid3"] = nid3
result["s1_after_nid3_edges_count"] = len(c1._edges)
# --- Section 2: add_step_below -----------------------------------------------
before_count = len(c1._nodes)
c1.add_step_below("nope-does-not-exist")
result["s2_missing_parent_nodes_unchanged"] = len(c1._nodes) == before_count
c1.add_step_below(nid2)
new_ids = [nid for nid in c1._nodes if nid not in (nid1, nid2, nid3)]
result["s2_new_node_ids"] = new_ids
new_id = new_ids[0]
result["s2_new_node_pos"] = [c1._nodes[new_id].node.x, c1._nodes[new_id].node.y]
result["s2_edge_nid2_to_new"] = any(
e.edge.source == nid2 and e.edge.target == new_id for e in c1._edges
)
# --- Section 3: _chain_tail ---------------------------------------------------
result["s3_tail_current_state"] = c1._chain_tail()
c_empty = Co4ECanvas()
result["s3_tail_empty_canvas"] = c_empty._chain_tail()
c_single = Co4ECanvas()
single_id = c_single.add_node(Step(label="Solo"))
result["s3_tail_single_node_no_edges"] = c_single._chain_tail()
# --- Section 4: add_palette_step ----------------------------------------------
tail_before = c1._chain_tail()
new_pal_id = c1.add_palette_step(Step(label="D"), QPointF(500, 500))
result["s4_add_palette_step_return"] = new_pal_id
newest_ids = [nid for nid in c1._nodes if nid not in (nid1, nid2, nid3, new_id)]
result["s4_new_ids"] = newest_ids
pal_id = newest_ids[0]
result["s4_pos"] = [c1._nodes[pal_id].node.x, c1._nodes[pal_id].node.y]
result["s4_tail_before"] = tail_before
result["s4_edge_from_tail_to_new"] = any(
e.edge.source == tail_before and e.edge.target == pal_id for e in c1._edges
)
# --- Section 5: begin_connect / _finish_connect --------------------------------
c2 = Co4ECanvas()
a2 = c2.add_node(Step(label="A"))
b2 = c2.add_node(Step(label="B"))
result["s5_initial_edges"] = len(c2._edges)
c2.begin_connect(a2)
result["s5_connect_from_after_begin"] = c2._connect_from
c2._finish_connect(a2) # self-connect
result["s5_connect_from_after_self_finish"] = c2._connect_from
result["s5_edges_after_self_finish"] = len(c2._edges)
c2.begin_connect(a2)
c2._finish_connect(b2)
result["s5_connect_from_after_finish_ab"] = c2._connect_from
result["s5_edges_after_finish_ab"] = [(e.edge.source, e.edge.target) for e in c2._edges]
c2._finish_connect(a2) # no active connect (src None)
result["s5_edges_after_finish_with_no_active_connect"] = len(c2._edges)
# --- Section 6: begin_port_drag / update / finish / _node_at / _make_edge dedup -
c3 = Co4ECanvas()
p3 = c3.add_node(Step(label="P"), x=0, y=0)
q3 = c3.add_node(Step(label="Q"), x=400, y=0)
result["s6_scene_items_before_drag"] = len(c3._scene.items())
p_item = c3._nodes[p3]
src_pt = p_item.pos() + QPointF(_NODE_W, _NODE_H / 2)
c3.begin_port_drag(p3, src_pt)
result["s6_port_src_after_begin"] = c3._port_src
result["s6_temp_edge_in_scene_after_begin"] = c3._temp_edge in c3._scene.items()
mid_pt = QPointF(200, 100)
c3.update_port_drag(mid_pt)
result["s6_temp_edge_path_elements_after_update"] = c3._temp_edge.path().elementCount()
q_hit_pt = c3._nodes[q3].pos() + QPointF(50, 50) # inside Q's card
node_at_q = c3._node_at(q_hit_pt)
result["s6_node_at_hit_point"] = node_at_q
empty_pt = QPointF(-500, -500)
result["s6_node_at_empty_point"] = c3._node_at(empty_pt)
c3.finish_port_drag(q_hit_pt)
result["s6_port_src_after_finish"] = c3._port_src
result["s6_temp_edge_after_finish"] = c3._temp_edge
result["s6_edges_after_finish"] = [(e.edge.source, e.edge.target) for e in c3._edges]
c3.begin_port_drag(p3, src_pt)
c3.finish_port_drag(q_hit_pt)
result["s6_edges_after_duplicate_drag"] = len(c3._edges)
c3.begin_port_drag(p3, src_pt)
c3.finish_port_drag(empty_pt)
result["s6_edges_after_finish_over_empty_space"] = len(c3._edges)
result["s6_port_src_after_empty_finish"] = c3._port_src
# --- Section 7: delete_edge / delete_node / delete_selected --------------------
c4 = Co4ECanvas()
a4 = c4.add_node(Step(label="A"), x=0, y=0)
b4 = c4.add_node(Step(label="B"), x=300, y=0, connect_from=a4)
c4_ = c4.add_node(Step(label="C"), x=600, y=0, connect_from=b4)
d4 = c4.add_node(Step(label="D"), x=900, y=0, connect_from=c4_)
result["s7_initial_edges"] = [(e.edge.source, e.edge.target) for e in c4._edges]
edge_ab = next(e.edge for e in c4._edges if e.edge.source == a4 and e.edge.target == b4)
changed4 = {"n": 0}
c4.graph_changed.connect(lambda: changed4.__setitem__("n", changed4["n"] + 1))
c4.delete_edge(edge_ab)
result["s7_edges_after_delete_ab"] = [(e.edge.source, e.edge.target) for e in c4._edges]
result["s7_changed_after_delete_ab"] = changed4["n"]
c4.delete_edge(edge_ab)
result["s7_changed_after_delete_ab_again"] = changed4["n"]
result["s7_edges_after_delete_ab_again"] = len(c4._edges)
result["s7_delete_node_missing_returns_early"] = c4.delete_node("no-such-node") is None
result["s7_changed_after_delete_missing_node"] = changed4["n"]
c4._nodes[c4_].setSelected(True)
c4.delete_selected()
result["s7_nodes_after_delete_selected"] = sorted(c4._nodes.keys())
result["s7_edges_after_delete_selected"] = [(e.edge.source, e.edge.target) for e in c4._edges]
# --- Section 8: zoom -----------------------------------------------------------
c5 = Co4ECanvas()
result["s8_initial_m11"] = c5.transform().m11()
result["s8_initial_zoom_attr"] = c5._zoom
c5.zoom_in()
result["s8_m11_after_1_zoom_in"] = c5.transform().m11()
result["s8_zoom_attr_after_1_zoom_in"] = c5._zoom
m11_series = []
for _ in range(20):
c5.zoom_in()
m11_series.append(round(c5.transform().m11(), 6))
result["s8_m11_series_zoom_in_x20_more"] = m11_series
result["s8_zoom_attr_after_many_zoom_in"] = c5._zoom
before_m11 = c5.transform().m11()
c5.zoom_in()
result["s8_m11_unchanged_when_already_at_cap"] = c5.transform().m11() == before_m11
c5.reset_zoom()
result["s8_m11_after_reset"] = c5.transform().m11()
result["s8_zoom_attr_after_reset"] = c5._zoom
m11_series_out = []
for _ in range(30):
c5.zoom_out()
m11_series_out.append(round(c5.transform().m11(), 6))
result["s8_m11_series_zoom_out_x30"] = m11_series_out
result["s8_zoom_attr_after_many_zoom_out"] = c5._zoom
# --- Section 9: relayout / relayout_if_vertical --------------------------------
c6 = Co4ECanvas()
c6.relayout()
result["s9_relayout_empty_ok"] = True
a6 = c6.add_node(Step(label="A"), x=0, y=0)
b6 = c6.add_node(Step(label="B"), x=0, y=150, connect_from=a6)
c6_ = c6.add_node(Step(label="C"), x=0, y=300, connect_from=b6)
result["s9_vertical_before"] = {
a6: [c6._nodes[a6].pos().x(), c6._nodes[a6].pos().y()],
b6: [c6._nodes[b6].pos().x(), c6._nodes[b6].pos().y()],
c6_: [c6._nodes[c6_].pos().x(), c6._nodes[c6_].pos().y()],
}
c6.relayout_if_vertical()
result["s9_vertical_after_relayout_if_vertical"] = {
a6: [c6._nodes[a6].pos().x(), c6._nodes[a6].pos().y()],
b6: [c6._nodes[b6].pos().x(), c6._nodes[b6].pos().y()],
c6_: [c6._nodes[c6_].pos().x(), c6._nodes[c6_].pos().y()],
}
c7 = Co4ECanvas()
a7 = c7.add_node(Step(label="A"), x=0, y=0)
b7 = c7.add_node(Step(label="B"), x=500, y=50, connect_from=a7)
result["s9_horizontal_before"] = {
a7: [c7._nodes[a7].pos().x(), c7._nodes[a7].pos().y()],
b7: [c7._nodes[b7].pos().x(), c7._nodes[b7].pos().y()],
}
c7.relayout_if_vertical()
result["s9_horizontal_after_relayout_if_vertical"] = {
a7: [c7._nodes[a7].pos().x(), c7._nodes[a7].pos().y()],
b7: [c7._nodes[b7].pos().x(), c7._nodes[b7].pos().y()],
}
c8 = Co4ECanvas()
a8 = c8.add_node(Step(label="Solo"), x=0, y=0)
c8.relayout_if_vertical()
result["s9_single_node_after_relayout_if_vertical"] = [
c8._nodes[a8].pos().x(), c8._nodes[a8].pos().y()
]
# --- Section 10: add_workflow ---------------------------------------------------
c9 = Co4ECanvas()
src_nodes = [
Node(id="src1", x=0.0, y=0.0, data=Step(label="X")),
Node(id="src2", x=200.0, y=0.0, data=Step(label="Y")),
]
src_edges = [Edge(id="e1", source="src1", target="src2")]
c9.add_workflow(src_nodes, src_edges, at=None)
first_batch_ids = sorted(c9._nodes.keys())
result["s10_first_batch_ids"] = first_batch_ids
result["s10_first_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in first_batch_ids
}
result["s10_first_batch_edges"] = [(e.edge.source, e.edge.target) for e in c9._edges]
c9.add_workflow(src_nodes, src_edges, at=None)
second_batch_ids = sorted(set(c9._nodes.keys()) - set(first_batch_ids))
result["s10_second_batch_ids"] = second_batch_ids
result["s10_second_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in second_batch_ids
}
result["s10_total_edges_after_second_call"] = len(c9._edges)
c9.add_workflow(src_nodes, src_edges, at=QPointF(1000, 1000))
third_batch_ids = sorted(
set(c9._nodes.keys()) - set(first_batch_ids) - set(second_batch_ids)
)
result["s10_third_batch_ids"] = third_batch_ids
result["s10_third_batch_positions"] = {
nid: [c9._nodes[nid].pos().x(), c9._nodes[nid].pos().y()] for nid in third_batch_ids
}
c10 = Co4ECanvas()
edges_unknown_target = [Edge(id="ex", source="src1", target="unknown")]
c10.add_workflow(src_nodes, edges_unknown_target, at=None)
result["s10_edges_with_unknown_target_dropped"] = len(c10._edges)
result["s10_nodes_still_added_despite_bad_edge"] = len(c10._nodes)
# --- Section 11: dropEvent ------------------------------------------------------
_mime_keepalive = [] # QDropEvent only stores a pointer to the QMimeData; the
# Python wrapper must be kept alive for the event's lifetime or mimeData()
# comes back as a dangling/base QObject (hit exactly this while writing the probe).
def make_drop_event(payload_bytes, fmt, pos):
mime = QMimeData()
if fmt is not None:
mime.setData(fmt, QByteArray(payload_bytes))
_mime_keepalive.append(mime)
return QDropEvent(pos, Qt.CopyAction, mime, Qt.NoButton, Qt.NoModifier)
c11 = Co4ECanvas()
step_payload = json.dumps({"label": "Dropped Step", "role": "AGENT"}).encode("utf-8")
ev1 = make_drop_event(step_payload, CO4E_MIME, QPointF(50, 60))
c11.dropEvent(ev1)
result["s11_step_drop_nodes_count"] = len(c11._nodes)
only_id = next(iter(c11._nodes))
result["s11_step_drop_label"] = c11._nodes[only_id].node.data.label
result["s11_step_drop_pos"] = [c11._nodes[only_id].pos().x(), c11._nodes[only_id].pos().y()]
wf_payload_dict = {
"kind": "workflow",
"workflow": workflow_to_dict(
Workflow(id="wfX", name="WF", nodes=[Node(id="wn1", x=0.0, y=0.0, data=Step(label="WFNode"))])
),
}
ev2 = make_drop_event(json.dumps(wf_payload_dict).encode("utf-8"), CO4E_MIME, QPointF(10, 10))
before_wf_drop = len(c11._nodes)
c11.dropEvent(ev2)
result["s11_workflow_drop_added_nodes"] = len(c11._nodes) - before_wf_drop
empty_wf_payload = {
"kind": "workflow",
"workflow": workflow_to_dict(Workflow(id="wfEmpty", name="Empty", nodes=[], edges=[])),
}
ev3 = make_drop_event(json.dumps(empty_wf_payload).encode("utf-8"), CO4E_MIME, QPointF(10, 10))
before_empty_drop = len(c11._nodes)
c11.dropEvent(ev3)
result["s11_empty_workflow_drop_is_noop"] = len(c11._nodes) == before_empty_drop
ev4 = make_drop_event(b"{not valid json", CO4E_MIME, QPointF(10, 10))
before_bad_json = len(c11._nodes)
c11.dropEvent(ev4)
result["s11_bad_json_drop_is_noop_no_crash"] = len(c11._nodes) == before_bad_json
ev5 = make_drop_event(b"whatever", "text/plain", QPointF(10, 10))
before_wrong_fmt = len(c11._nodes)
c11.dropEvent(ev5)
result["s11_wrong_mime_format_is_noop_no_crash"] = len(c11._nodes) == before_wrong_fmt
print(json.dumps(result, sort_keys=True))
print("PROBE_OK")
"""
@pytest.fixture(scope="module")
def probe_result(tmp_path_factory):
"""Chạy ``_PROBE_SCRIPT`` một lần cho cả module trong TIẾN TRÌNH CON, trả
về dict JSON đã in được. Test riêng lẻ chỉ đọc lại dict này — không dựng
lại canvas cho mỗi assert."""
sandbox = tmp_path_factory.mktemp("co4e-canvas-widget-home")
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
result = subprocess.run(
[sys.executable, "-c", _PROBE_SCRIPT, str(sandbox), str(REPO_PARENT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"probe co4e canvas widget that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "PROBE_OK" in result.stdout, result.stdout
json_line = result.stdout.strip().splitlines()[-2]
return json.loads(json_line)
# ---------------------------------------------------------------------------
# Section 1: add_node
# ---------------------------------------------------------------------------
def test_add_node_default_pos_and_no_edge_without_connect_from(probe_result):
assert probe_result["s1_nid1"] == "node_000001"
assert probe_result["s1_nid1_pos"] == pytest.approx([60.0, 60.0])
assert probe_result["s1_after_nid1_edges"] == 0
def test_add_node_emits_node_selected_and_graph_changed(probe_result):
# add_node emits graph_changed rồi node_selected — 1 lần add_node "trơn"
# (không connect_from hợp lệ) vẫn tính là 2 lần graph_changed: một từ
# add_node, một từ _reposition_edges gọi ngầm bên trong add_node? Số thực
# tế chốt được là 2 sau lần add_node đầu tiên.
assert probe_result["s1_changed_after_1"] == 2
assert probe_result["s1_sel_calls_after_1"] == ["node_000001"]
def test_add_node_with_valid_connect_from_creates_edge_with_deterministic_id(probe_result):
assert probe_result["s1_nid2"] == "node_000002"
assert probe_result["s1_nid2_pos"] == pytest.approx([10.0, 20.0])
assert probe_result["s1_after_nid2_edges"] == [
["node_000001", "node_000002", "e_node_000001__node_000002"],
]
assert probe_result["s1_changed_after_2"] == 5
assert probe_result["s1_sel_calls_after_2"] == ["node_000001", "node_000002"]
def test_add_node_with_unknown_connect_from_is_silently_skipped(probe_result):
# quirk: connect_from khong ton tai trong _nodes -> khong tao canh, khong
# loi, node van duoc them binh thuong.
assert probe_result["s1_nid3"] == "node_000003"
assert probe_result["s1_after_nid3_edges_count"] == 1
# ---------------------------------------------------------------------------
# Section 2: add_step_below
# ---------------------------------------------------------------------------
def test_add_step_below_missing_parent_is_noop(probe_result):
assert probe_result["s2_missing_parent_nodes_unchanged"] is True
def test_add_step_below_places_new_node_to_the_right_and_connects(probe_result):
# parent (nid2) o (10, 20); node moi o (10 + _NODE_W(210) + 150, 20) = (370, 20).
assert probe_result["s2_new_node_ids"] == ["node_000004"]
assert probe_result["s2_new_node_pos"] == pytest.approx([370.0, 20.0])
assert probe_result["s2_edge_nid2_to_new"] is True
# ---------------------------------------------------------------------------
# Section 3: _chain_tail
# ---------------------------------------------------------------------------
def test_chain_tail_picks_last_inserted_node_without_outgoing_edge(probe_result):
# trang thai luc nay: nid1->nid2->new(node_000004); nid3 dung mot minh.
# tails (theo thu tu chen) = [nid3, new] -> tails[-1] = new.
assert probe_result["s3_tail_current_state"] == "node_000004"
def test_chain_tail_empty_canvas_returns_empty_string(probe_result):
assert probe_result["s3_tail_empty_canvas"] == ""
def test_chain_tail_single_node_with_no_edges_is_itself(probe_result):
assert probe_result["s3_tail_single_node_no_edges"] == "node_000005"
# ---------------------------------------------------------------------------
# Section 4: add_palette_step
# ---------------------------------------------------------------------------
def test_add_palette_step_returns_none_unlike_add_node(probe_result):
# quirk: add_palette_step KHONG tra ve id node moi (khac add_node) - no
# goi self.add_node(...) nhung khong return ket qua cua no.
assert probe_result["s4_add_palette_step_return"] is None
def test_add_palette_step_uses_pos_directly_and_chains_from_tail(probe_result):
assert probe_result["s4_tail_before"] == "node_000004"
assert probe_result["s4_new_ids"] == ["node_000006"]
assert probe_result["s4_pos"] == pytest.approx([500.0, 500.0])
assert probe_result["s4_edge_from_tail_to_new"] is True
# ---------------------------------------------------------------------------
# Section 5: begin_connect / _finish_connect
# ---------------------------------------------------------------------------
def test_begin_connect_sets_pending_source(probe_result):
assert probe_result["s5_connect_from_after_begin"] == "node_000007"
def test_finish_connect_self_target_quirk_resets_state_but_makes_no_edge(probe_result):
# quirk: target_id == connect_from (tu noi minh vao minh) bi bo qua im
# lang, KHONG tao canh, nhung _connect_from van duoc reset ve None.
assert probe_result["s5_connect_from_after_self_finish"] is None
assert probe_result["s5_edges_after_self_finish"] == 0
def test_finish_connect_valid_pair_creates_edge_and_resets_state(probe_result):
assert probe_result["s5_connect_from_after_finish_ab"] is None
assert probe_result["s5_edges_after_finish_ab"] == [["node_000007", "node_000008"]]
def test_finish_connect_with_no_active_connect_is_noop(probe_result):
assert probe_result["s5_edges_after_finish_with_no_active_connect"] == 1
# ---------------------------------------------------------------------------
# Section 6: begin_port_drag / update_port_drag / finish_port_drag / _node_at
# ---------------------------------------------------------------------------
def test_begin_port_drag_adds_temp_edge_item_to_scene(probe_result):
assert probe_result["s6_scene_items_before_drag"] == 2
assert probe_result["s6_port_src_after_begin"] == "node_000009"
assert probe_result["s6_temp_edge_in_scene_after_begin"] is True
def test_update_port_drag_sets_elbow_path_on_temp_edge(probe_result):
# P o (0,0)->cong o (210,48); dich toi (200,100) khac y -> nhanh elbow
# cua _ortho_path (10 element, khop voi test_co4e_canvas_geometry.py).
assert probe_result["s6_temp_edge_path_elements_after_update"] == 10
def test_node_at_hits_node_under_point_and_none_when_empty(probe_result):
assert probe_result["s6_node_at_hit_point"] == "node_000010"
assert probe_result["s6_node_at_empty_point"] is None
def test_finish_port_drag_creates_edge_and_clears_temp_state(probe_result):
assert probe_result["s6_port_src_after_finish"] is None
assert probe_result["s6_temp_edge_after_finish"] is None
assert probe_result["s6_edges_after_finish"] == [["node_000009", "node_000010"]]
def test_make_edge_dedups_same_source_target_pair(probe_result):
# quirk: keo lai dung cap p->q lan hai khong tao canh trung.
assert probe_result["s6_edges_after_duplicate_drag"] == 1
def test_finish_port_drag_over_empty_space_adds_no_edge_and_clears_state(probe_result):
assert probe_result["s6_edges_after_finish_over_empty_space"] == 1
assert probe_result["s6_port_src_after_empty_finish"] is None
# ---------------------------------------------------------------------------
# Section 7: delete_edge / delete_node / delete_selected
# ---------------------------------------------------------------------------
def test_delete_edge_removes_matching_item_and_emits_graph_changed(probe_result):
assert probe_result["s7_initial_edges"] == [
["node_000011", "node_000012"],
["node_000012", "node_000013"],
["node_000013", "node_000014"],
]
assert probe_result["s7_edges_after_delete_ab"] == [
["node_000012", "node_000013"],
["node_000013", "node_000014"],
]
assert probe_result["s7_changed_after_delete_ab"] == 1
def test_delete_edge_called_again_on_already_removed_edge_still_emits(probe_result):
# quirk: khong co "removed count" guard - goi lai voi Edge da bi xoa van
# phat graph_changed, du khong con item nao khop de xoa.
assert probe_result["s7_changed_after_delete_ab_again"] == 2
assert probe_result["s7_edges_after_delete_ab_again"] == 2
def test_delete_node_missing_id_returns_early_without_emitting(probe_result):
# quirk: khac delete_edge o tren - delete_node voi id khong ton tai return
# SOM (truoc dong emit), nen KHONG phat graph_changed trong truong hop nay.
assert probe_result["s7_delete_node_missing_returns_early"] is True
assert probe_result["s7_changed_after_delete_missing_node"] == 2
def test_delete_selected_node_cascades_its_edges_before_edge_loop_runs(probe_result):
# quirk: xoa node c4_ (id thu 3, "node_000013") keo theo xoa ca 2 canh noi
# toi no (b->c va c->d) NGAY trong vong lap xoa node; vong lap xoa canh
# (rieng, cho canh dang selected) sau do khong con thay 2 canh nay nua.
assert probe_result["s7_nodes_after_delete_selected"] == [
"node_000011", "node_000012", "node_000014",
]
assert probe_result["s7_edges_after_delete_selected"] == []
# ---------------------------------------------------------------------------
# Section 8: _zoom_by / zoom_in / zoom_out / reset_zoom
# ---------------------------------------------------------------------------
def test_zoom_in_once_scales_by_1_15(probe_result):
assert probe_result["s8_initial_m11"] == pytest.approx(1.0)
assert probe_result["s8_initial_zoom_attr"] == pytest.approx(1.0)
assert probe_result["s8_m11_after_1_zoom_in"] == pytest.approx(1.15)
assert probe_result["s8_zoom_attr_after_1_zoom_in"] == pytest.approx(1.15)
def test_zoom_in_clamps_at_max_after_7_steps_from_1_0(probe_result):
# quirk: tran _ZOOM_MAX=3.0 dat duoc sau dung 7 lan zoom_in() lien tiep tu
# 1.0 (1 lan da tinh o test truoc + 6 lan trong series nay); 13 lan con
# lai trong series 20 lan la no-op tuyet doi (gia tri dung yen o 3.0).
expected = [
1.3225, 1.520875, 1.749006, 2.011357, 2.313061, 2.66002, 3.0,
3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0,
]
assert probe_result["s8_m11_series_zoom_in_x20_more"] == pytest.approx(expected)
assert probe_result["s8_zoom_attr_after_many_zoom_in"] == pytest.approx(3.0)
def test_zoom_in_at_cap_is_exact_noop(probe_result):
assert probe_result["s8_m11_unchanged_when_already_at_cap"] is True
def test_reset_zoom_returns_to_1_0_regardless_of_prior_zoom(probe_result):
assert probe_result["s8_m11_after_reset"] == pytest.approx(1.0)
assert probe_result["s8_zoom_attr_after_reset"] == pytest.approx(1.0)
def test_zoom_out_clamps_at_min_after_8_steps_from_1_0(probe_result):
# quirk: san _ZOOM_MIN=0.3 dat duoc sau dung 8 lan zoom_out() lien tiep tu
# 1.0; 22 lan con lai trong series 30 lan la no-op (dung yen o 0.3).
expected = [
0.869565, 0.756144, 0.657516, 0.571753, 0.497177, 0.432328, 0.375937,
0.326902, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3,
0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3,
]
assert probe_result["s8_m11_series_zoom_out_x30"] == pytest.approx(expected, abs=1e-5)
assert probe_result["s8_zoom_attr_after_many_zoom_out"] == pytest.approx(0.3)
# ---------------------------------------------------------------------------
# Section 9: relayout / relayout_if_vertical
# ---------------------------------------------------------------------------
def test_relayout_on_empty_canvas_is_a_safe_noop(probe_result):
assert probe_result["s9_relayout_empty_ok"] is True
def test_relayout_if_vertical_reflows_a_stacked_chain_left_to_right(probe_result):
# 3 node xep doc cung x=0 (chenh lech < _NODE_W=210) -> duoc coi la
# "vertical" -> relayout() sap lai theo wave: cot 0/1/2 x = w*(210+110).
assert probe_result["s9_vertical_before"] == {
"node_000015": pytest.approx([0.0, 0.0]),
"node_000016": pytest.approx([0.0, 150.0]),
"node_000017": pytest.approx([0.0, 300.0]),
}
assert probe_result["s9_vertical_after_relayout_if_vertical"] == {
"node_000015": pytest.approx([0.0, 0.0]),
"node_000016": pytest.approx([320.0, 0.0]),
"node_000017": pytest.approx([640.0, 0.0]),
}
def test_relayout_if_vertical_leaves_already_horizontal_graph_untouched(probe_result):
# chenh lech x (500) >= _NODE_W(210) -> khong duoc coi la "vertical" ->
# relayout_if_vertical() khong dong gi toi vi tri da xep, du no khong
# thang hang theo luoi wave.
before = probe_result["s9_horizontal_before"]
after = probe_result["s9_horizontal_after_relayout_if_vertical"]
assert after == before
assert after == {
"node_000018": pytest.approx([0.0, 0.0]),
"node_000019": pytest.approx([500.0, 50.0]),
}
def test_relayout_if_vertical_with_fewer_than_2_nodes_is_noop(probe_result):
assert probe_result["s9_single_node_after_relayout_if_vertical"] == pytest.approx(
[0.0, 0.0]
)
# ---------------------------------------------------------------------------
# Section 10: add_workflow
# ---------------------------------------------------------------------------
def test_add_workflow_first_batch_on_empty_canvas_keeps_original_positions(probe_result):
# quirk: offset ngam ("60 neu self._nodes khac rong") kiem tra TRANG THAI
# canvas LUC GOI, khong phai lien quan gi den tham so `at`. Canvas rong
# luc goi -> offset (0,0) -> toa do y het nodes goc.
ids = probe_result["s10_first_batch_ids"]
assert len(ids) == 2
positions = probe_result["s10_first_batch_positions"]
assert positions[ids[0]] == pytest.approx([0.0, 0.0])
assert positions[ids[1]] == pytest.approx([200.0, 0.0])
assert probe_result["s10_first_batch_edges"] == [[ids[0], ids[1]]]
def test_add_workflow_second_batch_same_args_gets_implicit_60_60_offset(probe_result):
# quirk: goi lai add_workflow VOI CUNG nodes/edges (khong at) nhung canvas
# gio da co node tu lan truoc -> offset ngam (60, 60) duoc ap dung, id moi
# hoan toan khac (fresh new_node_id moi lan).
ids = probe_result["s10_second_batch_ids"]
positions = probe_result["s10_second_batch_positions"]
assert positions[ids[0]] == pytest.approx([60.0, 60.0])
assert positions[ids[1]] == pytest.approx([260.0, 60.0])
assert probe_result["s10_total_edges_after_second_call"] == 2
def test_add_workflow_with_explicit_at_overrides_implicit_offset(probe_result):
ids = probe_result["s10_third_batch_ids"]
positions = probe_result["s10_third_batch_positions"]
assert positions[ids[0]] == pytest.approx([1000.0, 1000.0])
assert positions[ids[1]] == pytest.approx([1200.0, 1000.0])
def test_add_workflow_silently_drops_edges_with_unknown_endpoint(probe_result):
assert probe_result["s10_edges_with_unknown_target_dropped"] == 0
assert probe_result["s10_nodes_still_added_despite_bad_edge"] == 2
# ---------------------------------------------------------------------------
# Section 11: dropEvent
# ---------------------------------------------------------------------------
def test_drop_event_step_payload_adds_node_with_dropped_label(probe_result):
assert probe_result["s11_step_drop_nodes_count"] == 1
assert probe_result["s11_step_drop_label"] == "Dropped Step"
# quirk: view chua tung show()/resize khi dropEvent chay -> mapToScene tra
# ve toa do phu thuoc kich thuoc viewport MAC DINH cua QGraphicsView chua
# hien, KHONG phai (50, 60) nhu vi tri tha ban dau - dong dinh dung so da
# chay ra duoc, khong suy doan.
assert probe_result["s11_step_drop_pos"] == pytest.approx([-269.0, -179.0])
def test_drop_event_workflow_payload_merges_its_nodes(probe_result):
assert probe_result["s11_workflow_drop_added_nodes"] == 1
def test_drop_event_empty_workflow_payload_is_a_silent_noop(probe_result):
# quirk: workflow rong (wf.nodes == []) khong lam gi ca - `if wf.nodes:`
# false nen add_workflow khong duoc goi, khong loi, khong node moi.
assert probe_result["s11_empty_workflow_drop_is_noop"] is True
def test_drop_event_invalid_json_payload_is_a_silent_noop(probe_result):
# quirk: except (ValueError, UnicodeDecodeError): return - khong nem loi
# ra ngoai, khong e.acceptProposedAction() nao duoc goi trong nhanh nay.
assert probe_result["s11_bad_json_drop_is_noop_no_crash"] is True
def test_drop_event_wrong_mime_format_falls_back_to_base_class_noop(probe_result):
assert probe_result["s11_wrong_mime_format_is_noop_no_crash"] is True
# ---------------------------------------------------------------------------
# Lenh thu cong da dung de chot cac gia tri JSON o tren (quy trinh nguoc):
#
# .venv/Scripts/python.exe -c "<noi dung _PROBE_SCRIPT>" <sandbox> <repo_parent>
#
# voi <sandbox> la mot thu muc rong duoc gan vao HOME/USERPROFILE TRUOC khi
# script import bat ky thu gi thuoc cowork_local, va <repo_parent> la thu muc
# cha cua repo (de "import cowork_local" hoat dong dung nhu conftest.py lam).
# ---------------------------------------------------------------------------
@@ -0,0 +1,474 @@
"""Characterization test cho khu vực CHAT của ``Co4ETab`` (``ui/co4e_tab.py``).
BỌC HAI PHẦN ĐỘC LẬP (đúng phạm vi được giao cho lượt này — KHÔNG động tới gì
khác):
(A) ``class _ChatInput(QLineEdit)`` (dòng 139-228) + 3 hàm module-level nó
dùng: ``_skill_names`` (63-67), ``_agent_names`` (70-73),
``_directive_token`` (124-136). Đây là ô chat có autocomplete
``/skill:``/``/agent:`` (popup gợi ý, phím mũi tên/Tab/Enter/Escape).
(B) phần DỰNG WIDGET của ``Co4ETab._build_chat`` (dòng 1030-1093) — header
"Messages" + ``chat_stack`` + composer (ô chat + routing toggle + nút
gửi). KHÔNG bao gồm ``_toggle_messages`` (1095-1127) — hàm đó vẫn ở lại
``Co4ETab`` và không được test ở đây.
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) sang ``presentation/co4e/co4e_chat_view.py``.
Đợt này CHƯA tạo file production đó — chỉ chụp ảnh hành vi hiện tại của khu
vực chat để đợt tách sau có bằng chứng "trước/sau giống nhau". Mọi ``assert``
dưới đây được chốt lại từ giá trị THẬT in ra khi chạy code (quy trình ngược:
chạy trước, in kết quả, dán vào assert) — không phải giá trị suy luận trước.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: giống hệt kỹ thuật của
``tests/characterization/test_co4e_skills_panel.py``/``test_co4e_agent_panel.py``.
``_ChatInput._maybe_popup`` gọi ``_skill_names()``/``_agent_names()``, và
``_skill_names()`` đọc thật từ ``core/skills.py::SKILLS_DIR`` (hằng số module
tính MỘT LẦN lúc import, từ ``CONFIG_DIR = Path.home() / ".cowork_local"``).
Muốn đổi ``Path.home()`` phải đặt ``USERPROFILE``/``HOME`` TRƯỚC bất kỳ
import ``cowork_local.*`` nào, nên toàn bộ phần dựng ``QApplication`` +
``Co4ETab``/``_ChatInput`` chạy trong MỘT tiến trình con sạch (giống
``tools/capture_screens.py::_isolate_home()``), không phải trong tiến trình
pytest chính (nơi ``cowork_local`` rất có thể đã bị import từ trước bởi một
test khác, khiến việc patch ``os.environ`` sau đó vô nghĩa).
KHÔNG gọi AI/QThread thật: cả (A) và (B) không đụng ``Co4ERunManager``/
``AgentWorker``/``ChatView`` thật đi gửi tin — (B) chỉ dựng widget rồi thay
``_chat_send``/``_toggle_messages`` bằng stub đếm lệnh gọi (Co4ETab thật cần
``self.ctx``, ``self._chat_send``, ``self._toggle_messages`` đã tồn tại vì
``_build_chat`` nối ``.clicked``/``.submit`` tới chúng ngay trong hàm — gọi
``Co4ETab._build_chat(fake_self)`` như một hàm KHÔNG bị ràng buộc (unbound),
với ``fake_self`` là một object tối giản chỉ có ``ctx`` + 2 stub đó, để không
phải dựng toàn bộ ``Co4ETab``/canvas/sidebar nặng nề).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``/agent:<name>`` chèn NGUYÊN TÊN agent (có thể chứa dấu cách, ví dụ
"Business Analyst") vào ô chat, KHÔNG slugify — trong khi ``/skill:<name>``
chèn ``co4e.slugify(name)`` (không dấu cách). Hai directive cùng cú pháp
nhưng xử lý tên khác nhau; một khi đã chèn, chuỗi "/agent:Business Analyst "
có dấu cách nên ``_directive_token`` không còn coi phần sau dấu cách là
thuộc token đó nữa (regex partial dùng ``[\\w\\-.]*``, không nhận dấu cách).
* Autocomplete kích hoạt từ khi gõ mới 2 ký tự ("/a" hoặc "/sk"), TRƯỚC khi
có dấu ``:`` — nhánh fallback trong ``_directive_token`` khớp theo tiền tố
của ``"/skill"``/``"/agent"`` với ``partial=""``, nên popup hiện TOÀN BỘ
danh sách skill/agent ngay từ 2 ký tự, không phải danh sách rỗng.
* Khi popup ĐANG hiện có ít nhất 1 dòng, phím Enter/Return CHỌN dòng đó
(``_accept``) và KHÔNG emit ``submit`` — ngược với khi popup ẩn, Enter emit
``submit`` để gửi tin. Cùng một phím, hai hành vi khác nhau tuỳ trạng thái
popup.
* Phím Down/Up trên popup dùng modulo (``(row + step) % n``) nên vòng lặp:
từ dòng cuối bấm Down quay về dòng đầu, từ dòng đầu bấm Up quay về dòng
cuối — không dừng ở biên như nhiều danh sách khác.
* ``_accept()`` khi popup không có dòng nào được chọn (ẩn, rỗng) là no-op
tuyệt đối — không đổi text, không đổi con trỏ, không ném lỗi.
* ``_agent_names()`` không loại trùng theo slug mà theo TÊN HIỂN THỊ: một
custom agent trùng tên với một agent built-in (ví dụ "Business Analyst")
"thắng" — built-in cùng tên bị lọc khỏi danh sách (custom agents được đưa
vào trước, built-in chỉ thêm nếu tên chưa có).
* ``_skill_names()`` bọc try/except quanh toàn bộ ``list_skills() +
builtin_skills()`` và trả về ``[]`` một cách im lặng khi lỗi — autocomplete
``/skill:`` khi đó chỉ đơn giản không hiện popup, không có thông báo lỗi.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách khu vực CHAT (``ui/co4e_tab.py`` dòng
139-228 + phần dựng widget 1030-1093) sang
``presentation/co4e/co4e_chat_view.py``. Sau khi tách xong, các case ở phần
(A) nên viết lại thành test đặc tả cho ``_ChatInput``/hàm module độc lập
(không cần subprocess/QApplication nặng nếu module mới không còn đọc đĩa lúc
import), còn phần (B) nên viết lại thành test đặc tả cho widget composer mới
(input rõ ràng: ctx giả + 2 callback, không cần dựng qua ``Co4ETab`` gốc).
Quirk "agent không slugify nhưng skill có" và quirk "dedup theo tên hiển thị"
đáng mở issue hỏi ý kiến sản phẩm trước khi ai đó "dọn" chúng trong lúc tách —
rất dễ bị coi là bug và "sửa" nhầm trong khi đây là hành vi đang chạy thật.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import json
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QApplication, QListWidget
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QKeyEvent
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import co4e, skills as skills_mod
from cowork_local.core.co4e_builtins import BUILTIN_AGENTS
from cowork_local.ui.co4e_tab import (
Co4ETab, _ChatInput, _directive_token, _skill_names, _agent_names,
)
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
# =====================================================================
# PHAN A1: _directive_token (124-136) - ham thuan, khong dung Qt/dia
# =====================================================================
cases = [
("/skill:", 7, (0, "skill", "")),
("/skill:abc", 11, (0, "skill", "abc")),
("/agent", 6, (0, "agent", "")),
("/agent:", 7, (0, "agent", "")),
("/sk", 3, (0, "skill", "")),
("/a", 2, (0, "agent", "")),
("/", 1, None),
("", 0, None),
("hello", 5, None),
("hello /agent:bob", 17, (6, "agent", "bob")),
("hello /agent:bob", 10, (6, "agent", "")),
("/skill:ab cd", 9, (0, "skill", "ab")),
("/skill:ab cd", 12, None),
("//skill", 7, None),
]
for text, pos, expected in cases:
got = _directive_token(text, pos)
assert got == expected, (text, pos, got, expected)
print("CASE_DIRECTIVE_TOKEN_OK")
# =====================================================================
# PHAN A2: _skill_names / _agent_names - doc dia thong qua sandbox
# =====================================================================
assert not skills_mod.SKILLS_DIR.exists(), skills_mod.SKILLS_DIR
assert _skill_names() == []
builtin_only = _agent_names()
assert builtin_only == [a.name for a in BUILTIN_AGENTS], builtin_only
assert len(builtin_only) == 19, len(builtin_only)
print("CASE_EMPTY_DISK_NAMES_OK")
# quirk: custom agent TRUNG TEN voi built-in -> built-in bi loc, khong con 2 ban
co4e.AGENTS_DIR.mkdir(parents=True, exist_ok=True)
(co4e.AGENTS_DIR / "a1.json").write_text(
json.dumps({"id": "a1", "name": "Business Analyst", "role": "AGENT"}),
encoding="utf-8")
(co4e.AGENTS_DIR / "a2.json").write_text(
json.dumps({"id": "a2", "name": "My Custom Agent", "role": "AGENT"}),
encoding="utf-8")
mixed = _agent_names()
assert mixed[:2] == ["Business Analyst", "My Custom Agent"], mixed[:2]
assert mixed.count("Business Analyst") == 1, mixed
assert len(mixed) == 20, len(mixed) # 19 built-in - 1 trung ten + 2 custom
print("CASE_AGENT_NAME_DEDUP_QUIRK_OK")
skills_mod.SKILLS_DIR.mkdir(parents=True, exist_ok=True)
(skills_mod.SKILLS_DIR / "s1.json").write_text(json.dumps({
"name": "Viet Test", "description": "d", "instructions": "content", "enabled": True,
}), encoding="utf-8")
assert _skill_names() == ["Viet Test"], _skill_names()
print("CASE_SKILL_NAMES_AFTER_CREATE_OK")
# =====================================================================
# PHAN A3: _ChatInput - popup autocomplete + phim tat
# =====================================================================
ci = _ChatInput()
assert hasattr(ci, "submit")
assert ci._popup.focusPolicy() == Qt.NoFocus
assert ci._popup.windowFlags() == (
Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
| Qt.NoDropShadowWindowHint)
print("CASE_CHATINPUT_CTOR_OK")
# go "/skill:vi" -> 1 dong goi y, replacement da slugify + khoang trang cuoi
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
assert ci._popup.count() == 1, ci._popup.count()
row0 = ci._popup.item(0)
assert row0.text() == "Viet Test", row0.text()
assert row0.data(Qt.UserRole) == "/skill:viet-test ", row0.data(Qt.UserRole)
assert row0.toolTip() == "Viet Test", row0.toolTip()
assert ci._popup.isVisible() is True
# quirk: chieu rong popup = max(280, chieu rong o chat) - khong co gia tri co dinh
assert ci._popup.width() == max(280, ci.width()), (ci._popup.width(), ci.width())
assert ci._popup.height() == 8 + 1 * 22, ci._popup.height() # 1 dong -> 30px
print("CASE_SKILL_POPUP_SLUGIFIED_OK")
# quirk: /agent:<ten> CHEN NGUYEN TEN (co dau cach), KHONG slugify
ci.setText("/agent:business")
ci.setCursorPosition(len("/agent:business"))
ci._maybe_popup()
assert ci._popup.count() == 1, ci._popup.count()
row_a = ci._popup.item(0)
assert row_a.text() == "Business Analyst", row_a.text()
assert row_a.data(Qt.UserRole) == "/agent:Business Analyst ", row_a.data(Qt.UserRole)
print("CASE_AGENT_POPUP_NOT_SLUGIFIED_QUIRK_OK")
# quirk: goi "/a" (2 ky tu, chua co dau :) da kich hoat popup agent VOI partial rong
# -> hien TOAN BO danh sach agent, khong phai danh sach rong
ci.setText("/a")
ci.setCursorPosition(2)
ci._maybe_popup()
assert ci._popup.count() == len(_agent_names()), (ci._popup.count(), len(_agent_names()))
print("CASE_TWO_CHAR_PREFIX_TRIGGERS_FULL_LIST_QUIRK_OK")
# khong khop skill nao -> popup an, count 0
ci.setText("/skill:khongtontai")
ci.setCursorPosition(len("/skill:khongtontai"))
ci._maybe_popup()
assert ci._popup.isVisible() is False, ci._popup.isVisible()
assert ci._popup.count() == 0, ci._popup.count()
print("CASE_NO_MATCH_HIDES_POPUP_OK")
# khong phai directive -> an popup (du popup dang duoc show truoc do)
ci._popup.show()
ci.setText("hello")
ci.setCursorPosition(5)
ci._maybe_popup()
assert ci._popup.isVisible() is False
print("CASE_NON_DIRECTIVE_HIDES_POPUP_OK")
# _accept() thay the token bang replacement + dat lai con tro
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
ci._accept()
assert ci.text() == "/skill:viet-test ", ci.text()
assert ci.cursorPosition() == len("/skill:viet-test "), ci.cursorPosition()
assert ci._popup.isVisible() is False
print("CASE_ACCEPT_REPLACES_TOKEN_OK")
# quirk: _accept() khi khong co dong nao duoc chon -> no-op tuyet doi
ci._popup.clear()
ci._popup.hide()
ci.setText("hello world")
ci.setCursorPosition(5)
ci._accept()
assert ci.text() == "hello world", ci.text()
assert ci.cursorPosition() == 5, ci.cursorPosition()
print("CASE_ACCEPT_NOOP_WHEN_NO_ITEM_QUIRK_OK")
# Enter voi popup AN -> emit submit (gui tin)
submitted = []
ci.submit.connect(lambda: submitted.append(1))
ci._popup.hide()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier))
assert submitted == [1], submitted
print("CASE_ENTER_SUBMITS_WHEN_POPUP_HIDDEN_OK")
# quirk: Enter voi popup DANG HIEN (co dong) -> accept, KHONG submit
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
assert ci._popup.isVisible() is True and ci._popup.count() == 1
submitted.clear()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier))
assert ci.text() == "/skill:viet-test ", ci.text()
assert submitted == [], submitted
print("CASE_ENTER_ACCEPTS_INSTEAD_OF_SUBMIT_WHEN_POPUP_VISIBLE_QUIRK_OK")
# quirk: Down/Up dung modulo -> vong lap qua bien
(skills_mod.SKILLS_DIR / "s2.json").write_text(json.dumps({
"name": "Second Skill", "description": "", "instructions": "x", "enabled": True,
}), encoding="utf-8")
ci.setText("/skill:")
ci.setCursorPosition(len("/skill:"))
ci._maybe_popup()
assert ci._popup.count() == 2, ci._popup.count()
assert ci._popup.currentRow() == 0
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier))
assert ci._popup.currentRow() == 1
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier))
assert ci._popup.currentRow() == 0, ci._popup.currentRow() # vong lai dau
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Up, Qt.NoModifier))
assert ci._popup.currentRow() == 1, ci._popup.currentRow() # vong ve cuoi
print("CASE_UP_DOWN_WRAP_AROUND_QUIRK_OK")
# Escape an popup
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier))
assert ci._popup.isVisible() is False
print("CASE_ESCAPE_HIDES_POPUP_OK")
# Tab cung accept (giong Enter khi popup hien)
ci.setText("/skill:vi")
ci.setCursorPosition(len("/skill:vi"))
ci._maybe_popup()
ci.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier))
assert ci.text() == "/skill:viet-test ", ci.text()
print("CASE_TAB_ACCEPTS_OK")
# =====================================================================
# PHAN B: Co4ETab._build_chat (1030-1093) - CHI phan dung widget,
# KHONG bao gom _toggle_messages (o lai Co4ETab, khong test o day)
# =====================================================================
calls = []
class _FakeCo4ETab:
ctx = ctx
def _toggle_messages(self):
calls.append("toggle")
def _chat_send(self):
calls.append("send")
fake = _FakeCo4ETab()
w = Co4ETab._build_chat(fake) # goi nhu ham khong rang buoc, khong dung Co4ETab thuc
# DA CAP NHAT sau khi tach "Chat View" (xem
# presentation/co4e/co4e_chat_view.py): _build_chat gio tra ve mot ChatPanel
# (subclass QWidget dung trong presentation/co4e/co4e_chat_view.py) thay vi
# mot QWidget tran - van la mot QWidget that su (layout/cac widget con van
# nguyen), chi ten class cu the doi (cung khuon mau da dung cho
# RunsPagePanel, xem test_co4e_runs_page.py::test_runs_table_is_parented_into_the_returned_widget).
assert type(w).__name__ == "ChatPanel"
assert w is fake._chat_widget
assert w.layout().contentsMargins().left() == 0
assert w.layout().spacing() == 0
assert w.layout().count() == 3, w.layout().count()
child_types = [w.layout().itemAt(i).widget().objectName() or type(w.layout().itemAt(i).widget()).__name__
for i in range(w.layout().count())]
assert child_types == ["msgHeader", "QStackedWidget", "QWidget"], child_types
print("CASE_BUILD_CHAT_TOP_LAYOUT_OK")
# ---- header "Messages" ----
assert fake._mhdr.objectName() == "msgHeader"
mh = fake._mhdr.layout()
assert mh.count() == 4, mh.count()
assert mh.itemAt(0).widget() is fake.msgs_icon
assert mh.itemAt(1).widget() is fake.msgs_title
assert mh.itemAt(2).widget() is None # addStretch(1) - khong phai widget
assert mh.itemAt(3).widget() is fake.chat_toggle_btn
assert mh.contentsMargins().left() == 6 and mh.contentsMargins().top() == 3
assert mh.spacing() == 6
assert fake.msgs_title.text() == tr("co4e.messages"), fake.msgs_title.text()
assert fake.msgs_title.objectName() == "hint"
assert fake.msgs_icon.pixmap().width() == 14 and fake.msgs_icon.pixmap().height() == 14
assert fake.chat_toggle_btn.objectName() == "msgToggle"
assert fake.chat_toggle_btn.isFlat() is True
assert fake.chat_toggle_btn.width() == 22 and fake.chat_toggle_btn.height() == 22
assert fake.chat_toggle_btn.toolTip() == tr("co4e.tt_expand_msgs"), fake.chat_toggle_btn.toolTip()
print("CASE_BUILD_CHAT_HEADER_OK")
# ---- chat_stack + flow_logs ----
assert fake.chat_stack.count() == 0
assert fake._flow_logs == {}
assert fake.chat_stack.isHidden() is True # mac dinh COLLAPSED
print("CASE_BUILD_CHAT_STACK_OK")
# ---- composer (chat_input_row) ----
crow = fake.chat_input_row.layout()
assert crow.count() == 2, crow.count()
assert crow.itemAt(0).widget() is fake._usage_total_lbl
assert crow.contentsMargins().top() == 4 and crow.spacing() == 3
assert fake._usage_total_lbl.text() == ""
assert fake._usage_total_lbl.objectName() == "hint"
inp_widget = crow.itemAt(1).widget()
row = inp_widget.layout()
assert row.contentsMargins().left() == 0
assert row.count() == 3, row.count()
assert row.itemAt(0).widget() is fake.chat_input
assert row.stretch(0) == 1
assert row.itemAt(1).widget() is fake.co4e_routing_toggle
assert row.stretch(1) == 0
assert row.itemAt(2).widget() is fake.chat_send_btn
assert type(fake.chat_input).__name__ == "_ChatInput"
assert fake.chat_input.placeholderText() == tr("co4e.chat_placeholder"), fake.chat_input.placeholderText()
assert fake.chat_send_btn.text() == tr("co4e.send"), fake.chat_send_btn.text()
assert type(fake.co4e_routing_toggle).__name__ == "RoutingToggle"
assert fake.co4e_routing_toggle.surface == "co4e"
assert fake._co4e_routed_provider is None
assert fake.chat_input_row.isHidden() is True # mac dinh COLLAPSED
print("CASE_BUILD_CHAT_COMPOSER_OK")
# ---- 1073/1075: submit/click cua composer noi thang toi _chat_send ----
fake.chat_send_btn.click()
assert calls == ["send"], calls
calls.clear()
fake.chat_input.submit.emit()
assert calls == ["send"], calls
calls.clear()
print("CASE_COMPOSER_WIRES_TO_CHAT_SEND_OK")
# ---- 1047: chat_toggle_btn.clicked noi thang toi _toggle_messages ----
fake.chat_toggle_btn.click()
assert calls == ["toggle"], calls
calls.clear()
print("CASE_TOGGLE_BTN_WIRES_TO_TOGGLE_MESSAGES_OK")
# ---- trang thai mac dinh COLLAPSED sau khi _build_chat tra ve ----
assert fake._vsplit_sizes == [540, 220], fake._vsplit_sizes
assert fake._msgs_collapsed is True
assert w.maximumHeight() == fake._mhdr.sizeHint().height() + 6, (
w.maximumHeight(), fake._mhdr.sizeHint().height())
print("CASE_BUILD_CHAT_DEFAULT_COLLAPSED_STATE_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_chat_view_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_DIRECTIVE_TOKEN_OK",
"CASE_EMPTY_DISK_NAMES_OK",
"CASE_AGENT_NAME_DEDUP_QUIRK_OK",
"CASE_SKILL_NAMES_AFTER_CREATE_OK",
"CASE_CHATINPUT_CTOR_OK",
"CASE_SKILL_POPUP_SLUGIFIED_OK",
"CASE_AGENT_POPUP_NOT_SLUGIFIED_QUIRK_OK",
"CASE_TWO_CHAR_PREFIX_TRIGGERS_FULL_LIST_QUIRK_OK",
"CASE_NO_MATCH_HIDES_POPUP_OK",
"CASE_NON_DIRECTIVE_HIDES_POPUP_OK",
"CASE_ACCEPT_REPLACES_TOKEN_OK",
"CASE_ACCEPT_NOOP_WHEN_NO_ITEM_QUIRK_OK",
"CASE_ENTER_SUBMITS_WHEN_POPUP_HIDDEN_OK",
"CASE_ENTER_ACCEPTS_INSTEAD_OF_SUBMIT_WHEN_POPUP_VISIBLE_QUIRK_OK",
"CASE_UP_DOWN_WRAP_AROUND_QUIRK_OK",
"CASE_ESCAPE_HIDES_POPUP_OK",
"CASE_TAB_ACCEPTS_OK",
"CASE_BUILD_CHAT_TOP_LAYOUT_OK",
"CASE_BUILD_CHAT_HEADER_OK",
"CASE_BUILD_CHAT_STACK_OK",
"CASE_BUILD_CHAT_COMPOSER_OK",
"CASE_COMPOSER_WIRES_TO_CHAT_SEND_OK",
"CASE_TOGGLE_BTN_WIRES_TO_TOGGLE_MESSAGES_OK",
"CASE_BUILD_CHAT_DEFAULT_COLLAPSED_STATE_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi cac file skill/agent test tu tao xuong
# SKILLS_DIR/AGENTS_DIR (khong co du lieu nguoi dung that nao bi cham vao) -
# CONFIG_DIR nam trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local").exists()
@@ -0,0 +1,464 @@
"""Characterization test cho lớp CŨ ``core/co4e_run_manager.py``
(``Co4ERunManager`` + ``RunHandle``) — hook ``_on_event``/``_on_finished``/
``_on_failed`` và round-trip ``RunHandle.to_record``/``RunHandle.from_record``.
VÒNG ĐỜI: đây là giàn giáo (scaffolding), không phải công trình cuối cùng.
Mục đích DUY NHẤT là làm lưới an toàn cho đợt tách ``Co4ERunManager`` thành
``domain/workflows/run_record.py::RunRecord`` (DTO thuần) +
``application/workflows/co4e_workflow_service.py::Co4EWorkflowService``
(hành vi + lifecycle + lưu đĩa) — xem ``tests/test_co4e_workflow_service.py``,
nơi lớp MỚI được bọc lại bằng test đặc tả tử tế, và nơi có
``test_new_service_produces_same_json_record_as_old_manager`` chạy CÙNG một
chuỗi thao tác trên cả hai lớp rồi so JSON ghi ra đĩa — bằng chứng "hành vi
không lệch" chạy được. File NÀY chỉ ghi lại hành vi của lớp CŨ, KHÔNG được sửa
lớp cũ để "cho khớp" test — nếu một assert dưới đây đỏ mà code cũ trông "sai",
sửa assert, không sửa ``core/co4e_run_manager.py``. Sau khi đợt tách hoàn tất
và ``core/co4e_run_manager.py`` bị xoá/deprecate hẳn, file này hết nhiệm vụ và
nên được xoá theo (không viết lại thành spec — spec test đã có sẵn ở
``tests/test_co4e_workflow_service.py``).
CÁCH CHỐT ASSERT: mọi giá trị dưới đây được lấy bằng cách CHẠY code thật qua
``.venv/Scripts/python.exe -c "..."`` rồi dán nguyên kết quả in được vào
assert (đúng quy trình ngược yêu cầu), không suy luận lý thuyết.
AN TOÀN DỮ LIỆU (BẮT BUỘC — lý do có 2 lớp phòng thủ dưới đây):
1. ``Co4ERunManager.__init__`` gọi ``_load_history()`` đọc
``~/.cowork_local/co4e/run_history.json`` THẬT (``CONFIG_DIR`` là
``Path.home() / ".cowork_local"``, hằng số module tính MỘT LẦN lúc
``cowork_local.config`` được import), rồi mọi ``changed.emit()`` (từ mọi
hook mà test này gọi) kéo theo ``_save_history()`` ghi ĐÈ file đó. Vì
vậy, TRƯỚC khi import bất kỳ thứ gì thuộc ``cowork_local``, module này tự
dựng một HOME giả (``_isolate_home()``, cùng kỹ thuật
``tools/capture_screens.py::_isolate_home()``: đặt ``USERPROFILE``/
``HOME`` trỏ vào một thư mục tạm, xoá ``HOMEDRIVE``/``HOMEPATH``) rồi mới
import ``cowork_local.config``/``cowork_local.core.co4e_run_manager`` —
một assert ngay sau import chốt rằng ``CONFIG_DIR`` thật sự nằm trong
sandbox đó (kiểu ``tools/check_co4e.py:47``).
2. Phòng thủ thứ hai, độc lập với (1): mỗi test còn monkeypatch
``Co4ERunManager._history_path`` trỏ về một file trong ``tmp_path`` CỦA
RIÊNG NÓ. Lý do cần thêm lớp này dù đã có (1): nếu file test này được
chạy CÙNG bộ với các file khác đã import ``cowork_local.config`` với HOME
thật trước đó (thứ tự collect của pytest), hằng số module ``CONFIG_DIR``/
``CO4E_DIR`` đã bị đóng băng theo HOME thật mất rồi — xem đúng cái bẫy
này được ghi lại trong docstring đầu
``tests/characterization/test_co4e_skills_panel.py``. Vá thẳng
``_history_path`` (đọc lại lúc GỌI, không đọc lúc import) không phụ
thuộc thời điểm import nên luôn đúng bất kể thứ tự collect.
KHÔNG gọi ``Co4ERunManager.start()`` (spawn ``AgentWorker``/``QThread`` thật,
gọi provider AI thật, tốn tiền, ghi file thật). Mọi test dưới đây seed thẳng
vào ``manager._runs[...]`` bằng ``RunHandle`` rồi gọi ``_on_event``/
``_on_finished``/``_on_failed`` trực tiếp — đúng route hook thật mà
``start()`` nối qua ``worker.event.connect(...)`` v.v., chỉ bỏ qua phần
spawn/chạy job.
KHÔNG CẦN QApplication: ``Co4ERunManager``/``RunHandle`` chỉ dùng
``QObject``+``Signal`` đồng luồng (kết nối rồi ``emit()`` ngay trong test,
không dựng widget, không đọc kích thước, không cần app instance).
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bug nhưng là hành vi đang chạy thật hôm nay):
* ``event = Signal(str, dict)`` khai báo kiểu cứng cho tham số thứ hai. Khi
``_on_event(run_id, ev)`` được gọi với ``ev=None`` và ``run_id`` KHÔNG có
trong ``_runs`` (nên nhánh xử lý dict bị bỏ qua, đi thẳng xuống
``self.event.emit(run_id, ev)``), Qt/Shiboken ép kiểu ``None`` thành
``dict`` RỖNG (``{}``) ngay tại điểm ``emit`` — listener nhận được
``{}`` chứ KHÔNG PHẢI ``None``. (Shiboken có in một dòng cảnh báo
"Cannot copy-convert ... (NoneType) to C++" ra stderr, nhưng KHÔNG ném
lỗi.) Đây là khác biệt cố ý với lớp MỚI
(``Co4EWorkflowService``/``domain/workflows/run_record.py``), nơi không
còn ``Signal`` nữa nên callback nhận đúng ``None`` gốc — xem
``tests/test_co4e_workflow_service.py::test_on_event_none_payload_does_not_raise_and_reemits_none``.
* Khi ``ev["type"] == "run_done"`` mà ``handle.status`` KHÔNG phải
``"running"`` (ví dụ đã ``"stopped"``), nhánh ``if handle.status ==
"running":`` không đổi ``status``, nhưng ``self.changed.emit()`` vẫn được
gọi VÔ ĐIỀU KIỆN ngay sau đó (nằm ngoài ``if``) — run "đã xong" vẫn kích
một lần refresh + một lần ghi lịch sử xuống đĩa, dù không có gì thay đổi
trên ``handle`` đó.
* ``RunHandle.to_record()``/``from_record()`` đổi trường ``wf`` qua lại
thành ĐỐI TƯỢNG ``Workflow`` thật (``workflow_to_dict``/
``workflow_from_dict`` từ ``core/co4e.py``) — khác hẳn ``RunRecord`` mới
(``domain/workflows/run_record.py``), nơi ``wf`` CỐ Ý được giữ nguyên là
dict thô vì domain không được phép import ``core.co4e.Workflow``. Đây là
một khác biệt thiết kế có chủ đích giữa bản cũ và bản mới, không phải lỗi
port thiếu.
* Round-trip KHÔNG đối xứng: ``status == "running"`` đọc lại từ
``from_record()`` bị chốt thành ``"stopped"`` (worker của nó đã mất theo
khi app tắt giữa run), nhưng ``to_record()`` vẫn ghi đúng "running" xuống
đĩa tại thời điểm lưu. ``from_record({})``/``from_record(None)`` mặc định
``status="done"`` (không phải "running") nên KHÔNG rơi vào nhánh đổi
thành "stopped".
* ``total`` âm bị ``max(0, total)`` kẹp về 0 ngay lúc khởi tạo
``RunHandle``, không giữ nguyên giá trị âm.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
def _isolate_home() -> Path:
"""Trỏ USERPROFILE/HOME sang một thư mục tạm TRƯỚC khi import
``cowork_local`` — cùng kỹ thuật ``tools/capture_screens.py::_isolate_home()``.
Không có dữ liệu thật nào được sao chép vào đây (khác capture_screens):
test này không cần đọc lịch sử run thật, chỉ cần KHÔNG BAO GIỜ chạm vào nó.
"""
sandbox = Path(tempfile.mkdtemp(prefix="co4e-run-manager-test-home-"))
(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
_SANDBOX_HOME = _isolate_home()
import pytest # noqa: E402
from cowork_local.config import CONFIG_DIR # noqa: E402
from cowork_local.core.co4e import Node, Step, Workflow # noqa: E402
from cowork_local.core.co4e_run_manager import Co4ERunManager, RunHandle # noqa: E402
# GIỚI HẠN ĐÃ BIẾT (không phải lỗ hổng an toàn dữ liệu — xem lớp phòng thủ #2
# ở docstring đầu file, mọi test dưới đây đều tự vá ``_history_path`` bất kể
# kết quả kiểm tra này): ``CONFIG_DIR`` là hằng số module tính MỘT LẦN lúc
# ``cowork_local.config`` được import. Khi chạy CHỈ file này (đúng lệnh VERIFY
# ở đầu task), ``_isolate_home()`` ở trên chạy trước import đầu tiên nên chốt
# đúng. Khi chạy CẢ BỘ, một file khác được pytest collect trước có thể đã
# import ``cowork_local.config`` với HOME thật rồi — ``CONFIG_DIR`` khi đó đã
# đóng băng theo giá trị thật, không cách nào isolate lại được nữa từ file này
# (cùng giới hạn được ghi trong docstring đầu
# ``tests/characterization/test_co4e_skills_panel.py``). Vì vậy test dưới đây
# CHỦ ĐỘNG bỏ qua (không fail cả file, không làm mất 30 test còn lại) khi phát
# hiện giới hạn này, thay vì assert cứng ở cấp module (từng thử — gây lỗi
# collection cho TOÀN BỘ file khi chạy chung với các file khác đã import
# ``cowork_local.config`` trước).
def test_home_isolation_pins_config_dir_into_sandbox_when_first_to_import():
if str(_SANDBOX_HOME) not in str(CONFIG_DIR):
pytest.skip(
"cowork_local.config da bi mot file test khac import voi HOME "
"that TRUOC file nay trong cung phien pytest (thu tu collect) -- "
f"CONFIG_DIR={CONFIG_DIR!r} khong con nam trong sandbox cua file "
"nay. Day la gioi han da biet (xem docstring dau file), KHONG "
"phai mat an toan du lieu: moi test hook trong file nay tu va "
"thang Co4ERunManager._history_path (doc lap voi CONFIG_DIR) nen "
"khong test nao trong file thuc su cham vao lich su run that."
)
assert str(_SANDBOX_HOME) in str(CONFIG_DIR)
class _Ctx:
"""Stub ``ctx``: không hook/round-trip nào trong file này đụng tới
``ctx.config`` (chỉ ``start()``/``_out_dir()`` — không thuộc phạm vi bọc
của file này — mới cần nó thật)."""
@pytest.fixture
def manager(tmp_path, monkeypatch):
# Lớp phòng thủ thứ hai (xem docstring đầu file): mỗi test có lịch sử
# riêng trong tmp_path của chính nó, độc lập với CONFIG_DIR.
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
return Co4ERunManager(_Ctx())
def _seed(manager: Co4ERunManager, run_id: str, **kw) -> RunHandle:
defaults = dict(wf_id="wf1", name="Flow", total=3, plan_mode=False, manual=False)
defaults.update(kw)
h = RunHandle(run_id, **defaults)
manager._runs[run_id] = h
return h
def _make_workflow(node_count: int = 1, wf_id: str = "wf-x", name: str = "Flow X") -> Workflow:
nodes = [Node(id=f"n{i}", x=0.0, y=0.0, data=Step(label=f"Step{i}")) for i in range(1, node_count + 1)]
return Workflow(id=wf_id, name=name, nodes=nodes, edges=[])
# ---------------------------------------------------------------------------
# RunHandle: gia tri mac dinh / kep bien
# ---------------------------------------------------------------------------
def test_run_handle_defaults_on_construction():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
assert h.status == "running"
assert h.done == 0
assert h.progress_text() == "0/3"
assert h.running is True
def test_run_handle_negative_total_clamped_to_zero():
h = RunHandle("run2", "wf2", "Flow2", -5, False, False)
assert h.total == 0
def test_run_handle_zero_total_progress_text_falls_back_to_status():
h = RunHandle("run3", "wf3", "Flow3", 0, False, False)
assert h.progress_text() == "running"
# ---------------------------------------------------------------------------
# to_record / from_record
# ---------------------------------------------------------------------------
def test_to_record_contains_expected_keys_and_values():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False,
created_by="alice", created_at="2026-08-23 10:00", project_id="p1")
rec = h.to_record()
assert sorted(rec.keys()) == [
"created_at", "created_by", "done", "error", "id", "manual", "name",
"node_status", "out_dir", "plan_mode", "project_id", "status", "total",
"wf", "wf_id",
]
assert rec["id"] == "run1"
assert rec["status"] == "running"
assert rec["wf"] is None
def test_round_trip_status_running_becomes_stopped():
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
rec = h.to_record()
assert rec["status"] == "running" # to_record ghi dung "running"
back = RunHandle.from_record(rec)
assert back.status == "stopped" # nhung from_record chot lai thanh "stopped"
@pytest.mark.parametrize("status", ["done", "error", "stopped"])
def test_round_trip_non_running_statuses_are_preserved(status):
h = RunHandle("run1", "wf1", "My Flow", 3, False, False)
h.status = status
back = RunHandle.from_record(h.to_record())
assert back.status == status
def test_from_record_empty_dict_uses_documented_defaults():
h = RunHandle.from_record({})
assert h.id == ""
assert h.status == "done" # quirk: khong roi vao nhanh doi thanh "stopped"
assert h.wf is None
assert h.node_status == {}
def test_from_record_none_treated_same_as_empty_dict():
assert RunHandle.from_record(None).id == RunHandle.from_record({}).id
assert RunHandle.from_record(None).status == RunHandle.from_record({}).status
def test_round_trip_workflow_snapshot_becomes_real_workflow_object():
# quirk: khac RunRecord moi (giu wf la dict tho), RunHandle CU doi wf qua
# lai thanh doi tuong Workflow that qua workflow_to_dict/workflow_from_dict.
h = RunHandle("run4", "wf-x", "Flow X run", 1, False, False)
h.wf = _make_workflow()
rec = h.to_record()
assert isinstance(rec["wf"], dict) # tren dia luon la dict (JSON-able)
assert rec["wf"]["id"] == "wf-x"
assert rec["wf"]["name"] == "Flow X"
back = RunHandle.from_record(rec)
assert isinstance(back.wf, Workflow) # nhung doc lai thanh doi tuong that
assert back.wf.id == "wf-x"
assert back.wf.name == "Flow X"
assert len(back.wf.nodes) == 1
assert back.wf.nodes[0].id == "n1"
# ---------------------------------------------------------------------------
# _on_event
# ---------------------------------------------------------------------------
def test_on_event_node_status_done_increments_progress_and_emits_changed(manager):
h = _seed(manager, "run1")
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
assert h.node_status == {"n1": "done"}
assert h.done == 1
assert len(changed) == 1
def test_on_event_node_status_planned_counts_as_terminal_too(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "planned"})
assert h.done == 1
def test_on_event_node_status_running_is_not_terminal(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "running"})
assert h.done == 0
def test_on_event_node_status_missing_keys_stores_none_key(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "node_status"})
assert h.node_status == {None: None}
def test_on_event_run_done_default_ok_marks_done(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "run_done"})
assert h.status == "done"
def test_on_event_run_done_ok_false_marks_error(manager):
h = _seed(manager, "run1")
manager._on_event("run1", {"type": "run_done", "ok": False})
assert h.status == "error"
def test_on_event_run_done_when_not_running_leaves_status_but_still_emits_changed(manager):
# quirk (xem docstring dau file): "if status == running" khong doi status,
# nhung changed.emit() nam NGOAI if nen van chay du khong co gi doi tren
# handle nay.
h = _seed(manager, "run1")
h.status = "stopped"
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_event("run1", {"type": "run_done", "ok": False})
assert h.status == "stopped"
assert len(changed) == 1
def test_on_event_unknown_run_id_does_not_raise_and_still_reemits_event(manager):
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})
assert received == [("no-such-run", {"type": "node_status", "node_id": "n1", "status": "done"})]
def test_on_event_none_payload_on_unknown_run_id_is_coerced_to_empty_dict_by_qt(manager):
# quirk (xem docstring dau file): event = Signal(str, dict) ep None thanh
# {} ngay tai diem emit -- khac han lop MOI (khong con Signal) nhan dung
# None goc. Day la khac biet CO CHU Y giua ban cu va ban moi, khong phai
# bug can sua.
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("no-such-run", None)
assert received == [("no-such-run", {})]
def test_on_event_none_payload_on_known_run_id_does_not_mutate_handle(manager):
h = _seed(manager, "run1")
received = []
manager.event.connect(lambda rid, ev: received.append((rid, ev)))
manager._on_event("run1", None)
assert h.status == "running"
assert h.node_status == {}
assert received == [("run1", {})] # cung bi Qt ep thanh {} nhu tren
# ---------------------------------------------------------------------------
# _on_finished
# ---------------------------------------------------------------------------
def test_on_finished_while_running_settles_to_done(manager):
h = _seed(manager, "run1")
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("run1")
assert h.status == "done"
assert len(changed) == 1
def test_on_finished_when_already_settled_is_a_noop(manager):
h = _seed(manager, "run1")
h.status = "error"
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("run1")
assert h.status == "error"
assert len(changed) == 0
def test_on_finished_unknown_run_id_is_a_total_noop(manager):
changed = []
manager.changed.connect(lambda: changed.append(1))
manager._on_finished("no-such-run")
assert manager._runs == {}
assert changed == []
# ---------------------------------------------------------------------------
# _on_failed
# ---------------------------------------------------------------------------
def test_on_failed_marks_error_with_message_and_emits_run_error_event(manager):
h = _seed(manager, "run1")
events = []
changed = []
manager.event.connect(lambda rid, ev: events.append((rid, ev)))
manager.changed.connect(lambda: changed.append(1))
manager._on_failed("run1", "boom")
assert h.status == "error"
assert h.error == "boom"
assert events == [("run1", {"type": "run_error", "error": "boom"})]
assert len(changed) == 1
def test_on_failed_overrides_status_even_when_already_settled(manager):
h = _seed(manager, "run1")
h.status = "done"
manager._on_failed("run1", "late failure")
assert h.status == "error"
def test_on_failed_unknown_run_id_is_a_total_noop(manager):
events = []
changed = []
manager.event.connect(lambda rid, ev: events.append((rid, ev)))
manager.changed.connect(lambda: changed.append(1))
manager._on_failed("no-such-run", "err")
assert events == []
assert changed == []
# ---------------------------------------------------------------------------
# persistence: hook -> dia THAT (trong sandbox cua rieng tung test) -> reload
# ---------------------------------------------------------------------------
def test_changed_hook_persists_to_history_file(manager, tmp_path):
_seed(manager, "run1")
manager._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
path = tmp_path / "run_history.json"
assert path.exists()
import json
data = json.loads(path.read_text(encoding="utf-8"))
assert len(data["runs"]) == 1
assert data["runs"][0]["id"] == "run1"
assert data["runs"][0]["status"] == "running"
def test_reloading_manager_after_hook_settles_running_to_stopped(tmp_path, monkeypatch):
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
mgr1 = Co4ERunManager(_Ctx())
_seed(mgr1, "run1")
mgr1._on_event("run1", {"type": "node_status", "node_id": "n1", "status": "done"})
mgr2 = Co4ERunManager(_Ctx())
assert "run1" in mgr2._runs
assert mgr2._runs["run1"].status == "stopped"
assert mgr2._seq == 1
def test_reloaded_seq_avoids_colliding_with_history_ids(tmp_path, monkeypatch):
history_path = tmp_path / "run_history.json"
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: history_path)
mgr1 = Co4ERunManager(_Ctx())
_seed(mgr1, "run7")
mgr1._on_event("run7", {"type": "node_status", "node_id": "n1", "status": "done"})
mgr2 = Co4ERunManager(_Ctx())
assert mgr2._seq == 7
assert mgr2._next_id() == "run8"
def test_load_history_missing_file_is_silent_noop(tmp_path, monkeypatch):
monkeypatch.setattr(Co4ERunManager, "_history_path", lambda self: tmp_path / "does-not-exist.json")
mgr = Co4ERunManager(_Ctx())
assert mgr._runs == {}
assert mgr._seq == 0
@@ -0,0 +1,524 @@
"""Characterization test cho phần DỰNG WIDGET của trang "Runs" trong Co4E
(``ui/co4e_tab.py``, method ``_build_runs_page``, dòng 869-928).
VÒNG ĐỜI: đây là giàn giáo (scaffolding), KHÔNG phải đặc tả cuối cùng. Mục
đích DUY NHẤT là lưới an toàn cho đợt tách "Run Control" sắp tới (xem
``docs/architecture/co4e-split-map-run-control.md`` — dự kiến ``_build_runs_page``
sẽ dời sang ``presentation/co4e/co4e_run_control_widget.py``). Sau khi việc
tách hoàn tất và ổn định, các test ở đây nên được viết lại thành test đặc tả
(specification test) cho widget/API mới; quirk nào liệt kê dưới đây nên có
issue riêng nếu ai đó muốn "dọn" chúng — ĐỪNG tự sửa code sản phẩm để "dọn"
quirk khi đọc thấy test này.
PHẠM VI: CHỈ ``_build_runs_page`` — phần dựng ``QWidget``/``runs_table`` (5
cột) + 7 widget con (``runs_back_btn``, ``runs_title``, ``ws_folder_btn``,
``run_stop_btn``, ``run_rename_btn``, ``run_del_btn``, ``run_clear_btn``) và
việc NỐI (connect) các signal của chúng tới các method xử lý. CÁC METHOD XỬ LÝ
(``_refresh_runs``, ``_stop_selected_run``, ``_delete_selected_run``,
``_runs_context_menu``, ``_rename_selected_run``, ``_open_run_from_table``,
``_open_workspace_folder``, ``_refresh_ws_folder_btn``) VẪN Ở NGUYÊN trên
``Co4ETab`` và KHÔNG được characterize ở đây (đề bài giao đúng phạm vi
constructor cho lượt này) — test này chỉ xác nhận rằng bấm nút/emit signal
tương ứng CÓ gọi tới đúng method trên ``self`` (qua fake/stub), không xác
nhận method đó làm gì bên trong.
CÁCH DỰNG: ``_build_runs_page`` là instance method cần rất nhiều state của
``Co4ETab`` thật (``self.ctx``, ``self.manager``, hàng chục method khác) để
dựng trọn vẹn — dựng cả ``Co4ETab`` chỉ để test 60 dòng constructor này là
tốn kém và kéo theo rủi ro chạm những phần KHÔNG thuộc phạm vi. Nên ở đây
gọi thẳng ``Co4ETab._build_runs_page(fake_self)`` (unbound, theo đúng gợi ý
của đề bài) trên một ``_FakeTab`` tối giản: chỉ có ``ctx``/``_project_dir``/
``manager`` (để ``_flow_output_root``/``_refresh_ws_folder_btn`` — hai
method DÙNG THẬT của ``Co4ETab``, được gọi ngay TRONG lúc dựng ở dòng 893 —
chạy được) và các method xử lý còn lại được thay bằng stub ghi lại số lần gọi
(KHÔNG gọi ``Co4ERunManager.start()``/dựng ``QThread``/``AgentWorker`` thật —
đúng ràng buộc "không spawn thread/gọi provider thật").
AN TOÀN DỮ LIỆU: dù bước dựng widget này tự nó KHÔNG chạm đĩa, việc gọi
``_flow_output_root()``/``_refresh_ws_folder_btn()`` NGAY trong lúc dựng lại
tính ``CONFIG_DIR`` (qua ``cowork_local.config.CONFIG_DIR = Path.home() /
".cowork_local"``, xuyên qua ``core.co4e.CO4E_DIR``) — nên toàn bộ probe chạy
trong TIẾN TRÌNH CON riêng (tránh xung đột ``QApplication`` singleton với các
test khác trong cùng lượt chạy pytest) với ``HOME``/``USERPROFILE`` trỏ vào
một ``tmp_path`` sandbox ĐẶT TRƯỚC khi script import bất kỳ thứ gì thuộc
``cowork_local`` (đúng kỹ thuật ``tools/capture_screens.py::_isolate_home()``:
đặt ``USERPROFILE``/``HOME``, xoá ``HOMEDRIVE``/``HOMEPATH``), và
``QT_QPA_PLATFORM=offscreen`` được đặt TRƯỚC khi import PySide6 (đúng khuôn
``tools/check_co4e.py`` dòng 22+40-42). Một assert ngay trong tiến trình con
chốt ``CONFIG_DIR`` nằm trong sandbox trước khi làm gì khác (kiểu
``tools/check_co4e.py:47``).
CẦN QApplication: ``_build_runs_page`` dựng ``QWidget``/``QTableWidget`` thật,
đọc ``.text()``/``.toolTip()``/``.icon()``/``.cursor()`` và bấm nút thật qua
``.click()`` — không phải kiểu giá trị thuần, nên bắt buộc ``QApplication``
(``offscreen``), khác các test hình học thuần (``test_co4e_canvas_geometry.py``).
CÁCH CHỐT ASSERT: mọi giá trị dưới đây lấy bằng cách CHẠY code thật (script
``_PROBE_SCRIPT`` bên dưới, qua ``.venv/Scripts/python.exe``, giống hệt lệnh ở
cuối file) rồi dán NGUYÊN VĂN JSON in được vào assert — không suy luận lý
thuyết. Các chuỗi hiển thị phụ thuộc ``tr()`` (tiếng Việt mặc định) được so
sánh bằng cách gọi LẠI ``tr()`` thật ngay trong tiến trình test (không cần
QApplication, ``i18n.py`` là tra bảng thuần) thay vì chép tay chuỗi có dấu —
tránh gõ nhầm ký tự Unicode khi transcribe.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới — ĐỪNG "dọn" các chỗ này khi
tách, chúng trông như bất đối xứng khó hiểu nhưng là hành vi đang chạy thật
hôm nay):
* ``run_clear_btn`` là nút DUY NHẤT trong 5 nút hàng thao tác KHÔNG có
``setIcon(...)`` — icon của nó luôn rỗng (``.icon().isNull() is True``),
khác hẳn ``run_stop_btn``/``run_rename_btn``/``run_del_btn``/
``ws_folder_btn`` đều có icon SVG built-in.
* ``run_clear_btn.clicked`` nối THẲNG tới
``lambda: self.manager.clear_finished()`` — gọi trực tiếp method trên
``manager``, KHÔNG đi qua một method riêng trên ``self`` như 3 nút hàng
xóm (``_stop_selected_run``/``_rename_selected_run``/
``_delete_selected_run``) — không có lớp bọc nào để thêm xác nhận
(confirm dialog) sau này mà không sửa trực tiếp dòng connect này.
* ``ws_folder_btn`` text được dựng bằng ``"…/" + "/".join(parts[-2:])``
(đúng 3 ký tự: dấu chấm lửng Unicode U+2026 rồi dấu gạch chéo xuôi) khi
đường dẫn có hơn 2 phần — dùng ``"/"`` LUÔN LUÔN, kể cả trên Windows
(khác separator ``\\`` của phần còn lại của path), và không có fallback
hiển thị full path trừ khi ``len(parts) <= 2``.
* ``_flow_output_root()`` (được ``_refresh_ws_folder_btn()`` gọi NGAY
trong lúc dựng, dòng 893) bọc ``ctx.config.cowork_output_dir()`` trong
``except Exception`` RỘNG — bất kỳ lỗi nào từ ``ctx.config`` (kể cả
``AttributeError`` vì ``ctx`` không có ``.config`` như trong probe này)
đều rơi vào nhánh fallback ``co4e.CO4E_DIR / "runs"`` một cách im lặng,
không log, không báo cho người gọi biết đã fallback.
* ``runs_table`` sau khi dựng xong LUÔN có 0 hàng bất kể ``manager`` đang
có run nào hay không — ``_build_runs_page`` không tự seed dữ liệu, việc
đó thuộc về ``_refresh_runs`` (method riêng, KHÔNG thuộc phạm vi test
này) — ai tách file mà tưởng constructor này "phải" gọi ``_refresh_runs``
luôn thì sẽ đổi hành vi so với hôm nay.
Lệnh thủ công đã dùng để chốt các con số trên (quy trình ngược, xem cuối file
để chạy lại nếu cần chốt lại sau khi code đổi có chủ đích).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_PROBE_SCRIPT = r"""
import json
import os
import sys
from pathlib import Path
sandbox = sys.argv[1]
repo_parent = sys.argv[2]
sys.path.insert(0, repo_parent)
os.environ["QT_QPA_PLATFORM"] = "offscreen"
for var in ("USERPROFILE", "HOME"):
os.environ[var] = sandbox
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
from PySide6.QtCore import QPoint, Qt
from PySide6.QtWidgets import (
QApplication, QHeaderView, QTableWidget, QTableWidgetItem,
)
app = QApplication([])
from cowork_local.config import CONFIG_DIR
assert str(Path(sandbox).resolve()) in str(CONFIG_DIR.resolve()), (
"khong co lap: CONFIG_DIR=" + str(CONFIG_DIR))
from cowork_local.ui.co4e_tab import Co4ETab
result = {}
calls = {"show_runs": [], "stop": 0, "rename": 0, "delete": 0, "open_run": 0,
"ws_open": 0, "ctx_menu": 0}
class _Manager:
# Stand-in cho Co4ERunManager - KHONG dung QThread/AgentWorker that.
def __init__(self):
self.clear_finished_calls = 0
def clear_finished(self):
self.clear_finished_calls += 1
class _Config:
def cowork_output_dir(self):
# co tinh nem loi de _flow_output_root roi vao nhanh fallback
# (quirk duoc dong dinh trong docstring cua file test).
raise RuntimeError("no config in probe")
class _Ctx:
def __init__(self):
self.config = _Config()
class _FakeTab:
# Chi mang du state de goi duoc Co4ETab._build_runs_page(self) unbound -
# KHONG dung ca Co4ETab that (tranh keo theo sidebar/canvas/config panel
# ngoai pham vi).
# Hai method NAY la method THAT cua Co4ETab, duoc _build_runs_page goi
# NGAY trong luc dung (dong 893) - phai la ban that, khong stub.
_flow_output_root = Co4ETab._flow_output_root
_refresh_ws_folder_btn = Co4ETab._refresh_ws_folder_btn
_build_runs_page = Co4ETab._build_runs_page
def __init__(self):
self.ctx = _Ctx()
self._project_dir = None
self.manager = _Manager()
def _show_runs(self, on):
calls["show_runs"].append(on)
def _stop_selected_run(self):
calls["stop"] += 1
def _rename_selected_run(self):
calls["rename"] += 1
def _delete_selected_run(self):
calls["delete"] += 1
def _open_run_from_table(self, item):
calls["open_run"] += 1
def _open_workspace_folder(self):
calls["ws_open"] += 1
def _runs_context_menu(self, pos):
calls["ctx_menu"] += 1
fake = _FakeTab()
w = fake._build_runs_page()
result["w_type"] = type(w).__name__
result["layout_type"] = type(w.layout()).__name__
result["table_is_child_of_w"] = w.findChild(type(fake.runs_table)) is fake.runs_table
result["col_count"] = fake.runs_table.columnCount()
result["row_count"] = fake.runs_table.rowCount()
result["header_resize_mode_is_stretch"] = (
fake.runs_table.horizontalHeader().sectionResizeMode(0) == QHeaderView.Stretch
)
result["vheader_visible"] = fake.runs_table.verticalHeader().isVisible()
# isVisible() luon False vi 'w' khong bao gio duoc .show() trong probe nay
# (widget khong nam trong mot top-level dang hien thi that) - dung
# isVisibleTo(ancestor) de bat dung co hidden explicit da duoc setVisible(...)
# dat tren verticalHeader, khong phu thuoc chuoi ancestor co duoc show hay
# khong. Xem PySide6 doc QWidget.isVisibleTo(): tra ve True/False dua tren co
# WA_WState_Hidden explicit cua chinh widget do (va cac ancestor tinh den
# truoc 'ancestor'), bat ke ancestor da .show() hay chua.
result["vheader_visible_to_table"] = (
fake.runs_table.verticalHeader().isVisibleTo(fake.runs_table)
)
result["edit_triggers_is_no_edit"] = (
fake.runs_table.editTriggers() == QTableWidget.NoEditTriggers
)
result["selection_behavior_is_select_rows"] = (
fake.runs_table.selectionBehavior() == QTableWidget.SelectRows
)
result["context_menu_policy_is_custom"] = (
fake.runs_table.contextMenuPolicy() == Qt.CustomContextMenu
)
result["table_tooltip"] = fake.runs_table.toolTip()
result["back_btn_text"] = fake.runs_back_btn.text()
result["back_btn_tooltip"] = fake.runs_back_btn.toolTip()
result["back_icon_isnull"] = fake.runs_back_btn.icon().isNull()
result["title_text"] = fake.runs_title.text()
result["title_object_name"] = fake.runs_title.objectName()
result["ws_folder_flat"] = fake.ws_folder_btn.isFlat()
result["ws_folder_cursor_is_pointing_hand"] = (
fake.ws_folder_btn.cursor().shape() == Qt.PointingHandCursor
)
result["ws_folder_text"] = fake.ws_folder_btn.text()
result["ws_folder_tooltip"] = fake.ws_folder_btn.toolTip()
result["ws_folder_icon_isnull"] = fake.ws_folder_btn.icon().isNull()
result["stop_btn_text"] = fake.run_stop_btn.text()
result["stop_btn_object_name"] = fake.run_stop_btn.objectName()
result["stop_btn_tooltip"] = fake.run_stop_btn.toolTip()
result["stop_btn_icon_isnull"] = fake.run_stop_btn.icon().isNull()
result["rename_btn_text"] = fake.run_rename_btn.text()
result["rename_btn_tooltip"] = fake.run_rename_btn.toolTip()
result["rename_btn_icon_isnull"] = fake.run_rename_btn.icon().isNull()
result["del_btn_text"] = fake.run_del_btn.text()
result["del_btn_tooltip"] = fake.run_del_btn.toolTip()
result["del_btn_icon_isnull"] = fake.run_del_btn.icon().isNull()
result["clear_btn_text"] = fake.run_clear_btn.text()
result["clear_btn_tooltip"] = fake.run_clear_btn.toolTip()
result["clear_btn_object_name"] = fake.run_clear_btn.objectName()
result["clear_btn_icon_isnull"] = fake.run_clear_btn.icon().isNull()
# --- bam nut / emit signal that -> xac nhan CO goi dung method tren self ----
fake.runs_back_btn.click()
result["calls_after_back_click"] = list(calls["show_runs"])
fake.ws_folder_btn.click()
result["ws_open_calls_after_click"] = calls["ws_open"]
fake.run_stop_btn.click()
result["stop_calls_after_click"] = calls["stop"]
fake.run_rename_btn.click()
result["rename_calls_after_click"] = calls["rename"]
fake.run_del_btn.click()
result["delete_calls_after_click"] = calls["delete"]
fake.run_clear_btn.click()
result["manager_clear_finished_calls_after_click"] = fake.manager.clear_finished_calls
item = QTableWidgetItem("row0")
fake.runs_table.setRowCount(1)
fake.runs_table.setItem(0, 0, item)
fake.runs_table.itemDoubleClicked.emit(item)
result["open_run_calls_after_dbl_click"] = calls["open_run"]
fake.runs_table.customContextMenuRequested.emit(QPoint(5, 5))
result["ctx_menu_calls_after_signal"] = calls["ctx_menu"]
result["flow_output_root_fallback"] = str(fake._flow_output_root())
result["config_dir"] = str(CONFIG_DIR)
print(json.dumps(result, sort_keys=True, ensure_ascii=True))
print("PROBE_OK")
"""
@pytest.fixture(scope="module")
def probe_result(tmp_path_factory):
"""Chạy ``_PROBE_SCRIPT`` một lần cho cả module trong TIẾN TRÌNH CON, trả
về dict JSON đã in được cùng ``sandbox`` đã dùng (để test tính lại các giá
trị phụ thuộc đường dẫn, thay vì chép tay chuỗi tuyệt đối)."""
sandbox = tmp_path_factory.mktemp("co4e-runs-page-home")
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
result = subprocess.run(
[sys.executable, "-c", _PROBE_SCRIPT, str(sandbox), str(REPO_PARENT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, (
f"probe co4e runs page that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
assert "PROBE_OK" in result.stdout, result.stdout
json_line = result.stdout.strip().splitlines()[-2]
data = json.loads(json_line)
data["_sandbox"] = str(sandbox)
return data
# ---------------------------------------------------------------------------
# runs_table: cau truc/co so mac dinh
# ---------------------------------------------------------------------------
def test_runs_table_has_5_columns_and_starts_empty(probe_result):
assert probe_result["col_count"] == 5
# quirk: _build_runs_page KHONG tu seed hang nao, du manager co run hay
# khong - seed la viec cua _refresh_runs (ngoai pham vi test nay).
assert probe_result["row_count"] == 0
def test_runs_table_is_parented_into_the_returned_widget(probe_result):
# DA CAP NHAT sau khi tach "Run Control" (xem
# docs/architecture/co4e-split-map-run-control.md): _build_runs_page gio
# tra ve mot RunsPagePanel (subclass QWidget dung trong presentation/co4e/
# co4e_run_control_widget.py) thay vi mot QWidget tran - van la mot QWidget
# that su (layout/table van nguyen), chi ten class cu the doi.
assert probe_result["w_type"] == "RunsPagePanel"
assert probe_result["layout_type"] == "QVBoxLayout"
assert probe_result["table_is_child_of_w"] is True
def test_runs_table_display_settings(probe_result):
assert probe_result["header_resize_mode_is_stretch"] is True
# KHONG dung "vheader_visible" (QWidget.isVisible()) o day: gia tri do
# luon False bat ke setVisible(True/False) trong code san pham, vi
# runs_table/verticalHeader khong bao gio duoc .show() thuc su trong luc
# probe (isVisible() phu thuoc CA chuoi ancestor co dang hien tren man
# hinh hay khong). Dung "vheader_visible_to_table" (isVisibleTo(ancestor))
# de bat dung co explicit hidden ma setVisible(False) dat len
# verticalHeader, khong phu thuoc runs_table co duoc show hay khong -
# mutation setVisible(False) -> True lam gia tri nay lat tu False len True.
assert probe_result["vheader_visible_to_table"] is False
assert probe_result["edit_triggers_is_no_edit"] is True
assert probe_result["selection_behavior_is_select_rows"] is True
assert probe_result["context_menu_policy_is_custom"] is True
def test_runs_table_tooltip_matches_tr_key(probe_result):
from cowork_local.i18n import tr
assert probe_result["table_tooltip"] == tr("co4e.tt_runs_list")
# ---------------------------------------------------------------------------
# runs_back_btn
# ---------------------------------------------------------------------------
def test_back_button_text_tooltip_and_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["back_btn_text"] == tr("co4e.back_to_flow")
assert probe_result["back_btn_tooltip"] == tr("co4e.tt_back_to_flow")
assert probe_result["back_icon_isnull"] is False
def test_back_button_click_calls_show_runs_with_false(probe_result):
# _build_runs_page noi runs_back_btn.clicked -> lambda: self._show_runs(False)
assert probe_result["calls_after_back_click"] == [False]
# ---------------------------------------------------------------------------
# runs_title
# ---------------------------------------------------------------------------
def test_runs_title_text_and_object_name(probe_result):
from cowork_local.i18n import tr
assert probe_result["title_text"] == tr("co4e.running_flows")
assert probe_result["title_object_name"] == "hint"
# ---------------------------------------------------------------------------
# ws_folder_btn
# ---------------------------------------------------------------------------
def test_ws_folder_button_is_flat_with_pointing_hand_cursor(probe_result):
assert probe_result["ws_folder_flat"] is True
assert probe_result["ws_folder_cursor_is_pointing_hand"] is True
assert probe_result["ws_folder_icon_isnull"] is False
def test_ws_folder_button_text_uses_ellipsis_and_forward_slash_regardless_of_os(
probe_result,
):
# quirk: "…/" (dau cham lung That, KHONG phai 3 dau cham thuong) roi
# noi 2 phan cuoi cua path bang "/" luon luon - kha nang fallback rong day
# du duoc kich hoat boi ctx.config gia lap khong co .config that.
assert probe_result["ws_folder_text"] == "…/runs/co4e"
def test_ws_folder_button_tooltip_embeds_the_fallback_root_path(probe_result):
from cowork_local.i18n import tr
root = str(Path(probe_result["config_dir"]) / "co4e" / "runs" / "co4e")
assert probe_result["ws_folder_tooltip"] == tr(
"co4e.tt_open_workspace", path=root
)
assert probe_result["flow_output_root_fallback"] == root
def test_ws_folder_button_click_calls_open_workspace_folder(probe_result):
assert probe_result["ws_open_calls_after_click"] == 1
def test_config_dir_is_isolated_inside_the_sandbox(probe_result):
# chot lai rang qua trinh dung widget khong lam chuyen sang HOME that.
assert probe_result["_sandbox"] in probe_result["config_dir"]
# ---------------------------------------------------------------------------
# run_stop_btn / run_rename_btn / run_del_btn / run_clear_btn
# ---------------------------------------------------------------------------
def test_stop_button_text_object_name_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["stop_btn_text"] == tr("co4e.stop")
assert probe_result["stop_btn_object_name"] == "danger"
assert probe_result["stop_btn_tooltip"] == tr("co4e.tt_stop_run")
assert probe_result["stop_btn_icon_isnull"] is False
def test_stop_button_click_calls_stop_selected_run(probe_result):
assert probe_result["stop_calls_after_click"] == 1
def test_rename_button_text_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["rename_btn_text"] == tr("co4e.rename_run")
assert probe_result["rename_btn_tooltip"] == tr("co4e.tt_rename_run")
assert probe_result["rename_btn_icon_isnull"] is False
def test_rename_button_click_calls_rename_selected_run(probe_result):
assert probe_result["rename_calls_after_click"] == 1
def test_delete_button_text_tooltip_icon(probe_result):
from cowork_local.i18n import tr
assert probe_result["del_btn_text"] == tr("co4e.delete_run")
assert probe_result["del_btn_tooltip"] == tr("co4e.tt_delete_run")
assert probe_result["del_btn_icon_isnull"] is False
def test_delete_button_click_calls_delete_selected_run(probe_result):
assert probe_result["delete_calls_after_click"] == 1
def test_clear_button_text_tooltip_and_no_object_name(probe_result):
from cowork_local.i18n import tr
assert probe_result["clear_btn_text"] == tr("co4e.clear_done")
assert probe_result["clear_btn_tooltip"] == tr("co4e.tt_clear_runs")
# quirk: khac 4 nut hang xom, run_clear_btn khong setObjectName.
assert probe_result["clear_btn_object_name"] == ""
def test_clear_button_has_no_icon_unlike_its_siblings(probe_result):
# quirk: run_clear_btn la nut DUY NHAT trong hang khong co setIcon(...).
assert probe_result["clear_btn_icon_isnull"] is True
def test_clear_button_click_calls_manager_clear_finished_directly(probe_result):
# quirk: noi THANG toi lambda: self.manager.clear_finished() - khong di
# qua mot method rieng tren self nhu 3 nut hang xom (stop/rename/delete).
assert probe_result["manager_clear_finished_calls_after_click"] == 1
# ---------------------------------------------------------------------------
# runs_table: double-click / context-menu signals
# ---------------------------------------------------------------------------
def test_double_click_on_a_row_calls_open_run_from_table(probe_result):
assert probe_result["open_run_calls_after_dbl_click"] == 1
def test_context_menu_request_calls_runs_context_menu(probe_result):
assert probe_result["ctx_menu_calls_after_signal"] == 1
# ---------------------------------------------------------------------------
# Lenh thu cong da dung de chot cac gia tri JSON o tren (quy trinh nguoc):
#
# .venv/Scripts/python.exe -c "<noi dung _PROBE_SCRIPT>" <sandbox> <repo_parent>
#
# voi <sandbox> la mot thu muc rong duoc gan vao HOME/USERPROFILE TRUOC khi
# script import bat ky thu gi thuoc cowork_local, va <repo_parent> la thu muc
# cha cua repo (de "import cowork_local" hoat dong dung nhu conftest.py lam).
# ---------------------------------------------------------------------------
@@ -0,0 +1,309 @@
"""Characterization test cho khu vực SKILLS trong sidebar của ``Co4ETab``
(``ui/co4e_tab.py``): ``sk_manage_btn``/``skill_list``/``_manage_skills`` (nối
click) và phần "populate skill_list" bên trong ``_reload_sidebar`` — đúng các
đoạn được giao: dòng 569-580 (dựng widget qua ``SkillsListPanel`` + nối
signal), 602 (vòng lặp ``setMinimumHeight(56)`` dùng CHUNG cho
``wf_list``/``agent_list``/``skill_list``/``runs_side_list``), 690-723
(``_reload_sidebar``, CHỈ đoạn skill) và 1370-1374 (``_manage_skills``).
VÌ SAO GHI LẠI CHỨ KHÔNG PHÁN XÉT: đây là lưới an toàn cho đợt tách
``ui/co4e_tab.py`` (2000+ dòng) thành các module con dưới ``presentation/co4e/``
(xem ``docs/architecture/co4e-split-map.md`` — khu vực SKILLS đã dời phần
DỰNG WIDGET sang ``presentation/co4e/skills_list_panel.py::SkillsListPanel``,
còn phần NỐI SIGNAL + populate vẫn ở ``Co4ETab``). Mọi ``assert`` dưới đây
được chốt lại từ giá trị THẬT in ra khi chạy code (quy trình ngược: chạy
trước, in ra, dán vào assert) — không phải giá trị tôi nghĩ nó "nên" là gì.
VÌ SAO CHẠY TRONG TIẾN TRÌNH CON CÔ LẬP HOME: giống hệt kỹ thuật của
``tests/test_build_co4e_tab.py`` (đọc docstring đầu file đó để thấy khuôn gốc)
và ``tests/characterization/test_co4e_agent_panel.py`` — dựng ``Co4ETab`` thật
kéo theo ``AppConfig``/``CONFIG_DIR`` (config.py) và ``SKILLS_DIR``
(core/skills.py) đều là hằng số module tính MỘT LẦN lúc import từ
``Path.home()``. Monkeypatch thuộc tính module SAU khi import không đủ (còn
``AppConfig.load()`` có tham số mặc định đóng băng lúc định nghĩa hàm — xem
``test_build_co4e_tab.py``), nên phải cô lập ``HOME``/``USERPROFILE`` TRƯỚC
bất kỳ import ``cowork_local.*`` nào, trong một tiến trình con sạch hoàn toàn.
AN TOÀN DỮ LIỆU: script con assert ``str(CONFIG_DIR).startswith(sandbox)``
NGAY sau khi import, trước khi ghi bất kỳ file skill nào xuống
``skills_mod.SKILLS_DIR`` (``CONFIG_DIR / "skills"``) — chạy nhầm trên máy
thật sẽ ghi/xoá skill thật của người dùng.
KHÔNG gọi provider AI thật / spawn thread thật: test này không đụng tới
``Co4ERunManager``/``AgentWorker``. ``SkillsDialog.exec()`` (modal, sẽ treo
tiến trình headless) được monkeypatch thành một lớp giả NGAY TRONG tiến trình
con của test — không sửa code sản phẩm; lớp giả chỉ đếm số lần được gọi và trả
về một giá trị falsy (giống bấm Cancel) để lộ ra quirk "luôn reload" bên dưới.
QUIRK ĐÃ ĐÓNG ĐINH (xem case tương ứng bên dưới):
* ``skill_list`` không lọc theo ``Skill.enabled`` — ``_skill_names()`` gọi
``list_skills() + builtin_skills()`` không quan tâm cờ ``enabled``, nên
một skill được TẠO nhưng chưa được người dùng tick bật (``enabled=False``)
vẫn xuất hiện trong danh sách kéo-thả của Co4E y hệt một skill đã bật.
Cờ ``enabled`` chỉ ảnh hưởng nơi khác (``active_skills_text`` cho chat),
không ảnh hưởng palette này.
* Payload kéo-thả của một skill KHÔNG chứa ``instructions`` thô của skill,
mà chứa nguyên khối trả về bởi ``skills_mod.skill_prefix_for(name)`` —
tức đã có tiền tố ``"## Skill: <tên>\\n"`` dán trước nội dung. Ai "dọn"
chỗ này để dùng thẳng ``skill.instructions`` sẽ làm mọi flow kéo-thả sẵn
mất dòng tiêu đề đó.
* Khi skill có ``instructions`` rỗng, ``skill_prefix_for`` trả về chuỗi rỗng
(không phải ``None``, không ném lỗi) — payload kéo-thả của skill đó có
``instructions == ""`` dù skill vẫn hiện trong danh sách với đúng tên.
* ``_manage_skills`` LUÔN gọi ``self._reload_sidebar()`` sau
``SkillsDialog(...).exec()``, bất kể dialog trả về gì (khác với
``_new_agent``/``_edit_agent`` ở khu vực AGENTS, nơi chỉ reload khi
``dlg.exec()`` truthy) — đóng Skills manager bằng Cancel/Esc vẫn khiến
``skill_list`` bị dựng lại từ đĩa.
* Thư mục skills không tồn tại (chưa từng tạo skill nào) không ném lỗi:
``_skill_names()`` có ``try/except`` bọc quanh, trả về ``[]`` một cách im
lặng — ``skill_list`` rỗng, không có thông báo lỗi nào cho người dùng.
VÒNG ĐỜI: đây là giàn giáo cho đợt tách phần NỐI SIGNAL + populate của khu vực
Skills sang ``presentation/co4e/`` (dựng widget đã tách xong thành
``SkillsListPanel`` — xem cột "Trạng thái" dòng liên quan trong
``docs/architecture/co4e-split-map.md``). Sau khi phần còn lại được tách (ví
dụ một ``SkillsListController`` biết ``_manage_skills``/populate mà không cần
biết toàn bộ ``Co4ETab``), các case ở đây nên viết lại thành test đặc tả cho
controller mới (input rõ ràng, không cần dựng cả ``Co4ETab``/``QApplication``
nặng nề qua subprocess). Quirk "payload dùng khối có tiền tố thay vì
instructions thô" và quirk "luôn reload dù Cancel" đáng mở issue hỏi ý kiến
sản phẩm trước khi ai đó "dọn" chúng trong lúc tách.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import json
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from PySide6.QtWidgets import QAbstractItemView, QApplication
from PySide6.QtCore import Qt
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.state import AppContext
from cowork_local.core import skills as skills_mod
from cowork_local.ui.co4e_tab import Co4ETab, _PaletteList, _skill_names
from cowork_local.ui.icons import icon as _icon
from cowork_local.i18n import tr
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(sandbox), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
ctx = AppContext(AppConfig.load())
tab = Co4ETab(ctx)
# ---- 569-580: widget cua khu vuc SKILLS duoc dung qua SkillsListPanel ------
assert tab.sk_manage_btn.text() == tr("co4e.manage_skills"), tab.sk_manage_btn.text()
assert tab.sk_manage_btn.toolTip() == tr("co4e.tt_manage_skills"), tab.sk_manage_btn.toolTip()
assert tab.sk_manage_btn.objectName() == "co4eSectionAction"
assert tab.sk_manage_btn.isFlat() is True
assert tab.sk_manage_btn.cursor().shape() == Qt.PointingHandCursor
assert isinstance(tab.skill_list, _PaletteList), type(tab.skill_list)
assert tab.skill_list.dragEnabled() is True
assert tab.skill_list.dragDropMode() == QAbstractItemView.DragOnly
print("CASE_WIDGETS_OK")
# ---- 602: bon danh sach dung CHUNG mot vong lap setMinimumHeight(56) -------
heights = {
"wf_list": tab.wf_list.minimumHeight(),
"agent_list": tab.agent_list.minimumHeight(),
"skill_list": tab.skill_list.minimumHeight(),
"runs_side_list": tab.runs_side_list.minimumHeight(),
}
assert heights == {
"wf_list": 56, "agent_list": 56, "skill_list": 56, "runs_side_list": 56,
}, heights
print("CASE_MIN_HEIGHT_SHARED_OK")
# ---- quirk: chua tao skill nao -> thu muc SKILLS_DIR khong ton tai ---------
# _skill_names() boc try/except quanh list_skills()/builtin_skills(); thu muc
# chua ton tai (Co4ETab.__init__ khong tu tao no) khong nem loi, tra ve rong.
assert not skills_mod.SKILLS_DIR.exists(), skills_mod.SKILLS_DIR
assert _skill_names() == []
tab._reload_sidebar()
assert tab.skill_list.count() == 0, tab.skill_list.count()
print("CASE_EMPTY_DIR_OK")
# ---- 690-723 (doan skill): mot skill co instructions, da bat enabled -------
skills_mod.SKILLS_DIR.mkdir(parents=True, exist_ok=True)
(skills_mod.SKILLS_DIR / "s1.json").write_text(json.dumps({
"name": "Viet test",
"description": "desc 1",
"instructions": "Luon viet test",
"enabled": True,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 1, tab.skill_list.count()
it0 = tab.skill_list.item(0)
assert it0.text() == "Viet test", it0.text()
assert it0.icon().isNull() is False
# Dong lo hong da bi mutation test bat duoc (xem docs/architecture/
# co4e-refactor-run-report.md muc 4, luot 3): doi icon_name truyen vao
# _palette_item(name, "sparkle", payload) tu "sparkle" sang "robot" o
# ui/co4e_tab.py (dong _reload_sidebar, khu vuc SKILLS) truoc day KHONG bi bat,
# vi assert cu chi kiem "co icon" (isNull() is False) ma khong kiem la icon NAO.
# So sanh pixmap that (icon() la ham thuan, cung name/size/color -> cung anh)
# thay vi so QIcon truc tiep (QIcon khong dinh nghia __eq__ theo noi dung).
assert it0.icon().pixmap(16, 16).toImage() == _icon("sparkle").pixmap(16, 16).toImage(), (
"icon cua skill trong palette phai dung 'sparkle' (_palette_item(name, 'sparkle', payload))"
)
payload0 = it0.data(Qt.UserRole)
assert payload0 == {
"variant": "step", "label": "Viet test", "agent_slug": "viet-test",
"role": "SKILL", "icon": "sparkle",
"instructions": "## Skill: Viet test\nLuon viet test",
"context": "", "model": "", "self_verify": True, "max_verify_rounds": 1,
"permission_preset": "full", "skills": ["Viet test"], "attachments": [],
"sub_agents": [],
}, payload0
assert it0.data(Qt.UserRole + 1) is None
print("CASE_ONE_SKILL_OK")
# ---- quirk: skill CHUA duoc bat (enabled=False) van hien trong palette -----
# list_skills()/builtin_skills() khong loc theo enabled - _skill_names() lay
# ca hai, nen mot skill "tat" van keo-tha duoc tu Co4E y het skill "bat".
(skills_mod.SKILLS_DIR / "s2-disabled.json").write_text(json.dumps({
"name": "Chua bat",
"description": "",
"instructions": "Noi dung chua bat",
"enabled": False,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 2, tab.skill_list.count()
names = [tab.skill_list.item(i).text() for i in range(tab.skill_list.count())]
assert names == ["Viet test", "Chua bat"], names
it1 = tab.skill_list.item(1)
payload1 = it1.data(Qt.UserRole)
assert payload1["instructions"] == "## Skill: Chua bat\nNoi dung chua bat", payload1
print("CASE_DISABLED_SKILL_STILL_SHOWN_QUIRK_OK")
# ---- quirk: skill co instructions RONG -> payload instructions == "" ------
# skill_prefix_for() tra ve "" khi instructions rong (khong None, khong loi);
# skill van hien dung ten trong danh sach.
(skills_mod.SKILLS_DIR / "s3-empty-instr.json").write_text(json.dumps({
"name": "Rong noi dung",
"description": "",
"instructions": "",
"enabled": True,
}), encoding="utf-8")
tab._reload_sidebar()
assert tab.skill_list.count() == 3, tab.skill_list.count()
it2 = tab.skill_list.item(2)
assert it2.text() == "Rong noi dung", it2.text()
payload2 = it2.data(Qt.UserRole)
assert payload2["instructions"] == "", payload2
assert payload2["skills"] == ["Rong noi dung"], payload2
print("CASE_EMPTY_INSTRUCTIONS_QUIRK_OK")
# ---- xoa het skill roi reload lai -> khong con dong nao (khong con sot) ----
for p in skills_mod.SKILLS_DIR.glob("*.json"):
p.unlink()
tab._reload_sidebar()
assert tab.skill_list.count() == 0, tab.skill_list.count()
print("CASE_CLEAR_ON_RELOAD_OK")
# ---- 1370-1374: _manage_skills() mo SkillsDialog(self, self.ctx) -----------
import cowork_local.ui.skills_dialog as skills_dialog_mod
dialog_calls = []
class _FakeSkillsDialog:
def __init__(self, parent, ctx_arg):
dialog_calls.append((parent is tab, ctx_arg is ctx))
def exec(self):
return 0 # falsy, gia lap bam Cancel/Esc
orig_dialog_cls = skills_dialog_mod.SkillsDialog
skills_dialog_mod.SkillsDialog = _FakeSkillsDialog
reload_calls = {"n": 0}
orig_reload = tab._reload_sidebar
def _counting_reload():
reload_calls["n"] += 1
return orig_reload()
tab._reload_sidebar = _counting_reload
tab._manage_skills()
skills_dialog_mod.SkillsDialog = orig_dialog_cls
tab._reload_sidebar = orig_reload
assert dialog_calls == [(True, True)], dialog_calls
# quirk: dialog tra ve gia tri falsy (Cancel) nhung sidebar VAN duoc reload.
assert reload_calls["n"] == 1, reload_calls
print("CASE_MANAGE_SKILLS_ALWAYS_RELOADS_QUIRK_OK")
# ---- 573: sk_manage_btn.clicked noi thang toi _manage_skills ---------------
recorded = []
tab._manage_skills = lambda: recorded.append(1)
tab.sk_manage_btn.click()
assert recorded == [1], recorded
print("CASE_CLICK_WIRES_TO_MANAGE_SKILLS_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_co4e_skills_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_WIDGETS_OK",
"CASE_MIN_HEIGHT_SHARED_OK",
"CASE_EMPTY_DIR_OK",
"CASE_ONE_SKILL_OK",
"CASE_DISABLED_SKILL_STILL_SHOWN_QUIRK_OK",
"CASE_EMPTY_INSTRUCTIONS_QUIRK_OK",
"CASE_CLEAR_ON_RELOAD_OK",
"CASE_MANAGE_SKILLS_ALWAYS_RELOADS_QUIRK_OK",
"CASE_CLICK_WIRES_TO_MANAGE_SKILLS_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi cac file skill test tu tao xuong SKILLS_DIR
# (khong co du lieu nguoi dung that nao bi cham vao) - CONFIG_DIR nam
# trong sandbox nhu da assert ngay dau script.
assert (sandbox / ".cowork_local").exists()
@@ -0,0 +1,463 @@
"""Characterization test cho ``StepConfigPanel`` (``ui/co4e_config_panel.py``,
dong 133-528) — KHONG bao gom ``_SectionHeader``/``_add_section`` (khung UI
chung, khong co hanh vi nghiep vu rieng dang characterize).
VI SAO GHI LAI CHU KHONG PHAN XET: day la luoi an toan cho dot tach
``StepConfigPanel`` ra khoi ``ui/co4e_config_panel.py`` sang cac module con
duoi ``presentation/co4e/`` (vi du ``node_property_panel.py``,
``node_property_actions_mixin.py``, ``step_config_section.py`` — xem
``docs/architecture/co4e-split-map-node-property.md``). Moi ``assert`` duoi
day duoc chot lai tu gia tri THAT in ra khi chay code that (quy trinh nguoc:
chay truoc, in ra, dan vao assert) — khong phai gia tri "nen" la gi theo suy
doan.
VI SAO CAN TIEN TRINH CON + CO LAP HOME: ban than ``StepConfigPanel.__init__``/
``load_step`` KHONG cham dia/mang, nhung ``_available_agent_names()`` (goi tu
``_add_subagent``/``_edit_subagent``) doc THAT tu
``core.co4e.list_custom_agents()`` duoi ``CONFIG_DIR/co4e/agents`` — mot
hang so module tinh MOT LAN tu ``Path.home()`` luc import. Vi vay HOME/
USERPROFILE phai duoc tro sang thu muc tam TRUOC BAT KY import
``cowork_local.*`` nao, trong mot tien trinh con sach (patch thuoc tinh sau
khi import la khong du) — dung khuon ``tools/capture_screens.py::_isolate_home``
va ``tests/characterization/test_co4e_agent_panel.py``. Tien trinh con rieng
cung tranh xung dot QApplication singleton neu mot tien trinh pytest khac da
tao QApplication trong cung luot chay. ``QT_QPA_PLATFORM=offscreen`` duoc dat
TRUOC khi import PySide6 (dung khuon ``tools/check_co4e.py`` dong 22+40-42);
script con assert ngay ``str(CONFIG_DIR).startswith(sandbox)`` truoc khi goi
bat ky ham co4e nao (giong ``tools/check_co4e.py:47``) — khong co lap la doc
(va co the ghi) du lieu that cua nguoi dung qua ``list_custom_agents()``/
``save_custom_agent()``.
KHONG goi provider AI that / spawn thread that: ``_ai_draft()`` va
``_load_models()`` (dung ``AgentWorker``/``QThread`` that) KHONG duoc goi o
bat ky case nao trong file nay — hai ham do nam NGOAI pham vi duoc giao
(139-462) va can duoc characterize rieng, tach biet, voi worker/thread duoc
gia lap chu khong start that.
QUIRK DA DONG DINH (xem case tuong ung ben duoi):
* ``gen_btn``/``load_models_btn`` duoc set ``setEnabled(ctx is not None)``
ngay trong ``__init__`` (dong 181, 208), NHUNG dong 313 goi
``self.setEnabled(False)`` cho CA PANEL o cuoi ``__init__`` — vi Qt tinh
``isEnabled()`` hieu qua tu ca chuoi ancestor, ca hai nut deu tra ve
``False`` NGAY SAU KHI DUNG XONG bat ke ``ctx`` la gi, cho toi khi
``load_step()`` (hoac ``setEnabled(True)`` truc tiep) bat lai ca panel.
Doc rieng dong 181/208 se de tuong "ctx=None thi nut luon tat, ctx khac
None thi nut luon bat" — sai, ca hai deu tat cho den khi co step duoc nap.
* ``rounds_spin`` = ``QSpinBox(range=1..5)`` nhung code goi
``setValue(max(1, step.max_verify_rounds))`` (dong 330) — voi
``max_verify_rounds=10``, ``max(1, 10) == 10`` nhung ``QSpinBox`` tu kep
ve tran cua no nen gia tri hien thi la ``5``, khong phai ``10``. Doc code
ma khong chay se tuong gia tri duoc giu nguyen.
* ``perm_combo`` voi ``step.permission_preset`` khong nam trong
``PERMISSION_PRESETS`` (nhanh fallback cua ``findData`` tra ve -1) roi ve
index 0 == ``"inherit"`` — im lang, khong bao loi.
* ``load_step`` chi dung skill_names de dung skills_list; mot skill trong
``step.skills`` khong con trong ``skill_names`` (skill da bi xoa khoi
registry) don gian BIEN MAT khoi checklist — va khi ``_on_edit`` chay lan
ke tiep (do BAT KY thay doi field nao khac, khong can dung vao
skills_list), ``s.skills`` duoc GHI DE lai chi bang cac item dang hien
trong checklist, nen ten skill "mo coi" do bi RUNG VINH VIEN khoi
``step.skills`` — mot edit khong lien quan (vi du sua role) am tham xoa
du lieu skill cu.
* ``variant != "parallel"``: ``_parallel_card`` bi an VA ``sub_list.clear()``
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
— du liệu van con trong step, chi khong hien tren UI.
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song.
* ``_edit_subagent``: neu agent hien tai cua sub-agent khong con trong danh
sach ten kha dung (``cur not in names``), dialog mo tai index 0 thay vi
bao loi hay giu nguyen lua chon cu.
VONG DOI: day la gian giao cho dot tach ``StepConfigPanel`` sang
``presentation/co4e/`` (xem ``docs/architecture/co4e-split-map-node-property.md``
cho ke hoach tach cu the). Sau khi tach xong thanh cac lop/mixin voi hop dong
ro rang, cac case o day nen duoc viet lai thanh test dac ta cho tung phan
(khong can dung ca ``QApplication``/tien trinh con neu phan tach ra la logic
thuan). Ba quirk "panel disabled de len enable cua nut", "orphan skill bi xoa
qua mot edit khong lien quan" va "sub_agents khong dong bo voi sub_list khi
doi variant" dang mo issue hoi y kien san pham truoc khi ai do "don" chung
trong luc tach — dac biet quirk orphan-skill, vi day la mot dang mat du lieu
tham lang de bi coi la bug can sua ngay khi gap lai, nhung sua no thay doi
hanh vi luu tru hien co ma khong ai ro co ai dang phu thuoc vao khong.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
REPO_PARENT = REPO_ROOT.parent
_SCRIPT = r"""
import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from pathlib import Path as _PP
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem
from PySide6.QtCore import Qt
from cowork_local.config import CONFIG_DIR
sandbox = sys.argv[1]
assert str(CONFIG_DIR).startswith(str(_PP(sandbox))), "khong co lap: CONFIG_DIR=" + str(CONFIG_DIR)
app = QApplication([])
from cowork_local.ui.co4e_config_panel import StepConfigPanel
from cowork_local.core.co4e import Step, SubAgent, PERMISSION_PRESETS
from cowork_local.core import co4e as co4e_mod
from cowork_local.core.co4e_builtins import BUILTIN_AGENTS
# ---- __init__ (139-313) ----------------------------------------------------
p = StepConfigPanel()
assert p.gen_btn.isEnabled() is False
assert p.load_models_btn.isEnabled() is False
assert p.isEnabled() is False, "panel bat dau bi setEnabled(False) o dong 313"
assert p.perm_combo.count() == len(PERMISSION_PRESETS) == 4
assert [p.perm_combo.itemData(i) for i in range(4)] == list(PERMISSION_PRESETS)
assert (p.rounds_spin.minimum(), p.rounds_spin.maximum()) == (1, 5)
print("CASE_INIT_DEFAULT_CTX_NONE_OK")
# quirk: gen_btn/load_models_btn duoc set theo ctx, nhung setEnabled(False)
# cho CA PANEL o cuoi __init__ de len tren ca hai -> ca hai deu False cho den
# khi mot cai gi do bat lai ca panel (vd load_step() goi setEnabled(True)).
p_ctx = StepConfigPanel(ctx=object())
assert p_ctx.gen_btn.isEnabled() is False, "quirk: panel disabled de len len ctx!=None"
assert p_ctx.load_models_btn.isEnabled() is False
p_ctx.setEnabled(True) # mo phong dieu load_step() lam
assert p_ctx.gen_btn.isEnabled() is True
assert p_ctx.load_models_btn.isEnabled() is True
p_none = StepConfigPanel(ctx=None)
p_none.setEnabled(True)
assert p_none.gen_btn.isEnabled() is False, "ctx=None -> nut van tat sau khi panel duoc bat"
assert p_none.load_models_btn.isEnabled() is False
print("CASE_INIT_CTX_ENABLE_QUIRK_OK")
p.show()
app.processEvents()
assert p._parallel_card.isVisible() is True, "form4/_parallel_card khong tu an luc dung"
print("CASE_INIT_PARALLEL_CARD_DEFAULT_VISIBLE_OK")
# ---- load_step (316-354): step "step" thuong -------------------------------
step = Step(
variant="step", label="My Step", role="worker", icon="file",
instructions="Do X", context="bg info", model="gpt-4",
permission_preset="standard", self_verify=False, max_verify_rounds=3,
skills=["Test Skill", "OrphanSkill"], attachments=["C:/foo/bar/baz.txt", "note.md"],
sub_agents=[SubAgent(agent="Ghost")],
)
p.load_step("n1", step, ["Test Skill", "Other Skill"])
assert p.label_edit.text() == "My Step"
assert p.role_edit.text() == "worker"
assert p.icon_edit.currentText() == "file"
assert p.instructions_edit.toPlainText() == "Do X"
assert p.context_edit.toPlainText() == "bg info"
assert p.model_combo.currentText() == "gpt-4"
assert (p.perm_combo.currentIndex(), p.perm_combo.currentData()) == (2, "standard")
assert p.verify_chk.isChecked() is False
assert p.rounds_spin.value() == 3
assert p.skills_list.count() == 2, "chi 2 muc trong skill_names duoc ve, OrphanSkill khong co hang"
assert [p.skills_list.item(i).text() for i in range(2)] == ["Test Skill", "Other Skill"]
assert p.skills_list.item(0).checkState() == Qt.Checked
assert p.skills_list.item(1).checkState() == Qt.Unchecked
assert p.attach_list.count() == 2
assert p.attach_list.item(0).text() == "baz.txt"
assert p.attach_list.item(0).toolTip() == "C:/foo/bar/baz.txt"
assert p.attach_list.item(1).text() == "note.md"
app.processEvents()
assert p._parallel_card.isVisible() is False, "variant='step' -> card an"
assert p.sub_list.count() == 0, "quirk: sub_agents khong rong nhung khong dong bo vao UI khi khong phai parallel"
assert step.sub_agents == [SubAgent(agent="Ghost")], "du lieu step khong bi dong cham, chi UI khong ve"
print("CASE_LOAD_STEP_BASIC_OK")
# quirk: rounds_spin.setValue(max(1, n)) nhung QSpinBox tu kep tran o 5.
p.load_step("n0", Step(max_verify_rounds=0), [])
assert p.rounds_spin.value() == 1
p.load_step("nneg", Step(max_verify_rounds=-5), [])
assert p.rounds_spin.value() == 1
p.load_step("n10", Step(max_verify_rounds=10), [])
assert p.rounds_spin.value() == 5, "quirk: max(1,10)=10 nhung QSpinBox kep ve tran 5"
print("CASE_LOAD_STEP_ROUNDS_CLAMP_QUIRK_OK")
# quirk: permission_preset la khoa khong ton tai -> fallback ve index 0 (inherit)
p.load_step("nbad", Step(permission_preset="does-not-exist"), [])
assert (p.perm_combo.currentIndex(), p.perm_combo.currentData()) == (0, "inherit")
print("CASE_LOAD_STEP_UNKNOWN_PRESET_FALLBACK_OK")
# variant="parallel" -> card hien, sub_list duoc ve tu sub_agents
steppar = Step(variant="parallel", sub_agents=[SubAgent(agent="A1"), SubAgent(agent="A2")])
p.load_step("npar", steppar, [])
app.processEvents()
assert p._parallel_card.isVisible() is True
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["A1", "A2"]
print("CASE_LOAD_STEP_PARALLEL_OK")
# rong: skills/attachments rong -> list rong, khong loi
p.load_step("nempty", Step(), [])
assert p.skills_list.count() == 0
assert p.attach_list.count() == 0
print("CASE_LOAD_STEP_EMPTY_OK")
# ---- clear_step (356-359) ---------------------------------------------------
p.clear_step()
assert p._step is None
assert p._node_id == ""
assert p.isEnabled() is False
print("CASE_CLEAR_STEP_OK")
# _on_edit voi _step None -> khong loi, khong lam gi
p._on_edit()
print("CASE_ON_EDIT_NO_STEP_NOOP_OK")
# ---- _on_edit (362-378): guard _loading, fallback role, quirk orphan-skill --
p.load_step("npar2", step, ["Test Skill", "Other Skill"])
assert step.skills == ["Test Skill", "OrphanSkill"], "chua edit gi thi step chua bi dong den"
p.role_edit.setText("") # -> _on_edit tu dong chay qua signal that, khong mock
assert step.role == "AGENT", "role rong -> fallback 'AGENT' (dong 367)"
assert step.skills == ["Test Skill"], (
"quirk: mot edit KHONG LIEN QUAN (sua role) cung ghi de s.skills bang "
"danh sach dang checked trong UI -> OrphanSkill bi rung vinh vien"
)
print("CASE_ON_EDIT_ROLE_FALLBACK_AND_ORPHAN_SKILL_QUIRK_OK")
p._loading = True
step.role = "UNTOUCHED"
p.role_edit.setText("Something Else")
assert step.role == "UNTOUCHED", "guard _loading chan _on_edit khong ghi lai step"
p._loading = False
print("CASE_ON_EDIT_LOADING_GUARD_OK")
p.role_edit.setText("scout")
assert step.role == "SCOUT", "role duoc upper() hoa (dong 367)"
print("CASE_ON_EDIT_ROLE_UPPERCASE_OK")
# ---- _available_agent_names (380-390, static) ------------------------------
names0 = StepConfigPanel._available_agent_names()
assert names0 == [a.name for a in BUILTIN_AGENTS], "khong co custom agent -> chi builtin, dung thu tu"
assert names0[0] == "Business Analyst"
assert len(names0) == len(BUILTIN_AGENTS) == 19
print("CASE_AVAILABLE_NAMES_NO_CUSTOM_OK")
custom = co4e_mod.new_custom_agent("Zed Custom")
co4e_mod.save_custom_agent(custom)
names1 = StepConfigPanel._available_agent_names()
assert names1[0] == "Zed Custom", "custom agent dung TRUOC builtin"
assert len(names1) == len(BUILTIN_AGENTS) + 1
print("CASE_AVAILABLE_NAMES_ONE_CUSTOM_OK")
# quirk: custom trung ten voi mot builtin -> chi giu 1 lan (dong 389: "not in names")
dup = co4e_mod.new_custom_agent(BUILTIN_AGENTS[0].name)
co4e_mod.save_custom_agent(dup)
names2 = StepConfigPanel._available_agent_names()
assert names2.count(BUILTIN_AGENTS[0].name) == 1, "trung ten voi builtin bi loai bo, khong nhan doi"
assert names2[0] == "Zed Custom"
print("CASE_AVAILABLE_NAMES_DEDUPE_WITH_BUILTIN_OK")
# ---- _add_subagent (392-408) ------------------------------------------------
pstep = Step(variant="parallel")
p.load_step("nsub", pstep, [])
p._step = None
before_n = p.sub_list.count()
p._add_subagent()
assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
p._step = pstep
orig_getItem = QInputDialog.getItem
orig_getText = QInputDialog.getText
changed_n = {"n": 0}
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
assert changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
assert changed_n["n"] == 1, "khong them thi khong emit changed"
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
# quirk: khi _available_agent_names() rong -> dung getText thay vi getItem (dong 401-402)
# GHI CHU DOT TACH mixin (khong doi hanh vi, chi doi CACH patch/restore trong
# test): sau khi StepConfigPanel._available_agent_names duoc dua vao mixin
# rieng (_StepConfigActionsMixin), no khong con nam trong
# StepConfigPanel.__dict__ nua (ma nam trong __dict__ cua mixin, StepConfigPanel
# chi ke thua qua MRO) nen "StepConfigPanel.__dict__['_available_agent_names']"
# nem KeyError. Gan de ghi de truc tiep len StepConfigPanel (nhu dong duoi) van
# shadow dung nhu truoc; khi xong chi can `del` thuoc tinh do khoi
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
StepConfigPanel._available_agent_names = staticmethod(lambda: [])
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True))
p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem
QInputDialog.getText = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) -----------------------------------------------
pstep2 = Step(variant="parallel", sub_agents=[SubAgent(agent="Unknown Agent XYZ")])
p.load_step("nedit", pstep2, [])
captured = {}
def _fake_get_item(*a, **k):
captured["items"] = a[3]
captured["current_index"] = a[4]
return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item)
item0 = p.sub_list.item(0)
p._edit_subagent(item0)
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
assert pstep2.sub_agents[0].agent == "Renamed"
assert item0.text() == "Renamed"
print("CASE_EDIT_SUBAGENT_UNKNOWN_CUR_STARTS_AT_0_OK")
foreign_item = QListWidgetItem("not in list")
snapshot = dict(captured)
p._edit_subagent(foreign_item)
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
QInputDialog.getItem = orig_getItem
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
# ---- _del_subagent (430-437) -------------------------------------------------
pstep3 = Step(variant="parallel", sub_agents=[SubAgent(agent="A"), SubAgent(agent="B")])
p.load_step("ndel", pstep3, [])
p.sub_list.setCurrentRow(-1)
p._del_subagent()
assert [s.agent for s in pstep3.sub_agents] == ["A", "B"], "khong chon dong nao -> no-op"
p.sub_list.setCurrentRow(0)
p._del_subagent()
assert [s.agent for s in pstep3.sub_agents] == ["B"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["B"]
print("CASE_DEL_SUBAGENT_OK")
# ---- _add_attachment (439-453) -----------------------------------------------
astep = Step(attachments=["already/here.txt"])
p.load_step("natt", astep, [])
orig_getOpenFileNames = QFileDialog.getOpenFileNames
QFileDialog.getOpenFileNames = staticmethod(
lambda *a, **k: (["already/here.txt", "new/one.txt", ""], "")
)
att_changed = {"n": 0}
p.changed.connect(lambda: att_changed.__setitem__("n", att_changed["n"] + 1))
p._add_attachment()
assert astep.attachments == ["already/here.txt", "new/one.txt"], (
"duplicate bi loai (dong 447), chuoi rong bi loai boi 'if f and ...'"
)
assert p.attach_list.count() == 2
print("CASE_ADD_ATTACHMENT_DEDUPE_AND_SKIP_EMPTY_OK")
before_att_changed = att_changed["n"]
QFileDialog.getOpenFileNames = staticmethod(lambda *a, **k: ([], ""))
p._add_attachment()
assert att_changed["n"] == before_att_changed, "files rong -> khong emit changed (dong 452-453)"
QFileDialog.getOpenFileNames = orig_getOpenFileNames
print("CASE_ADD_ATTACHMENT_EMPTY_FILES_NO_CHANGED_OK")
p._step = None
before_att_count = p.attach_list.count()
p._add_attachment()
assert p.attach_list.count() == before_att_count, "_step None -> no-op"
p._step = astep
print("CASE_ADD_ATTACHMENT_NO_STEP_NOOP_OK")
# ---- _del_attachment (455-462) -----------------------------------------------
p.attach_list.setCurrentRow(-1)
p._del_attachment()
assert astep.attachments == ["already/here.txt", "new/one.txt"], "khong chon dong -> no-op"
p.attach_list.setCurrentRow(0)
p._del_attachment()
assert astep.attachments == ["new/one.txt"]
assert p.attach_list.count() == 1
print("CASE_DEL_ATTACHMENT_OK")
print("ALL_OK")
"""
def _run_isolated(sandbox: Path) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HOME"] = str(sandbox)
env["USERPROFILE"] = str(sandbox)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONIOENCODING"] = "utf-8"
env.pop("HOMEDRIVE", None)
env.pop("HOMEPATH", None)
script = _SCRIPT.replace("REPO_PARENT_PLACEHOLDER", repr(str(REPO_PARENT)))
return subprocess.run(
[sys.executable, "-c", script, str(sandbox)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_step_config_panel_hanh_vi_hien_tai(tmp_path):
sandbox = tmp_path / "home"
sandbox.mkdir()
result = _run_isolated(sandbox)
assert result.returncode == 0, (
f"characterization script that bai (exit {result.returncode}):\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
)
for marker in (
"CASE_INIT_DEFAULT_CTX_NONE_OK",
"CASE_INIT_CTX_ENABLE_QUIRK_OK",
"CASE_INIT_PARALLEL_CARD_DEFAULT_VISIBLE_OK",
"CASE_LOAD_STEP_BASIC_OK",
"CASE_LOAD_STEP_ROUNDS_CLAMP_QUIRK_OK",
"CASE_LOAD_STEP_UNKNOWN_PRESET_FALLBACK_OK",
"CASE_LOAD_STEP_PARALLEL_OK",
"CASE_LOAD_STEP_EMPTY_OK",
"CASE_CLEAR_STEP_OK",
"CASE_ON_EDIT_NO_STEP_NOOP_OK",
"CASE_ON_EDIT_ROLE_FALLBACK_AND_ORPHAN_SKILL_QUIRK_OK",
"CASE_ON_EDIT_LOADING_GUARD_OK",
"CASE_ON_EDIT_ROLE_UPPERCASE_OK",
"CASE_AVAILABLE_NAMES_NO_CUSTOM_OK",
"CASE_AVAILABLE_NAMES_ONE_CUSTOM_OK",
"CASE_AVAILABLE_NAMES_DEDUPE_WITH_BUILTIN_OK",
"CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK",
"CASE_ADD_SUBAGENT_PICKED_OK",
"CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK",
"CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK",
"CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK",
"CASE_EDIT_SUBAGENT_UNKNOWN_CUR_STARTS_AT_0_OK",
"CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK",
"CASE_DEL_SUBAGENT_OK",
"CASE_ADD_ATTACHMENT_DEDUPE_AND_SKIP_EMPTY_OK",
"CASE_ADD_ATTACHMENT_EMPTY_FILES_NO_CHANGED_OK",
"CASE_ADD_ATTACHMENT_NO_STEP_NOOP_OK",
"CASE_DEL_ATTACHMENT_OK",
"ALL_OK",
):
assert marker in result.stdout, f"thieu marker {marker}\n{result.stdout}"
# Sandbox chi duoc dung boi save_custom_agent() cua chinh test (khong co
# du lieu nguoi dung that nao bi cham vao) - CONFIG_DIR nam trong sandbox
# nhu da assert ngay dau script.
assert (sandbox / ".cowork_local" / "co4e").exists()