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:
@@ -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()
|
||||
Reference in New Issue
Block a user