feat(da07): devices tab adopts spec 6 empty-slot treatment, status tags, summary
This commit is contained in:
@@ -8,6 +8,7 @@ from cim_suite.core.ui.chrome import message_box
|
||||
from cim_suite.core.ui.combo_delegate import ComboBoxDelegate
|
||||
from cim_suite.core.ui.copy_menu import set_extra_actions
|
||||
from cim_suite.core.ui.help import normalize_label
|
||||
from cim_suite.core.ui.kit import KIND_ROLE, QUIET_ROLE, STATUS_ROLE, CellKind
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
from ..protocol import messages as m
|
||||
|
||||
@@ -20,10 +21,11 @@ _EDIT_MAP = {
|
||||
6: "set_device_delay",
|
||||
}
|
||||
_ACTIVE_COL = 1
|
||||
_STATUS_COL = 2
|
||||
_TYPE_COL = 3
|
||||
_SERIAL_COL = 7
|
||||
|
||||
_EMPTY_TYPE_HINT = "— pick a type to add —"
|
||||
_EMPTY_SLOT_TEXT = "— empty slot —" # §6: quiet placeholder; the TYPE cell stays the add affordance
|
||||
|
||||
|
||||
def _confirm_remove(parent) -> bool:
|
||||
@@ -53,9 +55,24 @@ class DevicesTab(TableTab):
|
||||
self.table.itemSelectionChanged.connect(self._on_select)
|
||||
self.table.cellDoubleClicked.connect(self._maybe_edit_serial)
|
||||
set_extra_actions(self.table, self._row_actions)
|
||||
|
||||
self.table.setAlternatingRowColors(False) # §6: slot states provide the rhythm
|
||||
d = self.delegate
|
||||
d.set_column_kind(0, CellKind.NUMERIC)
|
||||
d.set_column_kind(_STATUS_COL, CellKind.STATUS)
|
||||
for col in _EDIT_MAP: # Addr / Ctrl / Delay
|
||||
d.set_column_kind(col, CellKind.NUMERIC)
|
||||
d.set_column_kind(_SERIAL_COL, CellKind.IDENTIFIER)
|
||||
self.enable_summary("✎ Click a value to edit · Enter saves · Esc cancels")
|
||||
self._pending_writes: set[tuple[int, int]] = set()
|
||||
|
||||
self.rebuild()
|
||||
|
||||
def rebuild(self) -> None:
|
||||
for r, c in self._pending_writes: # §5.6: the echo IS the confirmation
|
||||
self._delegate_for(c).resolve(r, c, ok=True)
|
||||
self._pending_writes.clear()
|
||||
|
||||
by_index = {d.index: d for d in self._ctrl.devices.all()}
|
||||
max_devices = self._ctrl.config.max_devices if self._ctrl.config else len(by_index)
|
||||
# show all populated indices even if any exceed max_devices
|
||||
@@ -72,17 +89,24 @@ class DevicesTab(TableTab):
|
||||
rows.append([
|
||||
d.index + 1,
|
||||
active,
|
||||
self._ctrl.devices.status(d.index),
|
||||
self._label_for_type(d.type) if d.present else _EMPTY_TYPE_HINT,
|
||||
self._ctrl.devices.status(d.index) if d.present else "",
|
||||
self._label_for_type(d.type) if d.present else _EMPTY_SLOT_TEXT,
|
||||
d.address, d.control, d.delay,
|
||||
self._ctrl.full_serial(d.index) if d.present else "",
|
||||
])
|
||||
self.set_rows(rows)
|
||||
self._apply_empty_row_flags()
|
||||
self._apply_status_roles()
|
||||
present = sum(1 for d in self._devices if d.present)
|
||||
self.summary.set_summary([
|
||||
(f"{len(self._devices)} slots", None),
|
||||
(f"{present} devices", None),
|
||||
])
|
||||
|
||||
def _apply_empty_row_flags(self) -> None:
|
||||
"""On empty slots, disable everything except the Type cell (the add affordance)."""
|
||||
self._loading = True # suppress itemChanged while we flip flags
|
||||
"""Empty slots go quiet (§6): dimmed slot number, faint placeholder, no
|
||||
toggle, no status — only the Type cell stays live as the add affordance."""
|
||||
self._loading = True # suppress itemChanged while we flip flags/roles
|
||||
try:
|
||||
for row, d in enumerate(self._devices):
|
||||
if d.present:
|
||||
@@ -91,9 +115,46 @@ class DevicesTab(TableTab):
|
||||
item = self.table.item(row, col)
|
||||
if item is not None:
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
||||
active = self.table.item(row, _ACTIVE_COL)
|
||||
if active is not None: # no toggle on an empty slot
|
||||
active.setData(KIND_ROLE, CellKind.TEXT)
|
||||
active.setData(Qt.ItemDataRole.CheckStateRole, None)
|
||||
active.setFlags(active.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
|
||||
status = self.table.item(row, _STATUS_COL)
|
||||
if status is not None: # no status tag either
|
||||
status.setData(KIND_ROLE, CellKind.TEXT)
|
||||
for col in (0, _TYPE_COL): # quiet: faint at paint time
|
||||
item = self.table.item(row, col)
|
||||
if item is not None:
|
||||
item.setData(QUIET_ROLE, True)
|
||||
finally:
|
||||
self._loading = False
|
||||
|
||||
def _apply_status_roles(self) -> None:
|
||||
"""§1.2 tags on the Status column: OK*→ok; COM/FLO/ERR faults→alarm; no
|
||||
'H' report yet→off ('—'). Empty slots were stripped to TEXT already."""
|
||||
self._loading = True
|
||||
try:
|
||||
for row, d in enumerate(self._devices):
|
||||
if not d.present:
|
||||
continue
|
||||
item = self.table.item(row, _STATUS_COL)
|
||||
if item is None:
|
||||
continue
|
||||
text = item.text()
|
||||
if not text:
|
||||
item.setData(STATUS_ROLE, "off")
|
||||
item.setText("—")
|
||||
elif text.startswith("OK"):
|
||||
item.setData(STATUS_ROLE, "ok")
|
||||
else:
|
||||
item.setData(STATUS_ROLE, "alarm")
|
||||
finally:
|
||||
self._loading = False
|
||||
|
||||
def _delegate_for(self, col: int):
|
||||
return self._type_delegate if col == _TYPE_COL else self.delegate
|
||||
|
||||
def _type_labels(self) -> list[str]:
|
||||
"""Dropdown labels: a blank 'None' then each catalog id (mirrors DeviceList)."""
|
||||
labels = [""]
|
||||
@@ -140,17 +201,23 @@ class DevicesTab(TableTab):
|
||||
idx = self._index_for_label(value)
|
||||
if idx is not None:
|
||||
self._ctrl.set_device_type(self._devices[row].index, str(idx))
|
||||
self._delegate_for(col).mark_pending(row, col)
|
||||
self._pending_writes.add((row, col))
|
||||
return
|
||||
setter = _EDIT_MAP.get(col)
|
||||
if setter is None:
|
||||
return
|
||||
getattr(self._ctrl, setter)(self._devices[row].index, value)
|
||||
self._delegate_for(col).mark_pending(row, col)
|
||||
self._pending_writes.add((row, col))
|
||||
|
||||
def on_check(self, row: int, col: int, checked: bool) -> None:
|
||||
if col == _ACTIVE_COL and row < len(self._devices):
|
||||
dev = self._devices[row]
|
||||
if dev.present:
|
||||
self._ctrl.set_device_active(dev.index, checked)
|
||||
self.delegate.mark_pending(row, col)
|
||||
self._pending_writes.add((row, col))
|
||||
|
||||
def _row_actions(self, row: int, col: int):
|
||||
if 0 <= row < len(self._devices) and self._devices[row].present:
|
||||
|
||||
73
tests/da07/test_devices_tab_slots.py
Normal file
73
tests/da07/test_devices_tab_slots.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Devices tab per spec §6: quiet empty slots, no zebra, status tags, summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cim_suite.core.ui.kit import KIND_ROLE, QUIET_ROLE, STATUS_ROLE, CellKind
|
||||
from cim_suite.modules.da07.domain.controller import StationController
|
||||
from cim_suite.modules.da07.transport.simulator import SimulatedStation
|
||||
from cim_suite.modules.da07.ui.devices_tab import DevicesTab
|
||||
|
||||
|
||||
def _tab(qtbot, devices=2, channels=2, capacity=6):
|
||||
sim = SimulatedStation(devices=devices, channels=channels, capacity=capacity)
|
||||
ctrl = StationController()
|
||||
ctrl.attach(sim)
|
||||
sim.start()
|
||||
ctrl.refresh()
|
||||
tab = DevicesTab(ctrl)
|
||||
qtbot.addWidget(tab)
|
||||
return tab, ctrl
|
||||
|
||||
|
||||
def test_no_zebra_striping(qtbot):
|
||||
tab, _ = _tab(qtbot)
|
||||
assert tab.table.alternatingRowColors() is False
|
||||
|
||||
|
||||
def test_empty_slot_is_quiet(qtbot):
|
||||
tab, _ = _tab(qtbot, devices=2, capacity=6)
|
||||
row = 5 # an empty slot
|
||||
assert tab.table.item(row, 3).text() == "— empty slot —"
|
||||
assert tab.table.item(row, 3).data(QUIET_ROLE)
|
||||
assert tab.table.item(row, 0).data(QUIET_ROLE) # dimmed slot number
|
||||
active = tab.table.item(row, 1)
|
||||
assert active.data(KIND_ROLE) == CellKind.TEXT # no toggle paints
|
||||
assert active.data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
assert tab.table.item(row, 2).text() == "" # no status
|
||||
occupied = tab.table.item(0, 3)
|
||||
assert not occupied.data(QUIET_ROLE) # occupied slots normal
|
||||
|
||||
|
||||
def test_empty_slot_type_cell_is_the_add_affordance(qtbot):
|
||||
tab, _ = _tab(qtbot, devices=1, capacity=4)
|
||||
item = tab.table.item(3, 3)
|
||||
assert item.flags() & Qt.ItemFlag.ItemIsEditable
|
||||
assert item.flags() & Qt.ItemFlag.ItemIsEnabled
|
||||
|
||||
|
||||
def test_present_device_status_carries_status_role(qtbot):
|
||||
tab, ctrl = _tab(qtbot)
|
||||
ctrl.devices.set_status(0, "OK")
|
||||
ctrl.devices.set_status(1, "ERR")
|
||||
tab.rebuild()
|
||||
assert tab.table.item(0, 2).data(STATUS_ROLE) == "ok"
|
||||
assert tab.table.item(1, 2).data(STATUS_ROLE) == "alarm"
|
||||
|
||||
|
||||
def test_summary_counts_slots_and_devices(qtbot):
|
||||
tab, _ = _tab(qtbot, devices=2, capacity=6)
|
||||
strip = tab.summary
|
||||
texts = {lbl.text() for lbl in strip.findChildren(QLabel) if lbl.objectName() == "SummaryItem"}
|
||||
assert "6 SLOTS" in texts
|
||||
assert "2 DEVICES" in texts
|
||||
|
||||
|
||||
def test_edit_marks_pending_and_rebuild_resolves(qtbot):
|
||||
tab, _ = _tab(qtbot, devices=1, capacity=2)
|
||||
tab.on_edit(0, 4, "9") # Addr edit
|
||||
assert (0, 4) in tab.delegate._pending
|
||||
tab.rebuild()
|
||||
assert (0, 4) not in tab.delegate._pending
|
||||
@@ -464,7 +464,7 @@ def test_devices_tab_renders_all_slots(qtbot):
|
||||
assert tab.table.rowCount() == 6 # full capacity, not just the 2 present
|
||||
# an empty slot: placeholder in Type, disabled (non-checkable) Active cell
|
||||
empty_type = tab.table.item(5, 3)
|
||||
assert "add" in empty_type.text().lower()
|
||||
assert empty_type.text() == "— empty slot —"
|
||||
assert not (tab.table.item(5, 1).flags() & Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user