Files
cowork-local/tests/characterization/test_co4e_canvas_widget.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

843 lines
38 KiB
Python

"""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).
# ---------------------------------------------------------------------------