Compare commits

...
Author SHA1 Message Date
vudt15andClaude Sonnet 5 f6c196ca87 fix(graphrag): crash on project rename/path change (DF-009)
session_events.py::_on_projects_changed (runs on every project
create/rename/delete) called self.structure._refresh_project_combo() —
but that method only ever existed on GraphRenderer, not on
StructureGraphView itself (self.structure). Renaming a project or
changing its working-directory path raised AttributeError and crashed
the app.

Add StructureGraphView.refresh_project_list(), matching the existing
schedule_rescan/set_project/prewarm forwarding pattern, and call that
instead. Add a regression test
(test_refresh_project_list_forwards_to_renderer).

Logged as DF-009 in Task Tracking Template.xlsx — root cause, fix and
regression test recorded (row was empty in this local copy despite
being reported as already logged there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:46:33 +09:00
vudt15andClaude Sonnet 5 a20c7a2d69 docs(qa): log DF-007 investigation — feature already shipped, no fix needed
CI / test (pull_request) Canceled after 0s
Khảo sát cho thấy DF-007 (chọn thư mục làm việc là link cloud
OneDrive/SharePoint thay vì chỉ local) đã được xây dựng đầy đủ từ
commit f9f6bc0, trước cả đợt fix df002/df011. Không sửa code — chỉ
ghi lại bằng chứng vào Task Tracking Template.xlsx và đề nghị QA test
lại trên bản build mới nhất.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:19:02 +09:00
vudt15andClaude Sonnet 5 4f88171c64 fix(qa): resolve DF-002/DF-003/DF-006, log Defect Management sheet
DF-002 (Co4E): node đã chạy xong (STEP_DONE) vẫn bị khoá edit — chỉ khoá
khi node đang thực sự chạy (STEP_RUNNING). Phần "status sai khi back
tab" đã được xử lý từ trước (2a5ee29/13e2c22), xác minh vẫn nguyên vẹn.

DF-003: run.bat chỉ ẩn console ngay trước lúc chạy app, nên các bước
kiểm tra (Python/thư viện/junction) phía trước vẫn hiện console một
chút — ẩn ngay từ đầu file, hiện lại đúng lúc báo lỗi.

DF-006 (Monitoring): options số dòng/trang đổi thành 5/10/20/50/100
(mặc định 20); EventTable giờ phân trang thật (next/prev, page_count)
thay vì chỉ cắt top-N rồi bỏ phần còn lại.

Thêm tests/test_co4e_edit_lock.py (chưa có test nào phủ hành vi khoá
edit trước đó); viết lại tests/test_monitoring_page_size.py cho đúng
options/phân trang mới.

Cập nhật Task Tracking Template.xlsx — điền Bước tái hiện/Root Cause/
Cách xử lý/Regression Test/Evidence cho DF-002, DF-003, DF-005, DF-006
và dòng "Cowork/GraphRAG chỉ hiện khi chọn project" (qua Excel COM, để
không làm mất shape/data-validation nâng cao khác trong file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:07:13 +09:00
16 changed files with 301 additions and 56 deletions
Binary file not shown.
+6
View File
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.page_size_label": {
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
"monitoring.page_indicator": {
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
"monitoring.page_prev": {
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
"monitoring.page_next": {
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
"monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"},
+4 -4
View File
@@ -10,13 +10,13 @@ from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QMenu
from ...core import co4e
from ...core.co4e import STEP_DONE, STEP_RUNNING
from ...core.co4e import STEP_RUNNING
from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
class Co4EWorkflowCrudMixin:
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
vẫn sửa lại được bình thường.
"""
for n in self.canvas.nodes():
if n.id == node_id:
+5 -5
View File
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
def set_locked(self, locked: bool) -> None:
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
chỉ ô nhập liệu bị khoá, không phải cả panel.
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
phải cả panel.
"""
self._locked = locked
editable = not locked
@@ -219,6 +219,12 @@ class StructureGraphView(QWidget):
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
self.renderer.prewarm()
def refresh_project_list(self) -> None:
"""Project khác vừa được tạo/sửa/xoá/đổi tên — làm mới danh sách trong
bộ chọn project của renderer (bộ chọn KHÔNG tự nạp lại khi project
thay đổi ở màn khác, chỉ khi ``set_project`` được gọi)."""
self.renderer._refresh_project_combo()
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
+3 -2
View File
@@ -39,8 +39,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable already caps display at 300 rows — see
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
# rendering (EventTable paginates client-side, 5-100 rows/page — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
# read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30
+55 -11
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
_DEFAULT_PAGE_SIZE = 20
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
class _TimeItem(QTableWidgetItem):
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
"secret_in_output": "warning",
}
# Emitted whenever the rendered page changes (new data, page-size change,
# or prev/next navigation) — args are (current_page, page_count), both
# 1-based-friendly in that current_page is 0-indexed but page_count is a
# plain count. filter_scaffold.py's pager label/buttons listen to this.
page_changed = Signal(int, int)
def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát.
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ.
"""
self._show_result = show_result
self._page_size = _MAX_ROWS
self._page_size = _DEFAULT_PAGE_SIZE
self._current_page = 0
self._last_events: List[dict] = []
self._sorted_events: List[dict] = []
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
"""Số dòng đang hiển thị mỗi trang."""
return self._page_size
def page_count(self) -> int:
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
if not self._sorted_events:
return 1
return -(-len(self._sorted_events) // self._page_size) # ceil div
def current_page(self) -> int:
"""Trang đang hiển thị, đánh số từ 0."""
return self._current_page
def go_to_page(self, page: int) -> None:
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
self._current_page = page
self._render_current_page()
def next_page(self) -> None:
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
self.go_to_page(self._current_page + 1)
def prev_page(self) -> None:
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
self.go_to_page(self._current_page - 1)
def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
self._page_size = n
self.set_events(self._last_events)
self._current_page = 0
self._render_current_page()
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
trước (DF-006)."""
self._last_events = events
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
self._current_page = 0
self._render_current_page()
def _render_current_page(self) -> None:
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
từ ``self._sorted_events`` đã sắp sẵn.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai.
"""
self._last_events = events
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
start = self._current_page * self._page_size
events = self._sorted_events[start:start + self._page_size]
self.setSortingEnabled(False)
self.setRowCount(len(events))
for row, ev in enumerate(events):
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
self.setItem(row, col, item)
self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
@@ -95,20 +95,52 @@ def build_filter_scaffold(
if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a
# page size internally (default 300), this just makes that
# number visible AND user-choosable instead of a fixed constant.
# page size internally, this just makes that number visible AND
# user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n)
current = table.page_size()
page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl)
row.addWidget(page_size_combo)
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
# DF-006 follow-up: trimming to a page size alone silently dropped
# every row past it with no way back to see them — prev/next
# buttons plus a "trang X/Y" indicator make the rest reachable.
page_prev_btn = QPushButton()
page_prev_btn.setIcon(icon("chevron-left"))
page_prev_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_prev_btn, "monitoring.page_prev")
page_next_btn = QPushButton()
page_next_btn.setIcon(icon("chevron-right"))
page_next_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_next_btn, "monitoring.page_next")
page_indicator_lbl = QLabel()
def _refresh_pager(cur: int = None, total: int = None) -> None:
if cur is None or total is None:
cur, total = table.current_page(), table.page_count()
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
page_prev_btn.setEnabled(cur > 0)
page_next_btn.setEnabled(cur < total - 1)
page_prev_btn.clicked.connect(table.prev_page)
page_next_btn.clicked.connect(table.next_page)
table.page_changed.connect(_refresh_pager)
_refresh_pager()
row.addWidget(page_prev_btn)
row.addWidget(page_indicator_lbl)
row.addWidget(page_next_btn)
parts.update(
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
+2
View File
@@ -34,6 +34,7 @@ class McpTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -47,6 +48,7 @@ class McpTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
+1 -1
View File
@@ -106,4 +106,4 @@ class SessionEventsMixin:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
self.structure.refresh_project_list() # GraphRAG's project lock list follows too
+16 -10
View File
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv"
set "LAUNCHER=%APPHOME%\launcher"
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
rem lai cua so truoc khi in, de thong bao van doc duoc.
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
rem --------------------------------------------------------------------------
rem 1. Chon trinh thong dich
rem
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
)
if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo.
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo.
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LOI] Khong tao duoc lien ket thu muc:
echo "!PKGPATH!\cowork_local" -> "%REPO%"
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
rem ("'cowork_local' is a package and cannot be directly executed").
if not exist "!PKGPATH!\cowork_local\__main__.py" (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!"
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%"
rem --------------------------------------------------------------------------
rem 4. An cua so console trong luc chay
rem 4. Chay app
rem
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
rem thong bao loi ben duoi van doc duoc.
rem --------------------------------------------------------------------------
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
!RUNPY! -m cowork_local %*
set "RC=%ERRORLEVEL%"
@@ -55,6 +55,25 @@ def test_structure_graph_view_builds(ctx):
assert view.qa is not None
def test_refresh_project_list_forwards_to_renderer(ctx):
"""Crash bug: ``presentation/shell/session_events.py::_on_projects_changed``
called ``self.structure._refresh_project_combo()`` — a method that only
ever existed on ``GraphRenderer``, not on ``StructureGraphView`` itself —
so creating/renaming/deleting a project (anywhere in the app) raised
``AttributeError`` and crashed. ``refresh_project_list()`` is the public
forwarding method callers must use instead (matching ``schedule_rescan``/
``set_project``/``prewarm``'s existing forwarding pattern)."""
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
view = StructureGraphView(ctx)
assert not hasattr(view, "_refresh_project_combo")
combo = view.renderer.project_combo
before = combo.count()
view.refresh_project_list() # must not raise
assert combo.count() == before # re-populated from the same project list, same size
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
+55
View File
@@ -0,0 +1,55 @@
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
trở lại. Test này chốt cả nguồn sự thật (tuple
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.core.co4e import (
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
)
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def test_only_running_status_is_locked() -> None:
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
thực sự chạy (STEP_RUNNING) mới bị khoá."""
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
assert STEP_DONE not in _LOCKED_NODE_STATUSES
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
panel = StepConfigPanel()
panel.setEnabled(True) # panel starts disabled until a step is loaded
panel.set_locked(True)
assert not panel.label_edit.isEnabled()
assert not panel.instructions_edit.isEnabled()
assert not panel.model_combo.isEnabled()
panel.set_locked(False)
assert panel.label_edit.isEnabled()
assert panel.instructions_edit.isEnabled()
assert panel.model_combo.isEnabled()
+89 -19
View File
@@ -1,9 +1,11 @@
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
page-size state (presentation/monitoring/shared/event_table.py) and its
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
No dedicated test existed for this before — the design called for a
user-visible/choosable item-per-page control, and this exercises it end to
end (combo selection -> EventTable actually re-trimming its rows)."""
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
pagination: EventTable's page-size/page-index state
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
it with no way back); this exercises the real paging end to end."""
from __future__ import annotations
import os
@@ -32,19 +34,62 @@ def _events(n: int):
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
def test_default_page_size_matches_old_max_rows(qapp) -> None:
table = EventTable()
assert table.page_size() == 300
table.set_events(_events(500))
assert table.rowCount() == 300
def test_page_size_options_are_5_10_20_50_100() -> None:
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
def test_set_page_size_retrims_without_reloading(qapp) -> None:
def test_default_page_size(qapp) -> None:
table = EventTable()
table.set_events(_events(500))
assert table.page_size() == 20
table.set_events(_events(45))
assert table.rowCount() == 20
assert table.page_count() == 3
assert table.current_page() == 0
def test_set_page_size_resets_to_first_page(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.next_page()
assert table.current_page() == 1
table.set_page_size(50)
assert table.page_size() == 50
assert table.rowCount() == 50
assert table.current_page() == 0
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.set_page_size(20)
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 1
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 2
assert table.rowCount() == 5 # last page: remainder
table.next_page() # already on last page — stays put
assert table.current_page() == 2
table.prev_page()
assert table.current_page() == 1
table.prev_page()
table.prev_page() # already on first page — stays put
assert table.current_page() == 0
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
table = EventTable()
seen = []
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
table.set_events(_events(45))
table.set_page_size(20)
table.next_page()
assert seen[-1] == (1, 3)
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
table = EventTable()
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
assert "page_size_combo" not in parts
assert "page_prev_btn" not in parts
def test_page_size_combo_changes_the_table(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(500))
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
combo = parts["page_size_combo"]
assert combo.count() == len(PAGE_SIZE_OPTIONS)
assert combo.currentData() == 300 # matches EventTable's current page_size
assert combo.currentData() == 20 # matches EventTable's current page_size
idx = PAGE_SIZE_OPTIONS.index(50)
idx = PAGE_SIZE_OPTIONS.index(10)
combo.setCurrentIndex(idx)
assert table.page_size() == 50
assert table.rowCount() == 50
assert table.page_size() == 10
assert table.rowCount() == 10
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
table.set_page_size(20)
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
indicator = parts["page_indicator_label"]
assert not prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 1/3"
next_btn.click()
assert prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 2/3"
next_btn.click()
assert prev_btn.isEnabled()
assert not next_btn.isEnabled()
assert indicator.text() == "Trang 3/3"