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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
129
tests/da12/test_sensors_tab_status.py
Normal file
129
tests/da12/test_sensors_tab_status.py
Normal 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) == "+"
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user