feat(da12): sensors grid adopts status tags, alarm rows, units, summary, write feedback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:11:26 -04:00
parent 06dbf24856
commit 0d7a4ac101
5 changed files with 216 additions and 38 deletions

View File

@@ -170,6 +170,15 @@ class TableTab(QWidget):
self.table.setHorizontalHeaderItem(col, item)
item.setToolTip(text)
def enable_summary(self, hint: str = "") -> None:
"""Opt-in §5.4 summary strip below the grid; update via ``self.summary``."""
from .kit import SummaryStrip
self.summary = SummaryStrip(self)
if hint:
self.summary.set_hint(hint)
self.layout().addWidget(self.summary)
def set_column_units(self, mapping) -> None:
"""Show a mono units line under column headers (spec §5.4).

View File

@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
from cim_suite.core.sensor_models import identify, layout
from cim_suite.core.ui.copy_menu import set_extra_actions
from cim_suite.core.ui.group_band_delegate import GroupBandDelegate
from cim_suite.core.ui.kit import ALARM_ROW_ROLE, STATUS_ROLE, CellKind
from cim_suite.core.ui.table_tab import TableTab
from cim_suite.core.ui.theme import current, qcolor
from ..sensor_enums import calc_code, calc_label, calc_labels, disp_code, disp_label, disp_labels
@@ -32,6 +33,11 @@ _CALC_COL = 13
_INPUT_COL = 14
_REFRESH_COL = 15
# §1.2: alarm-class codes trigger full-row tint + alarm edge bar (ALARM_ROW_ROLE).
# Warn-class codes show ONLY in the Alarm column status tag — no whole-row tint.
_ALARM_CODES = ("ER", "HA", "LA")
_WARN_CODES = ("HW", "LW")
# Staleness thresholds in seconds (legacy Main.frm Timer1_Timer): < 20s fresh,
# < 60s aging, otherwise stale.
_FRESH_S = 20
@@ -78,15 +84,6 @@ class _EnumBandDelegate(GroupBandDelegate):
return
super().setModelData(editor, model, index)
def _alarm_colors(code: str | None) -> tuple[QColor, QColor] | None:
"""(row fill, text color) for a wire alarm code, from the live theme."""
pair = current().severity.get(code) if code else None
if pair is None:
return None
fg, fill = pair
return (qcolor(fill), qcolor(fg))
def _stale_colors(key: str) -> tuple[QColor, QColor]:
"""(cell fill, text color) for a staleness key, from the live theme."""
fg, fill = current().staleness[key]
@@ -113,6 +110,22 @@ class SensorsTab(TableTab):
)
self.table.setItemDelegate(self._delegate)
self.delegate = self._delegate # keep TableTab.delegate pointing at the installed one
d = self._delegate
d.set_column_kind(0, CellKind.NUMERIC) # '#'
d.set_column_kind(1, CellKind.IDENTIFIER) # Serial — recessive mono
d.set_column_kind(4, CellKind.IDENTIFIER) # Type code
for col in (5, 6, 7, 8, 9, 12, 14, 15):
d.set_column_kind(col, CellKind.NUMERIC)
d.set_column_kind(10, CellKind.STATUS) # Alarm — §1.2 tag
self.set_column_units({
"Scale": "×", "Offset": "+", "Timestamp": "hh:mm:ss",
"Input": "raw", "Refresh": "s",
})
self.enable_summary("✎ Click a value to edit · Enter saves · Esc cancels")
self._pending_writes: set[tuple[int, int]] = set()
controller.sensorsChanged.connect(self.rebuild)
self.rebuild()
# Tick the Refresh column once a second so a silent channel's counter
@@ -155,6 +168,10 @@ class SensorsTab(TableTab):
attach_help(self.btn_clear, H.BUTTONS["Clear Avg"])
def rebuild(self) -> None:
for r, c in self._pending_writes: # §5.6: the echo IS the confirmation
self.delegate.resolve(r, c, ok=True)
self._pending_writes.clear()
records = self._ctrl.sensors.all()
order, grouped_rows, starts = layout([s.serial for s in records])
self._ordered = [records[i] for i in order]
@@ -174,24 +191,37 @@ class SensorsTab(TableTab):
])
self.set_rows(rows)
self._delegate.set_groups(grouped_rows, starts)
self._colorize()
self._apply_status_roles()
self._tick_refresh()
def _colorize(self) -> None:
self._loading = True # suppress itemChanged while we tint cells
warn = sum(1 for s in self._ordered if s.alarm in _WARN_CODES)
alarm = sum(1 for s in self._ordered if s.alarm in _ALARM_CODES)
segments: list[tuple[str, str | None]] = [(f"{len(self._ordered)} sensors", None)]
if warn:
segments.append((f"{warn} warn", "warn"))
if alarm:
segments.append((f"{alarm} alarm", "alarm"))
self.summary.set_summary(segments)
def _apply_status_roles(self) -> None:
"""§1.2: status tag on the Alarm cell; full-row tint for alarm-class only."""
self._loading = True
try:
for r, s in enumerate(self._ordered):
colors = _alarm_colors(s.alarm)
if colors is None:
continue
fill, fg = colors
for c in range(self.table.columnCount()):
if c == _REFRESH_COL:
continue # staleness tint owns this cell; see _tick_refresh
it = self.table.item(r, c)
if it:
it.setBackground(fill)
it.setForeground(fg)
if s.alarm in _ALARM_CODES:
status = "alarm"
elif s.alarm in _WARN_CODES:
status = "warn"
else:
status = "ok"
cell = self.table.item(r, 10)
if cell is not None:
cell.setData(STATUS_ROLE, status)
if not cell.text():
cell.setText("OK")
first = self.table.item(r, 0)
if first is not None:
first.setData(ALARM_ROW_ROLE, status == "alarm")
finally:
self._loading = False
@@ -256,6 +286,8 @@ class SensorsTab(TableTab):
return
value = str(code)
getattr(self._ctrl, setter)(sid, value)
self.delegate.mark_pending(row, col)
self._pending_writes.add((row, col))
def _add(self) -> None:
text, ok = QInputDialog.getText(
@@ -296,6 +328,10 @@ class SensorsTab(TableTab):
)
return actions
# §5.6 makes the first click on an EDITABLE cell open the editor, so double-click
# history is reachable only from read-only cells (#, Serial, Model, …). That is
# deliberate: right-click → "Show history…" is the canonical affordance; the
# read-only double-click is a convenience that costs nothing to keep.
def _on_double_click(self, row: int, col: int) -> None:
try:
sid = int(self.row_value(row, 0))

View File

@@ -1,13 +1,12 @@
"""Regression: rebuilding the Sensors grid must not send settings writes.
``_colorize`` tints alarm rows with setBackground/setForeground; each call fires
``itemChanged``, and unless it runs under the ``_loading`` guard those phantom
``_apply_status_roles`` sets ALARM_ROW_ROLE / STATUS_ROLE via setData; each call
fires ``itemChanged``, and unless it runs under the ``_loading`` guard those phantom
"edits" route to ``on_edit`` -> controller ``set_*`` -> real settings frames on
the wire (~12 frames per alarm row per rebuild, verified empirically).
"""
from PySide6.QtCore import Qt
from cim_suite.core.ui.kit.delegate import ALARM_ROW_ROLE, STATUS_ROLE
from cim_suite.modules.da12.domain.controller import StationController
from cim_suite.modules.da12.transport.simulator import SimulatedStation
from cim_suite.modules.da12.ui.sensors_tab import SensorsTab
@@ -25,7 +24,7 @@ def _wired(sensors=2):
def test_rebuild_with_alarm_row_sends_no_settings_writes(qtbot):
ctrl, sim = _wired()
rec = ctrl.sensors.all()[0]
rec.alarm = "HA" # high alarm -> full-row severity tint in _colorize
rec.alarm = "HA" # high alarm -> ALARM_ROW_ROLE + STATUS_ROLE set by _apply_status_roles
tab = SensorsTab(ctrl)
qtbot.addWidget(tab)
@@ -34,11 +33,13 @@ def test_rebuild_with_alarm_row_sends_no_settings_writes(qtbot):
tab.rebuild()
assert sent == [], f"rebuild leaked settings writes: {sent}"
# Guard against a vacuous pass: the alarm row really got its tint applied.
# Guard against a vacuous pass: the alarm row really got its roles applied.
row = next(r for r in range(tab.table.rowCount())
if tab.row_value(r, 0) == str(rec.id))
cell = tab.table.item(row, 1) # Serial column, tinted by _colorize
assert cell.background().style() != Qt.BrushStyle.NoBrush
# Col 0: ALARM_ROW_ROLE must be truthy (full-row tint via delegate, not brush).
assert bool(tab.table.item(row, 0).data(ALARM_ROW_ROLE)) is True
# Col 10 (Alarm): STATUS_ROLE must be "alarm".
assert tab.table.item(row, 10).data(STATUS_ROLE) == "alarm"
def test_installed_delegate_is_exposed_as_tab_delegate(qtbot):

View File

@@ -0,0 +1,129 @@
"""Task 8: Sensors tab status tags, alarm rows, units, summary, write feedback."""
from __future__ import annotations
from PySide6.QtWidgets import QLabel
from cim_suite.core.ui.kit.delegate import ALARM_ROW_ROLE, STATUS_ROLE
from cim_suite.modules.da12.domain.controller import StationController
from cim_suite.modules.da12.transport.simulator import SimulatedStation
from cim_suite.modules.da12.ui.sensors_tab import HEADERS, SensorsTab
def _ctrl(sensors: int = 3):
sim = SimulatedStation(sensors=sensors)
c = StationController()
c.attach(sim)
sim.start()
c.refresh()
return c
def _tab(qtbot, sensors: int = 3):
ctrl = _ctrl(sensors)
tab = SensorsTab(ctrl)
qtbot.addWidget(tab)
tab.rebuild()
return tab, ctrl
def _set_alarm(ctrl: StationController, idx: int, code: str) -> None:
"""Set .alarm on the idx-th sensor (0-based) and rebuild."""
rec = ctrl.sensors.all()[idx]
rec.alarm = code
# ---------------------------------------------------------------------------
# test_alarm_column_carries_status_role
# ---------------------------------------------------------------------------
def test_alarm_column_carries_status_role(qtbot):
"""Alarm col (10) STATUS_ROLE = alarm/warn/ok; clear row shows 'OK'."""
tab, ctrl = _tab(qtbot, 3)
recs = ctrl.sensors.all()
recs[0].alarm = "HA" # alarm-class
recs[1].alarm = "HW" # warn-class
recs[2].alarm = "" # clear
tab.rebuild()
alarm_col = 10
# Row 0: alarm
assert tab.table.item(0, alarm_col).data(STATUS_ROLE) == "alarm"
# Row 1: warn
assert tab.table.item(1, alarm_col).data(STATUS_ROLE) == "warn"
# Row 2: ok + text "OK"
assert tab.table.item(2, alarm_col).data(STATUS_ROLE) == "ok"
assert tab.table.item(2, alarm_col).text() == "OK"
# ---------------------------------------------------------------------------
# test_alarm_rows_flag_full_row_tint_warn_rows_do_not
# ---------------------------------------------------------------------------
def test_alarm_rows_flag_full_row_tint_warn_rows_do_not(qtbot):
"""ALARM_ROW_ROLE truthy only for alarm-class codes (ER/HA/LA)."""
tab, ctrl = _tab(qtbot, 3)
recs = ctrl.sensors.all()
recs[0].alarm = "HA"
recs[1].alarm = "HW"
recs[2].alarm = ""
tab.rebuild()
assert bool(tab.table.item(0, 0).data(ALARM_ROW_ROLE)) is True # HA = alarm
assert not tab.table.item(1, 0).data(ALARM_ROW_ROLE) # HW = warn, no full row
assert not tab.table.item(2, 0).data(ALARM_ROW_ROLE) # clear
# ---------------------------------------------------------------------------
# test_summary_counts
# ---------------------------------------------------------------------------
def test_summary_counts(qtbot):
"""3 sensors / 1 warn / 1 alarm → strip labels include those counts."""
tab, ctrl = _tab(qtbot, 3)
recs = ctrl.sensors.all()
recs[0].alarm = "HA"
recs[1].alarm = "HW"
recs[2].alarm = ""
tab.rebuild()
strip = tab.summary
texts = {lbl.text() for lbl in strip.findChildren(QLabel) if lbl.objectName() == "SummaryItem"}
assert "3 SENSORS" in texts
assert "1 WARN" in texts
assert "1 ALARM" in texts
# ---------------------------------------------------------------------------
# test_edit_marks_pending_and_rebuild_resolves
# ---------------------------------------------------------------------------
def test_edit_marks_pending_and_rebuild_resolves(qtbot):
"""on_edit → mark_pending adds to delegate._pending; rebuild clears it."""
tab, ctrl = _tab(qtbot, 2)
row = 0
col = 3 # Name column
# Patch the setter so the controller call doesn't fail on a bad name
names_sent = []
ctrl.set_sensor_name = lambda sid, v: names_sent.append((sid, v))
tab.on_edit(row, col, "NewName")
assert (row, col) in tab.delegate._pending
tab.rebuild()
assert (row, col) not in tab.delegate._pending
# ---------------------------------------------------------------------------
# test_units_row_present
# ---------------------------------------------------------------------------
def test_units_row_present(qtbot):
"""UnitsHeaderView has '×' under Scale and '+' under Offset."""
tab, ctrl = _tab(qtbot, 1)
header = tab.table.horizontalHeader()
scale_idx = HEADERS.index("Scale")
offset_idx = HEADERS.index("Offset")
assert header._units.get(scale_idx) == "×"
assert header._units.get(offset_idx) == "+"

View File

@@ -178,15 +178,16 @@ def test_refresh_cell_blank_when_no_value_yet(qtbot):
def test_alarm_row_coloring_independent_of_refresh_tint(qtbot):
from cim_suite.modules.da12.ui.sensors_tab import SensorsTab, staleness
from cim_suite.core.ui.kit.delegate import ALARM_ROW_ROLE, STATUS_ROLE
from cim_suite.core.ui.theme import current, qcolor
from cim_suite.modules.da12.ui.sensors_tab import SensorsTab, staleness
ctrl, _ = _wired_controller()
tab = SensorsTab(ctrl)
qtbot.addWidget(tab)
rec = ctrl.sensors.get(1) # row 0
rec.alarm = "HA" # high-alarm -> ALARM row fill (distinct from any warn tint)
rec.alarm = "HA" # high-alarm -> ALARM_ROW_ROLE on col 0; STATUS_ROLE on col 10
rec.updated_at = 100.0
tab.rebuild()
tab._tick_refresh(now=130.0) # elapsed 30 -> "caution"
@@ -194,12 +195,14 @@ def test_alarm_row_coloring_independent_of_refresh_tint(qtbot):
t = current()
_afg, alarm_fill = t.severity["HA"]
_sfg, stale_fill = t.staleness[staleness(30)]
# HA (alarm_row) and caution (warn_bg) are distinct, so the assertions below can
# actually catch a swapped-fill bug. (HW would collapse into warn_bg with caution.)
# HA (alarm_row) and caution (warn_bg) are distinct colors — both assertions
# below would silently pass if the fill distinction were lost.
assert qcolor(alarm_fill).name() != qcolor(stale_fill).name()
# a normal data cell carries the alarm fill...
assert tab.table.item(0, 1).background().color().name().upper() == qcolor(alarm_fill).name().upper()
# ...while the Refresh cell (col 15) carries the staleness fill instead.
# Alarm row: ALARM_ROW_ROLE set on col-0 item (delegate paints the fill at draw time).
assert bool(tab.table.item(0, 0).data(ALARM_ROW_ROLE)) is True
# Alarm col (10): STATUS_ROLE = "alarm".
assert tab.table.item(0, 10).data(STATUS_ROLE) == "alarm"
# Refresh cell (col 15) still carries the staleness brush (owned by _tick_refresh).
assert tab.table.item(0, 15).background().color().name().upper() == qcolor(stale_fill).name().upper()