feat(da07): devices tab adopts spec 6 empty-slot treatment, status tags, summary

This commit is contained in:
2026-06-11 12:30:06 -04:00
parent b774a7d4ac
commit 7d64d3a8a6
3 changed files with 146 additions and 6 deletions

View File

@@ -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:

View 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

View File

@@ -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)