Compare commits

..
Author SHA1 Message Date
duylh19andClaude Opus 5 57af508971 fix(sandbox): chặn mạng thật sự cho mọi tool NETWORK của agent
CI / test (pull_request) Canceled after 0s
block_network trước đây chỉ được run_command đọc tới, nên fetch_url,
jira_search, jira_get_issue và install_package vẫn ra internet bình thường
trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool
đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo
BUILT_IN_CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:33:26 +09:00
duylh19andClaude Opus 5 d0df96c726 fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project
Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:08 +09:00
duylh19andClaude Opus 5 10799dc67b feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent,
AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập
đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được.

Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và
_sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong
tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py —
yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình.

Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự
ghi "khoá phía giao diện để chặn bấm nhầm ... KHÔNG phải cơ chế bảo mật
thật". Rào thật nằm ở sandbox lúc chạy lệnh. Vùng này thuộc diện SECURITY.md
yêu cầu Cowork Team soát thêm.

10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất
đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được
ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:31:49 +09:00
13 changed files with 54 additions and 274 deletions
Binary file not shown.
-6
View File
@@ -234,12 +234,6 @@ 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_RUNNING
from ...core.co4e import STEP_DONE, 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,)
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
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.
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.
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ả.
"""
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.
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.
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.
"""
self._locked = locked
editable = not locked
+2 -3
View File
@@ -39,9 +39,8 @@ _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 paginates client-side, 5-100 rows/page — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
# read.
# rendering (EventTable already caps display at 300 rows — see
# shared/event_table.py::_MAX_ROWS), 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
+11 -55
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
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
_DEFAULT_PAGE_SIZE = 20
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
_MAX_ROWS = 300
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
class _TimeItem(QTableWidgetItem):
@@ -63,12 +63,6 @@ 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.
@@ -77,10 +71,8 @@ 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 = _DEFAULT_PAGE_SIZE
self._current_page = 0
self._page_size = _MAX_ROWS
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)
@@ -113,56 +105,21 @@ 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, 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)."""
"""Đổ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)."""
self._page_size = n
self._current_page = 0
self._render_current_page()
self.set_events(self._last_events)
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, 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.
"""Đổ 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).
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._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._last_events = events
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
self.setSortingEnabled(False)
self.setRowCount(len(events))
for row, ev in enumerate(events):
@@ -207,7 +164,6 @@ 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,52 +95,20 @@ 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, this just makes that number visible AND
# user-choosable instead of a fixed constant.
# page size internally (default 300), 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 0)
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
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)
# 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)
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -34,7 +34,6 @@ 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."""
@@ -48,7 +47,6 @@ 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,7 +34,6 @@ 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."""
@@ -48,7 +47,6 @@ 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,7 +40,6 @@ 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."""
@@ -54,7 +53,6 @@ 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."""
+10 -16
View File
@@ -19,14 +19,6 @@ 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
@@ -46,7 +38,6 @@ 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.
@@ -60,7 +51,6 @@ 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.
@@ -104,7 +94,6 @@ 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%"
@@ -123,7 +112,6 @@ 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!"
@@ -151,12 +139,18 @@ set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%"
rem --------------------------------------------------------------------------
rem 4. Chay app
rem 4. An cua so console trong luc chay
rem
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 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 --------------------------------------------------------------------------
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
View File
@@ -1,55 +0,0 @@
"""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()
+18 -88
View File
@@ -1,11 +1,9 @@
"""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."""
"""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)."""
from __future__ import annotations
import os
@@ -34,62 +32,19 @@ def _events(n: int):
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
def test_page_size_options_are_5_10_20_50_100() -> None:
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
def test_default_page_size(qapp) -> None:
def test_default_page_size_matches_old_max_rows(qapp) -> None:
table = EventTable()
assert table.page_size() == 20
table.set_events(_events(45))
assert table.rowCount() == 20
assert table.page_count() == 3
assert table.current_page() == 0
assert table.page_size() == 300
table.set_events(_events(500))
assert table.rowCount() == 300
def test_set_page_size_resets_to_first_page(qapp) -> None:
def test_set_page_size_retrims_without_reloading(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.next_page()
assert table.current_page() == 1
table.set_events(_events(500))
table.set_page_size(50)
assert table.page_size() == 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)
assert table.rowCount() == 50
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
@@ -97,44 +52,19 @@ 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(45))
table.set_events(_events(500))
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() == 20 # matches EventTable's current page_size
assert combo.currentData() == 300 # matches EventTable's current page_size
idx = PAGE_SIZE_OPTIONS.index(10)
idx = PAGE_SIZE_OPTIONS.index(50)
combo.setCurrentIndex(idx)
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"
assert table.page_size() == 50
assert table.rowCount() == 50