diff --git a/Task Tracking Template.xlsx b/Task Tracking Template.xlsx index 2456d3e..2671c07 100644 Binary files a/Task Tracking Template.xlsx and b/Task Tracking Template.xlsx differ diff --git a/i18n/agents_admin_tab.py b/i18n/agents_admin_tab.py index 261908c..85a4320 100644 --- a/i18n/agents_admin_tab.py +++ b/i18n/agents_admin_tab.py @@ -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)"}, diff --git a/presentation/co4e/co4e_workflow_crud.py b/presentation/co4e/co4e_workflow_crud.py index f7b46b7..195ef28 100644 --- a/presentation/co4e/co4e_workflow_crud.py +++ b/presentation/co4e/co4e_workflow_crud.py @@ -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: diff --git a/presentation/co4e/node_property_panel.py b/presentation/co4e/node_property_panel.py index 8d40176..bd319e7 100644 --- a/presentation/co4e/node_property_panel.py +++ b/presentation/co4e/node_property_panel.py @@ -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 diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 5302ed6..1427c39 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -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 diff --git a/presentation/monitoring/shared/event_table.py b/presentation/monitoring/shared/event_table.py index 147e18b..d84c9f9 100644 --- a/presentation/monitoring/shared/event_table.py +++ b/presentation/monitoring/shared/event_table.py @@ -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).""" diff --git a/presentation/monitoring/shared/filter_scaffold.py b/presentation/monitoring/shared/filter_scaffold.py index 4196749..9b32f19 100644 --- a/presentation/monitoring/shared/filter_scaffold.py +++ b/presentation/monitoring/shared/filter_scaffold.py @@ -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) diff --git a/presentation/monitoring/tabs/action_logs_tab.py b/presentation/monitoring/tabs/action_logs_tab.py index fd52b11..99d3166 100644 --- a/presentation/monitoring/tabs/action_logs_tab.py +++ b/presentation/monitoring/tabs/action_logs_tab.py @@ -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.""" diff --git a/presentation/monitoring/tabs/mcp_tab.py b/presentation/monitoring/tabs/mcp_tab.py index 5038da2..03b86b4 100644 --- a/presentation/monitoring/tabs/mcp_tab.py +++ b/presentation/monitoring/tabs/mcp_tab.py @@ -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.""" diff --git a/presentation/monitoring/tabs/security_events_tab.py b/presentation/monitoring/tabs/security_events_tab.py index 4c961e7..6485fea 100644 --- a/presentation/monitoring/tabs/security_events_tab.py +++ b/presentation/monitoring/tabs/security_events_tab.py @@ -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.""" diff --git a/run.bat b/run.bat index 09b22ea..71111ca 100644 --- a/run.bat +++ b/run.bat @@ -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%" diff --git a/tests/test_co4e_edit_lock.py b/tests/test_co4e_edit_lock.py new file mode 100644 index 0000000..12a4145 --- /dev/null +++ b/tests/test_co4e_edit_lock.py @@ -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() diff --git a/tests/test_monitoring_page_size.py b/tests/test_monitoring_page_size.py index 1df786a..74edf8e 100644 --- a/tests/test_monitoring_page_size.py +++ b/tests/test_monitoring_page_size.py @@ -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"