Compare commits
19 Commits
b17c23198e
...
1f72c34725
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f72c34725 | |||
| 9537112854 | |||
| 7977ab74ee | |||
| 294b462734 | |||
| 0e89274be9 | |||
| f2e353fd0d | |||
| ca4631f5a0 | |||
| 0d7a4ac101 | |||
| 06dbf24856 | |||
| 78dd76c22e | |||
| 71522b9493 | |||
| af766ab26c | |||
| 9d451db795 | |||
| 323affac12 | |||
| 6931914fd4 | |||
| e4ff6def82 | |||
| 5497dd19ca | |||
| ea579e6c44 | |||
| f6b89f580a |
@@ -7,8 +7,9 @@ paints, so it composes with per-item background/foreground (alarm fills, stalene
|
||||
Set group membership with ``set_groups`` after each table rebuild.
|
||||
|
||||
Subclasses InstrumentDelegate, so banded tables also get the kit's editing UX
|
||||
(styled editor, Tab-move, hover affordance). The group accent bar can overlay
|
||||
the selection edge bar on column 0 — acceptable until Phases 4/5 revisit.
|
||||
(styled editor, Tab-move, hover affordance). Per spec §5.4, the selection/alarm edge bar
|
||||
painted by InstrumentDelegate owns column 0 on selected/alarm rows — the group accent
|
||||
bar yields on those rows and only paints when neither condition applies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,11 +17,11 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable
|
||||
|
||||
from PySide6.QtGui import QPen
|
||||
from PySide6.QtWidgets import QStyle
|
||||
|
||||
from .kit import InstrumentDelegate
|
||||
from .theme import current, qcolor
|
||||
|
||||
_BAR_WIDTH = 3 # px, left accent bar
|
||||
from .theme.tokens import Metrics
|
||||
|
||||
|
||||
class GroupBandDelegate(InstrumentDelegate):
|
||||
@@ -53,6 +54,14 @@ class GroupBandDelegate(InstrumentDelegate):
|
||||
pen.setWidth(1)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(rect.topLeft(), rect.topRight())
|
||||
if self.is_grouped(row) and index.column() == 0:
|
||||
painter.fillRect(rect.left(), rect.top(), _BAR_WIDTH, rect.height(), qcolor(t.accent))
|
||||
# §5.4: the selection/alarm edge bar (painted by InstrumentDelegate) owns
|
||||
# column 0 on selected/alarm rows — the group band yields there.
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
if (
|
||||
self.is_grouped(row) and index.column() == 0
|
||||
and not selected and not self._row_alarm(index)
|
||||
):
|
||||
painter.fillRect(
|
||||
rect.left(), rect.top(), Metrics.EDGE_BAR_W, rect.height(), qcolor(t.accent)
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
@@ -6,9 +6,10 @@ Nothing in this package talks to controllers, models, or transports.
|
||||
"""
|
||||
|
||||
from .activity_log import ActivityLogCard
|
||||
from .connection_chip import ConnectionChip
|
||||
from .delegate import ALARM_ROW_ROLE, KIND_ROLE, STATUS_ROLE, CellKind, InstrumentDelegate
|
||||
from .icons import icon_pixmap, tinted_icon
|
||||
from .settings_list import SettingsList
|
||||
from .settings_list import SettingsDelegate, SettingsList
|
||||
from .sidebar import Sidebar, SidebarEntry
|
||||
from .summary_strip import SummaryStrip
|
||||
from .tab_widget import InstrumentTabWidget
|
||||
@@ -18,11 +19,13 @@ from .units_header import UnitsHeaderView
|
||||
__all__ = [
|
||||
"ALARM_ROW_ROLE",
|
||||
"ActivityLogCard",
|
||||
"ConnectionChip",
|
||||
"KIND_ROLE",
|
||||
"STATUS_ROLE",
|
||||
"CellKind",
|
||||
"InstrumentDelegate",
|
||||
"InstrumentTabWidget",
|
||||
"SettingsDelegate",
|
||||
"SettingsList",
|
||||
"Sidebar",
|
||||
"SidebarEntry",
|
||||
|
||||
86
cim_suite/core/ui/kit/connection_chip.py
Normal file
86
cim_suite/core/ui/kit/connection_chip.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Connection chip (spec §5.2) — the ok pill beside the tool name in the toolbar.
|
||||
|
||||
Connected: ``ok_bg`` fill, filled ``ok`` dot, "Connected · <SOURCE>". Disconnected:
|
||||
transparent pill with a ``border`` outline, hollow ``faint`` dot (1.5px ring),
|
||||
"Not connected". Text is the spec's mixed case in the ``status_label`` mono font
|
||||
(§1.2's UPPER rule applies to OK/WARN/ALARM tags, not this chip). All colors are
|
||||
read from ``theme.current()`` at paint time so theme switches just repaint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QFontMetrics, QPainter, QPen
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from ..theme import qcolor, type_styles
|
||||
from ..theme.manager import current
|
||||
from ..theme.tokens import Radius
|
||||
|
||||
|
||||
class ConnectionChip(QWidget):
|
||||
_DOT = 7 # §1.2 status dot box
|
||||
_HPAD = 10
|
||||
_GAP = 6
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._connected = False
|
||||
self._source = ""
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
def set_state(self, connected: bool, source: str = "") -> None:
|
||||
"""``source`` is the port label (COM5, SIM); kept until replaced."""
|
||||
self._connected = connected
|
||||
if source:
|
||||
self._source = source
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
|
||||
def text(self) -> str:
|
||||
if self._connected:
|
||||
return f"Connected · {self._source}" if self._source else "Connected"
|
||||
return "Not connected"
|
||||
|
||||
def sizeHint(self) -> QSize: # noqa: N802 (Qt)
|
||||
fm = QFontMetrics(type_styles.status_label())
|
||||
w = self._HPAD * 2 + self._DOT + self._GAP + fm.horizontalAdvance(self.text())
|
||||
return QSize(w, fm.height() + 8)
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802 (Qt)
|
||||
t = current()
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pill = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
if self._connected:
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(qcolor(t.ok_bg))
|
||||
else:
|
||||
p.setPen(QPen(qcolor(t.border)))
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.drawRoundedRect(pill, Radius.SM, Radius.SM)
|
||||
|
||||
d = float(self._DOT)
|
||||
box = QRectF(self._HPAD, pill.center().y() - d / 2, d, d)
|
||||
if self._connected:
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(qcolor(t.ok))
|
||||
p.drawEllipse(box)
|
||||
else:
|
||||
pen = QPen(qcolor(t.faint))
|
||||
pen.setWidthF(1.5)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.drawEllipse(box.adjusted(0.75, 0.75, -0.75, -0.75))
|
||||
|
||||
p.setFont(type_styles.status_label())
|
||||
p.setPen(qcolor(t.ok if self._connected else t.faint))
|
||||
text_rect = QRectF(
|
||||
box.right() + self._GAP,
|
||||
0,
|
||||
max(0.0, self.width() - box.right() - self._GAP),
|
||||
self.height(),
|
||||
)
|
||||
p.drawText(
|
||||
text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.text()
|
||||
)
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Grouped Setting / Value list (spec §5.5) — Station tabs, IOModbus Device Settings.
|
||||
|
||||
Declarative: build the structure once (``add_group`` / ``add_setting`` /
|
||||
``add_toggle`` / ``add_choice``), then push values with ``set_value``. User edits
|
||||
come back through ``settingEdited(key, raw)`` where raw is wire-ready ("1"/"0" for
|
||||
toggles, the number for choices). Read-only rows render an RO chip with the value
|
||||
in ``sub`` and never enter edit mode.
|
||||
``add_toggle`` / ``add_choice`` / ``add_custom``; ``clear`` drops it for a rebuild),
|
||||
then push values with ``set_value``. User edits come back through
|
||||
``settingEdited(key, raw)`` where raw is wire-ready ("1"/"0" for toggles, the number
|
||||
for choices); ``add_custom`` rows instead emit ``settingActivated(key)`` so the owner
|
||||
can open a modal dialog. Read-only rows render an RO chip with the value in ``sub``
|
||||
and never enter edit mode.
|
||||
|
||||
The delegate extends InstrumentDelegate, so single-click editing, the styled
|
||||
editor, Tab-move and write feedback all apply here too.
|
||||
@@ -163,12 +165,13 @@ class SettingsDelegate(InstrumentDelegate):
|
||||
class _Row:
|
||||
key: str
|
||||
row: int
|
||||
kind: str # "text" | "toggle" | "choice"
|
||||
kind: str # "text" | "toggle" | "choice" | "custom"
|
||||
choices: dict[str, str] | None = None # raw -> display label
|
||||
|
||||
|
||||
class SettingsList(QWidget):
|
||||
settingEdited = Signal(str, str) # (key, raw wire value)
|
||||
settingEdited = Signal(str, str) # (key, raw wire value)
|
||||
settingActivated = Signal(str) # custom rows: clicked, edited via a modal dialog
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -194,6 +197,15 @@ class SettingsList(QWidget):
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# --- structure ---------------------------------------------------------------
|
||||
def clear(self) -> None:
|
||||
"""Drop every group and setting row (wire row-set changed → rebuild)."""
|
||||
self._loading = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
finally:
|
||||
self._loading = False
|
||||
self._rows.clear()
|
||||
|
||||
def _append_row(self) -> int:
|
||||
row = self.table.rowCount()
|
||||
self._loading = True
|
||||
@@ -259,9 +271,26 @@ class SettingsList(QWidget):
|
||||
item.setData(KIND_ROLE, CellKind.NUMERIC) # §5.5: value column right-aligned mono
|
||||
self._add_row(key, label, hint, item, "choice", dict(choices))
|
||||
|
||||
def add_custom(self, key: str, label: str, *, hint: str = "") -> None:
|
||||
"""A value row edited through a modal dialog, not inline (e.g. subnet).
|
||||
|
||||
Clicking the value emits ``settingActivated(key)``; ``set_value`` still
|
||||
drives the display text. No edit affordance paints (not ItemIsEditable).
|
||||
"""
|
||||
item = QTableWidgetItem("")
|
||||
item.setData(KIND_ROLE, CellKind.NUMERIC)
|
||||
item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||||
self._add_row(key, label, hint, item, "custom")
|
||||
|
||||
# --- values ----------------------------------------------------------------
|
||||
def set_value(self, key: str, raw: str) -> None:
|
||||
entry = self._rows[key]
|
||||
if (
|
||||
self.table.state() == QAbstractItemView.State.EditingState
|
||||
and self.table.currentRow() == entry.row
|
||||
and self.table.currentColumn() == 1
|
||||
):
|
||||
return # never clobber the value the user is typing (BL-DS-P3)
|
||||
item = self.table.item(entry.row, 1)
|
||||
self._loading = True
|
||||
try:
|
||||
@@ -280,6 +309,26 @@ class SettingsList(QWidget):
|
||||
entry = self._rows[key]
|
||||
return self._raw(entry, self.table.item(entry.row, 1))
|
||||
|
||||
def key_at_row(self, row: int) -> str | None:
|
||||
"""The setting key shown at table ``row``, or None for group/empty rows.
|
||||
|
||||
Lets owners map a context-menu click (which only knows the table row) back
|
||||
to a setting key without reaching into the private ``_rows`` map."""
|
||||
entry = next((r for r in self._rows.values() if r.row == row), None)
|
||||
return entry.key if entry is not None else None
|
||||
|
||||
def set_tooltip(self, key: str, text: str) -> None:
|
||||
"""Hover help for one setting row (replaces the old in-cell ⓘ markers)."""
|
||||
entry = self._rows[key]
|
||||
self._loading = True
|
||||
try:
|
||||
for col in (0, 1):
|
||||
item = self.table.item(entry.row, col)
|
||||
if item is not None:
|
||||
item.setToolTip(text)
|
||||
finally:
|
||||
self._loading = False
|
||||
|
||||
# --- edit plumbing -----------------------------------------------------------
|
||||
def _raw(self, entry: _Row, item: QTableWidgetItem) -> str:
|
||||
if entry.kind == "toggle":
|
||||
@@ -305,6 +354,11 @@ class SettingsList(QWidget):
|
||||
item = self.table.item(row, col)
|
||||
if item is None:
|
||||
return
|
||||
if col == 1:
|
||||
entry = next((r for r in self._rows.values() if r.row == row), None)
|
||||
if entry is not None and entry.kind == "custom":
|
||||
self.settingActivated.emit(entry.key)
|
||||
return
|
||||
flags = item.flags()
|
||||
if flags & Qt.ItemFlag.ItemIsEditable and flags & Qt.ItemFlag.ItemIsEnabled:
|
||||
self.table.editItem(item)
|
||||
|
||||
@@ -25,7 +25,7 @@ from cim_suite.core.export import Cell, Sheet
|
||||
|
||||
from .copy_menu import attach_copy_menu
|
||||
from .help import HELP_MARK
|
||||
from .kit import CellKind, InstrumentDelegate, UnitsHeaderView
|
||||
from .kit import CellKind, InstrumentDelegate, SummaryStrip, UnitsHeaderView
|
||||
from .theme import Space
|
||||
|
||||
# Qt's resizeColumnToContents derives width from the delegate's size hint, which with
|
||||
@@ -112,12 +112,28 @@ class TableTab(QWidget):
|
||||
if flags & Qt.ItemFlag.ItemIsEditable and flags & Qt.ItemFlag.ItemIsEnabled:
|
||||
self.table.editItem(item)
|
||||
|
||||
def _editing_cell(self) -> tuple[int, int] | None:
|
||||
"""(row, col) under an open inline editor, or None.
|
||||
|
||||
``set_rows`` skips this cell so a live stream refill never clobbers what the
|
||||
user is typing (Qt's updateEditorData would overwrite the editor text). The
|
||||
skipped cell catches up on the next rebuild or on the user's commit.
|
||||
"""
|
||||
if self.table.state() == QAbstractItemView.State.EditingState:
|
||||
idx = self.table.currentIndex()
|
||||
if idx.isValid():
|
||||
return (idx.row(), idx.column())
|
||||
return None
|
||||
|
||||
def set_rows(self, rows: Sequence[Sequence[str]]) -> None:
|
||||
self._loading = True
|
||||
try:
|
||||
editing = self._editing_cell()
|
||||
self.table.setRowCount(len(rows))
|
||||
for r, row in enumerate(rows):
|
||||
for col, value in enumerate(row):
|
||||
if (r, col) == editing:
|
||||
continue # never replace the cell under an open editor
|
||||
if col in self._checkable:
|
||||
item = QTableWidgetItem("")
|
||||
flags = item.flags() & ~Qt.ItemFlag.ItemIsEditable
|
||||
@@ -154,6 +170,13 @@ 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``."""
|
||||
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).
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ QToolButton:checked {{
|
||||
color: {t.accent};
|
||||
}}
|
||||
QToolButton:disabled {{ color: {t.faint}; }}
|
||||
QToolButton::menu-indicator {{ image: none; }}
|
||||
QToolButton#PrimaryAction {{
|
||||
background-color: {t.accent};
|
||||
color: {t.on_accent};
|
||||
@@ -93,6 +94,11 @@ QToolButton#PrimaryAction {{
|
||||
QToolButton#PrimaryAction:hover {{ background-color: {t.accent_hover}; }}
|
||||
QToolButton#PrimaryAction:pressed {{ background-color: {t.accent_pressed}; }}
|
||||
|
||||
/* QWidget {{ background: {t.bg} }} would otherwise paint the spacer and brand
|
||||
labels gray against the toolbar's surface-white background. */
|
||||
QToolBar QLabel {{ background: transparent; }}
|
||||
QWidget#ToolbarSpacer {{ background: transparent; }}
|
||||
|
||||
QLabel#BrandTitle {{
|
||||
color: {t.ink};
|
||||
font-size: 15px;
|
||||
@@ -313,6 +319,12 @@ QLabel#HelpText {{
|
||||
font-size: 11px;
|
||||
}}
|
||||
|
||||
/* ---- Chart header strip (spec §5.10: history/trend dialog) ------------- */
|
||||
QLabel#ChartHeader {{
|
||||
color: {t.sub};
|
||||
padding: 2px 0px;
|
||||
}}
|
||||
|
||||
/* ---- Status footer (spec §4: dark instrument readout, both themes) ----- */
|
||||
QStatusBar {{
|
||||
background-color: {t.footer};
|
||||
|
||||
@@ -47,6 +47,8 @@ class Da12Module:
|
||||
|
||||
if self._controller is None:
|
||||
return
|
||||
if self._window is not None:
|
||||
self._window.set_connection_source(port)
|
||||
if self._controller._transport is not None:
|
||||
self._controller.stop()
|
||||
self._controller.attach(SerialTransport(port))
|
||||
@@ -68,6 +70,8 @@ class Da12Module:
|
||||
return
|
||||
from .transport.simulator import SimulatedStation
|
||||
|
||||
if self._window is not None:
|
||||
self._window.set_connection_source("SIM")
|
||||
self._sim = SimulatedStation(sensors=4)
|
||||
self._controller.attach(self._sim)
|
||||
self._controller.start()
|
||||
|
||||
@@ -3,24 +3,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cim_suite.core.sensor_models import identify
|
||||
from cim_suite.core.ui.kit import CellKind
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
|
||||
HEADERS = ["#", "Serial", "Model", "Name", "Enabled", "Delay", "Lo Alarm", "Lo Warn", "Hi Warn", "Hi Alarm"]
|
||||
_EDIT_MAP = {
|
||||
4: "set_limit_enable",
|
||||
5: "set_limit_delay",
|
||||
6: "set_limit_lo_alarm",
|
||||
7: "set_limit_lo_warn",
|
||||
8: "set_limit_hi_warn",
|
||||
9: "set_limit_hi_alarm",
|
||||
}
|
||||
_ENABLE_COL = 4
|
||||
|
||||
|
||||
def _truthy_enable(value) -> bool:
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
class AlarmLimitsTab(TableTab):
|
||||
def __init__(self, controller, *, repository=None) -> None:
|
||||
super().__init__(HEADERS, editable_cols=_EDIT_MAP.keys())
|
||||
super().__init__(HEADERS, editable_cols=_EDIT_MAP.keys(), checkable_cols=(_ENABLE_COL,))
|
||||
self._ctrl = controller
|
||||
self._repo = repository
|
||||
|
||||
self.delegate.set_column_kind(0, CellKind.NUMERIC)
|
||||
self.delegate.set_column_kind(1, CellKind.IDENTIFIER)
|
||||
for col in (5, 6, 7, 8, 9):
|
||||
self.delegate.set_column_kind(col, CellKind.NUMERIC)
|
||||
self.set_column_units({"Delay": "s"})
|
||||
self.enable_summary("✎ Click a value to edit · Enter saves · Esc cancels")
|
||||
self._pending_writes: set[tuple[int, int]] = set()
|
||||
|
||||
from cim_suite.core.ui.copy_menu import set_extra_actions
|
||||
|
||||
set_extra_actions(self.table, self._row_actions)
|
||||
@@ -32,8 +46,13 @@ class AlarmLimitsTab(TableTab):
|
||||
self.set_column_help(H.ALARM_LIMITS_COLUMNS)
|
||||
|
||||
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()
|
||||
|
||||
rows = []
|
||||
for lim in self._ctrl.limits.all():
|
||||
limits = self._ctrl.limits.all()
|
||||
for lim in limits:
|
||||
rec = self._ctrl.sensors.get(lim.id)
|
||||
sm = identify(rec.serial) if rec else None
|
||||
rows.append([
|
||||
@@ -47,6 +66,35 @@ class AlarmLimitsTab(TableTab):
|
||||
])
|
||||
self.set_rows(rows)
|
||||
|
||||
enabled = sum(1 for lim in limits if _truthy_enable(lim.enable))
|
||||
self.summary.set_summary([
|
||||
(f"{len(limits)} sensors", None),
|
||||
(f"{enabled} enabled", None),
|
||||
])
|
||||
|
||||
def on_check(self, row: int, col: int, checked: bool) -> None:
|
||||
if col != _ENABLE_COL:
|
||||
return
|
||||
try:
|
||||
sid = int(self.row_value(row, 0))
|
||||
except ValueError:
|
||||
return
|
||||
self._ctrl.set_limit_enable(sid, "1" if checked else "0")
|
||||
self.delegate.mark_pending(row, col)
|
||||
self._pending_writes.add((row, col))
|
||||
|
||||
def on_edit(self, row: int, col: int, value: str) -> None:
|
||||
setter = _EDIT_MAP.get(col)
|
||||
if setter is None:
|
||||
return
|
||||
try:
|
||||
sid = int(self.row_value(row, 0))
|
||||
except ValueError:
|
||||
return
|
||||
getattr(self._ctrl, setter)(sid, value)
|
||||
self.delegate.mark_pending(row, col)
|
||||
self._pending_writes.add((row, col))
|
||||
|
||||
def _row_actions(self, row: int, col: int):
|
||||
if self._repo is None:
|
||||
return []
|
||||
@@ -65,16 +113,6 @@ class AlarmLimitsTab(TableTab):
|
||||
)
|
||||
]
|
||||
|
||||
def on_edit(self, row: int, col: int, value: str) -> None:
|
||||
setter = _EDIT_MAP.get(col)
|
||||
if setter is None:
|
||||
return
|
||||
try:
|
||||
sid = int(self.row_value(row, 0))
|
||||
except ValueError:
|
||||
return
|
||||
getattr(self._ctrl, setter)(sid, value)
|
||||
|
||||
|
||||
def _fmt(v) -> str:
|
||||
return "" if v is None else f"{v:g}"
|
||||
|
||||
@@ -17,6 +17,7 @@ from cim_suite.core.ui.chrome import message_box
|
||||
from .. import config
|
||||
from ..domain import calibration as cal
|
||||
from cim_suite.core.sensor_models import identify
|
||||
from cim_suite.core.ui.kit import CellKind
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
|
||||
HEADERS = ["#", "Serial #", "Model", "Name", "Value Before", "Scale", "Offset",
|
||||
@@ -28,6 +29,15 @@ class CalibrationTab(TableTab):
|
||||
super().__init__(HEADERS, editable_cols=())
|
||||
self._ctrl = controller
|
||||
self._csv = Path(csv_path) if csv_path else config.cal_csv_path()
|
||||
|
||||
self.delegate.set_column_kind(0, CellKind.NUMERIC)
|
||||
self.delegate.set_column_kind(1, CellKind.IDENTIFIER)
|
||||
for col in (4, 5, 6, 7, 8, 9): # values + both scale/offset pairs
|
||||
self.delegate.set_column_kind(col, CellKind.NUMERIC)
|
||||
# duplicate "Scale"/"Offset" headers → units mapped by column index
|
||||
self.set_column_units({5: "×", 6: "+", 8: "×", 9: "+"})
|
||||
self.enable_summary() # read-only grid: no edit hint
|
||||
|
||||
controller.calibrationLogged.connect(self.rebuild)
|
||||
self.rebuild()
|
||||
from .. import help as H
|
||||
@@ -66,6 +76,7 @@ class CalibrationTab(TableTab):
|
||||
self._records = cal.read_csv(self._csv)
|
||||
rows = [self._display_row(r) for r in self._records]
|
||||
self.set_rows(rows)
|
||||
self.summary.set_summary([(f"{len(rows)} records", None)])
|
||||
|
||||
def _copy(self) -> None:
|
||||
lines = ["\t".join(HEADERS)]
|
||||
|
||||
@@ -21,6 +21,7 @@ from PySide6.QtGui import QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -32,7 +33,8 @@ from cim_suite.core.export import Cell, Sheet
|
||||
from cim_suite.core.ui.chrome import ChromeDialog
|
||||
from cim_suite.core.ui.copy_menu import attach_copy_menu
|
||||
from cim_suite.core.ui.export_action import save_sheets_dialog
|
||||
from cim_suite.core.ui.theme import Space, current, qcolor
|
||||
from cim_suite.core.ui.kit import CellKind, InstrumentDelegate
|
||||
from cim_suite.core.ui.theme import Space, current, qcolor, type_styles
|
||||
|
||||
from .. import config
|
||||
from ..domain.history import Sample
|
||||
@@ -41,6 +43,12 @@ _TABLE_HEADERS = ["Timestamp", "Average", "Current", "Alarm"]
|
||||
_DEFAULT_RECORDS = 500
|
||||
|
||||
|
||||
def _pen(token: str, width: float) -> QPen:
|
||||
pen = QPen(qcolor(token))
|
||||
pen.setWidthF(width)
|
||||
return pen
|
||||
|
||||
|
||||
class HistoryDialog(ChromeDialog):
|
||||
def __init__(self, controller, record, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -54,10 +62,21 @@ class HistoryDialog(ChromeDialog):
|
||||
layout = QVBoxLayout(self.body)
|
||||
layout.setContentsMargins(Space.LG, Space.LG, Space.LG, Space.LG)
|
||||
layout.setSpacing(Space.MD)
|
||||
|
||||
self._header = QLabel(
|
||||
f"LIVE TREND — #{record.id} {(record.name or record.serial)}".upper()
|
||||
)
|
||||
self._header.setObjectName("ChartHeader")
|
||||
self._header.setFont(type_styles.column_header())
|
||||
layout.addWidget(self._header)
|
||||
|
||||
self._build_toolbar(layout)
|
||||
self._build_chart(layout)
|
||||
self._build_table(layout)
|
||||
|
||||
from cim_suite.core.ui.theme import manager
|
||||
manager.signals.themeChanged.connect(self._apply_chart_theme)
|
||||
|
||||
controller.historyChanged.connect(self._on_history_changed)
|
||||
controller.limitsChanged.connect(self._draw_limit_lines)
|
||||
self._reload()
|
||||
@@ -70,6 +89,11 @@ class HistoryDialog(ChromeDialog):
|
||||
self._ctrl.limitsChanged.disconnect(self._draw_limit_lines)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
try:
|
||||
from cim_suite.core.ui.theme import manager
|
||||
manager.signals.themeChanged.disconnect(self._apply_chart_theme)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
super().closeEvent(event)
|
||||
|
||||
# --- construction ------------------------------------------------------
|
||||
@@ -93,22 +117,17 @@ class HistoryDialog(ChromeDialog):
|
||||
def _build_chart(self, layout: QVBoxLayout) -> None:
|
||||
self._chart = QChart()
|
||||
self._chart.legend().setVisible(True)
|
||||
self._chart.legend().setAlignment(Qt.AlignmentFlag.AlignBottom)
|
||||
|
||||
self._avg = QLineSeries()
|
||||
self._avg.setName("Average")
|
||||
self._avg.setPen(QPen(qcolor(current().chart["average"]), 2))
|
||||
self._avg.setName("AVERAGE")
|
||||
self._cur = QLineSeries()
|
||||
self._cur.setName("Current")
|
||||
self._cur.setPen(QPen(qcolor(current().chart["current"]), 1))
|
||||
self._cur.setName("CURRENT")
|
||||
self._chart.addSeries(self._avg)
|
||||
self._chart.addSeries(self._cur)
|
||||
|
||||
self._axis_x = QDateTimeAxis()
|
||||
self._axis_x.setFormat("HH:mm:ss")
|
||||
self._axis_x.setTitleText("Time")
|
||||
self._axis_y = QValueAxis()
|
||||
self._axis_y.setTitleText("Value")
|
||||
self._chart.addAxis(self._axis_x, Qt.AlignmentFlag.AlignBottom)
|
||||
self._chart.addAxis(self._axis_y, Qt.AlignmentFlag.AlignLeft)
|
||||
for s in (self._avg, self._cur):
|
||||
@@ -121,7 +140,6 @@ class HistoryDialog(ChromeDialog):
|
||||
self._limit_series: list[QLineSeries] = []
|
||||
for _ in range(4):
|
||||
ls = QLineSeries()
|
||||
ls.setPen(QPen(qcolor(current().chart["limit"]), 1, Qt.PenStyle.DashLine))
|
||||
self._chart.addSeries(ls)
|
||||
ls.attachAxis(self._axis_x)
|
||||
ls.attachAxis(self._axis_y)
|
||||
@@ -135,12 +153,54 @@ class HistoryDialog(ChromeDialog):
|
||||
self._view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
layout.addWidget(self._view, stretch=3)
|
||||
|
||||
# Apply theme-derived styling (colors, pens, legend, axes).
|
||||
self._apply_chart_theme()
|
||||
|
||||
def _apply_chart_theme(self, *_args) -> None:
|
||||
"""§5.10 colors/pens from the live theme — rerun on themeChanged."""
|
||||
t = current()
|
||||
self._chart.setBackgroundBrush(qcolor(t.surface))
|
||||
self._chart.setPlotAreaBackgroundBrush(qcolor(t.surface))
|
||||
self._chart.setPlotAreaBackgroundVisible(True)
|
||||
self._chart.setBackgroundRoundness(0)
|
||||
|
||||
self._avg.setPen(_pen(t.chart["average"], 1.6))
|
||||
self._cur.setPen(_pen(t.chart["current"], 1.0))
|
||||
|
||||
for i, ls in enumerate(self._limit_series):
|
||||
color = qcolor(t.chart["limit"])
|
||||
if i in (0, 1): # lo_alarm, lo_warn — §5.10: low limit ~55%
|
||||
color.setAlphaF(0.55)
|
||||
pen = QPen(color)
|
||||
pen.setWidthF(1.0)
|
||||
pen.setStyle(Qt.PenStyle.CustomDashLine)
|
||||
pen.setDashPattern([5.0, 4.0]) # §5.10: 5-4 dash
|
||||
ls.setPen(pen)
|
||||
|
||||
for axis in (self._axis_x, self._axis_y):
|
||||
axis.setGridLinePen(_pen(t.hair, 1.0))
|
||||
axis.setLinePen(_pen(t.border, 1.0))
|
||||
axis.setLabelsBrush(qcolor(t.faint))
|
||||
axis.setLabelsFont(type_styles.units_row()) # mono 9.5
|
||||
|
||||
legend = self._chart.legend()
|
||||
legend.setAlignment(Qt.AlignmentFlag.AlignTop) # §5.10: above the plot
|
||||
legend.setFont(type_styles.units_row()) # mono microcaps
|
||||
legend.setLabelColor(qcolor(t.sub))
|
||||
|
||||
def _build_table(self, layout: QVBoxLayout) -> None:
|
||||
self._table = QTableWidget(0, len(_TABLE_HEADERS), self)
|
||||
self._table.setHorizontalHeaderLabels(_TABLE_HEADERS)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
attach_copy_menu(self._table)
|
||||
|
||||
delegate = InstrumentDelegate(self._table)
|
||||
delegate.set_column_kind(0, CellKind.IDENTIFIER) # timestamp — recessive mono
|
||||
delegate.set_column_kind(1, CellKind.NUMERIC)
|
||||
delegate.set_column_kind(2, CellKind.NUMERIC)
|
||||
self._table.setItemDelegate(delegate)
|
||||
|
||||
layout.addWidget(self._table, stretch=2)
|
||||
|
||||
# --- data --------------------------------------------------------------
|
||||
@@ -224,6 +284,12 @@ class HistoryDialog(ChromeDialog):
|
||||
ls.setVisible(True)
|
||||
else:
|
||||
ls.setVisible(False)
|
||||
# QtCharts re-shows a series' legend marker when the series turns visible;
|
||||
# the limit guides are not legend entries (§5.10) — keep them hidden.
|
||||
legend = self._chart.legend()
|
||||
for ls in self._limit_series:
|
||||
for marker in legend.markers(ls):
|
||||
marker.setVisible(False)
|
||||
|
||||
def _fill_table(self, samples: list[Sample]) -> None:
|
||||
self._table.setRowCount(len(samples))
|
||||
|
||||
@@ -5,20 +5,22 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cim_suite.core.ui.chrome import StatusFooter
|
||||
from cim_suite.core.ui.copy_menu import enable_copy_in
|
||||
from cim_suite.core.ui.export_action import add_export_actions
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cim_suite.core.ui.chrome import message_box
|
||||
from cim_suite.core.ui.kit import InstrumentTabWidget
|
||||
from cim_suite.core.ui.kit import ConnectionChip, InstrumentTabWidget
|
||||
|
||||
from ..domain.server_link import ServerLinkState, ServerLinkStatus
|
||||
from ..protocol import messages as m
|
||||
@@ -31,6 +33,9 @@ from .statistics_tab import StatisticsTab
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
# Emitted when the user clicks ← Suite; SuiteWindow connects to show_launcher.
|
||||
suiteRequested = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
@@ -44,11 +49,12 @@ class MainWindow(QMainWindow):
|
||||
self._repo = repository
|
||||
self._setting_history_dialogs: list = []
|
||||
self._connected = False
|
||||
self._source = "" # port label shown in the connection chip (COM5, SIM)
|
||||
self._station_time = ""
|
||||
self._prev_buffered: int | None = None
|
||||
self._prev_comm: int | None = None
|
||||
self.setWindowTitle("DA Series Monitoring Station Service Tool")
|
||||
self.resize(1100, 720)
|
||||
self.resize(1280, 760)
|
||||
|
||||
self.tabs = InstrumentTabWidget()
|
||||
self.station_tab = StationTab(controller, repository=repository)
|
||||
@@ -74,70 +80,84 @@ class MainWindow(QMainWindow):
|
||||
tb = self.addToolBar("Main")
|
||||
tb.setMovable(False)
|
||||
|
||||
# Brand wordmark on the left: styled by the BrandTitle QSS rule.
|
||||
brand = QLabel("DA-12 Service Tool")
|
||||
brand.setObjectName("BrandTitle")
|
||||
tb.addWidget(brand)
|
||||
# §5.2: [← Suite] | DA-12 Service Tool [chip] …… [actions] | [Connect]
|
||||
self.act_suite = QAction("← Suite", self)
|
||||
self.act_suite.triggered.connect(self.suiteRequested.emit)
|
||||
tb.addAction(self.act_suite)
|
||||
tb.addSeparator()
|
||||
|
||||
act_connect = QAction("Connect…", self)
|
||||
act_connect.triggered.connect(self._connect)
|
||||
tb.addAction(act_connect)
|
||||
# Highlight Connect as the primary call-to-action.
|
||||
btn = tb.widgetForAction(act_connect)
|
||||
if btn is not None:
|
||||
btn.setObjectName("PrimaryAction")
|
||||
brand = QLabel("DA-12")
|
||||
brand.setObjectName("BrandTitle")
|
||||
tb.addWidget(brand)
|
||||
descriptor = QLabel("Service Tool")
|
||||
descriptor.setObjectName("BrandTitleAccent")
|
||||
tb.addWidget(descriptor)
|
||||
self.chip = ConnectionChip()
|
||||
tb.addWidget(self.chip)
|
||||
|
||||
# Push everything left; remaining actions float right.
|
||||
spacer = QWidget()
|
||||
spacer.setObjectName("ToolbarSpacer")
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
tb.addWidget(spacer)
|
||||
|
||||
act_refresh = QAction("Refresh", self)
|
||||
act_refresh.triggered.connect(self._ctrl.refresh)
|
||||
tb.addAction(act_refresh)
|
||||
|
||||
act_clock = QAction("Set Clock", self)
|
||||
act_clock.triggered.connect(lambda: self._ctrl.set_clock())
|
||||
tb.addAction(act_clock)
|
||||
|
||||
act_reset = QAction("Reboot Station", self)
|
||||
act_reset.triggered.connect(self._reset)
|
||||
tb.addAction(act_reset)
|
||||
|
||||
# Push the toggle to the right edge of the toolbar.
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
tb.addWidget(spacer)
|
||||
|
||||
self.act_log = QAction("Log Measurements", self)
|
||||
self.act_log.setCheckable(True)
|
||||
self.act_log.toggled.connect(self._ctrl.set_logging)
|
||||
tb.addAction(self.act_log)
|
||||
|
||||
from .. import help as H
|
||||
from cim_suite.core.ui.help import attach_help
|
||||
|
||||
attach_help(act_connect, H.BUTTONS["Connect…"])
|
||||
attach_help(act_refresh, H.BUTTONS["Refresh"])
|
||||
attach_help(act_clock, H.BUTTONS["Set Clock"])
|
||||
attach_help(act_reset, H.BUTTONS["Reboot Station"])
|
||||
attach_help(self.act_log, H.BUTTONS["Log Measurements"])
|
||||
|
||||
from .. import config
|
||||
|
||||
tb.addSeparator()
|
||||
add_export_actions(
|
||||
tb,
|
||||
self.tabs,
|
||||
self,
|
||||
self._export_metadata,
|
||||
file_prefix="DA12",
|
||||
last_dir=config.export_dir,
|
||||
tb, self.tabs, self, self._export_metadata,
|
||||
file_prefix="DA12", last_dir=config.export_dir,
|
||||
remember_dir=config.set_export_dir,
|
||||
)
|
||||
|
||||
if self._repo is not None:
|
||||
tb.addSeparator()
|
||||
act_hist = QAction("Setting history…", self)
|
||||
act_hist.triggered.connect(self._open_setting_history)
|
||||
tb.addAction(act_hist)
|
||||
|
||||
tb.addSeparator()
|
||||
# §5.2: rare/risky commands behind a confirm-guarded menu.
|
||||
menu_btn = QToolButton(tb)
|
||||
menu_btn.setText("Station commands ▾")
|
||||
self.station_menu = QMenu(menu_btn)
|
||||
self.act_clock = self.station_menu.addAction("Set Clock…")
|
||||
self.act_clock.triggered.connect(self._set_clock)
|
||||
self.act_reboot = self.station_menu.addAction("Reboot Station…")
|
||||
self.act_reboot.triggered.connect(self._reset)
|
||||
menu_btn.setMenu(self.station_menu)
|
||||
menu_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
tb.addWidget(menu_btn)
|
||||
|
||||
tb.addSeparator()
|
||||
self.act_connect = QAction("Connect…", self)
|
||||
self.act_connect.triggered.connect(self._toggle_connection)
|
||||
tb.addAction(self.act_connect)
|
||||
# Highlight Connect/Disconnect as the primary call-to-action.
|
||||
btn = tb.widgetForAction(self.act_connect)
|
||||
if btn is not None:
|
||||
btn.setObjectName("PrimaryAction")
|
||||
|
||||
from .. import help as H
|
||||
from cim_suite.core.ui.help import attach_help
|
||||
|
||||
# attach_help appends a ⓘ marker to the action text. _on_connection
|
||||
# overwrites act_connect's text on each state change — the marker is
|
||||
# intentionally dropped then (tooltip remains wired, marker not re-added
|
||||
# on toggle to avoid stacking ⓘⓘ). That is acceptable: the tooltip
|
||||
# stays intact and the ⓘ on initial Connect… still shows on first open.
|
||||
attach_help(self.act_connect, H.BUTTONS["Connect…"])
|
||||
attach_help(act_refresh, H.BUTTONS["Refresh"])
|
||||
attach_help(self.act_clock, H.BUTTONS["Set Clock"])
|
||||
attach_help(self.act_reboot, H.BUTTONS["Reboot Station"])
|
||||
attach_help(self.act_log, H.BUTTONS["Log Measurements"])
|
||||
|
||||
def _open_setting_history(self) -> None:
|
||||
from cim_suite.core.repository.ui.history_dialog import setting_history_action
|
||||
from ..domain.repo_snapshot import setting_writer, station_mac
|
||||
@@ -170,6 +190,17 @@ class MainWindow(QMainWindow):
|
||||
self.footer.add_field("station", "Station", side="right")
|
||||
|
||||
# --- actions -----------------------------------------------------------
|
||||
def set_connection_source(self, source: str) -> None:
|
||||
"""The port label shown in the chip (COM5, SIM). Called by the module."""
|
||||
self._source = source
|
||||
self.chip.set_state(self._connected, source)
|
||||
|
||||
def _toggle_connection(self) -> None:
|
||||
if self._connected:
|
||||
self._ctrl.stop()
|
||||
else:
|
||||
self._connect()
|
||||
|
||||
def _connect(self) -> None:
|
||||
from .. import config
|
||||
|
||||
@@ -179,6 +210,7 @@ class MainWindow(QMainWindow):
|
||||
port = dlg.selected_port()
|
||||
cfg["port"] = port
|
||||
config.save_config(cfg)
|
||||
self.set_connection_source(port)
|
||||
if self._connector is not None:
|
||||
self._connector(port)
|
||||
|
||||
@@ -186,10 +218,20 @@ class MainWindow(QMainWindow):
|
||||
if message_box.question(self, "Reboot Station", "Reboot the station now?"):
|
||||
self._ctrl.reboot_station()
|
||||
|
||||
def _set_clock(self) -> None:
|
||||
if message_box.question(
|
||||
self, "Set Clock", "Set the station clock from this computer's clock?"
|
||||
):
|
||||
self._ctrl.set_clock()
|
||||
|
||||
# --- signal handlers ---------------------------------------------------
|
||||
def _on_connection(self, connected: bool) -> None:
|
||||
self._connected = connected
|
||||
self.footer.set_dot("link", "ok" if connected else "off")
|
||||
self.chip.set_state(connected, self._source)
|
||||
# Overwrite text directly; the ⓘ marker from attach_help is intentionally
|
||||
# not re-applied on toggle (tooltip stays intact, no ⓘⓘ stacking risk).
|
||||
self.act_connect.setText("Disconnect" if connected else "Connect…")
|
||||
|
||||
def _on_status(self, status: m.StationStatus) -> None:
|
||||
if status.station_clock:
|
||||
|
||||
@@ -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)) # row index may go stale if the grid reorders before resolve; cosmetic only
|
||||
|
||||
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))
|
||||
|
||||
108
cim_suite/modules/da12/ui/station_settings_meta.py
Normal file
108
cim_suite/modules/da12/ui/station_settings_meta.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Per-setting presentation metadata for the DA-12 Station tab (spec §5.5).
|
||||
|
||||
Maps each known wire label (matched via ``setting_match_key`` so the station's
|
||||
parenthetical hints don't matter) to its settings-list group, clean label,
|
||||
constraint hint, and control kind. Unknown labels — future firmware fields —
|
||||
fall back to the Advanced group with the parenthetical (if any) as the hint, so
|
||||
nothing the station sends ever disappears.
|
||||
|
||||
Kinds: "text" (inline editor), "toggle" (0/1 switch), "choice" (dropdown showing
|
||||
the meaning, storing the number), "custom" (modal dialog — subnet). Read-only is
|
||||
NOT decided here: the wire's ``type_code < 0`` governs (see StationTab).
|
||||
|
||||
Hints quote the station's own parenthetical semantics (captured hardware labels in
|
||||
``transport/simulator.py::_SEED_SETTINGS``); reboot-required wording mirrors
|
||||
``help.py``. Reporting mode / OP05 mode (0-7) stay "text": we have no verified
|
||||
value-meaning table, and inventing one violates the VB6-fidelity rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cim_suite.core.ui.help import setting_match_key
|
||||
|
||||
GROUPS = ("Identity", "Communication", "Sensors", "Alarms & beeper", "OP05", "Advanced")
|
||||
|
||||
_PAREN = re.compile(r"\s*\(([^)]*)\)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingMeta:
|
||||
group: str
|
||||
label: str
|
||||
hint: str = ""
|
||||
kind: str = "text" # text | toggle | choice | custom
|
||||
choices: dict[str, str] = field(default_factory=dict) # raw -> display
|
||||
|
||||
|
||||
def _m(group, label, hint="", kind="text", choices=None):
|
||||
return SettingMeta(group, label, hint, kind, dict(choices or {}))
|
||||
|
||||
|
||||
META: dict[str, SettingMeta] = {
|
||||
setting_match_key(k): v
|
||||
for k, v in {
|
||||
# --- Identity --------------------------------------------------------
|
||||
"Station name": _m("Identity", "Station name", "16 chars max"),
|
||||
"Station's mac address": _m("Identity", "Station's MAC address"),
|
||||
"Firmware version": _m("Identity", "Firmware version", "primary.build"),
|
||||
"Battery Run Time": _m("Identity", "Battery run time", "hours"),
|
||||
# --- Communication ---------------------------------------------------
|
||||
"Comm. mode": _m("Communication", "Comm. mode", "0 = normal · >0 = test"),
|
||||
"Reporting interval": _m("Communication", "Reporting interval", "updates per report"),
|
||||
"Reporting mode": _m("Communication", "Reporting mode", "0–7"),
|
||||
"Host comm loss timeout": _m("Communication", "Host comm loss timeout", "seconds"),
|
||||
"Server port number": _m("Communication", "Server port number", "reboot required"),
|
||||
"Server IP address": _m("Communication", "Server IP address", "reboot required"),
|
||||
"Local port number": _m(
|
||||
"Communication", "Local port number", "normally 10001 · reboot required"
|
||||
),
|
||||
"Local IP address": _m(
|
||||
"Communication", "Local IP address", "0.0.5.0 = DHCP · reboot required"
|
||||
),
|
||||
"Subnet bits": _m(
|
||||
"Communication", "Subnet bits", "click to edit · reboot required", kind="custom"
|
||||
),
|
||||
"Gateway IP address": _m("Communication", "Gateway IP address", "reboot required"),
|
||||
# --- Sensors ---------------------------------------------------------
|
||||
"Sensor mode": _m(
|
||||
"Sensors", "Sensor mode", kind="choice", choices={"0": "All", "1": "Limited"}
|
||||
),
|
||||
"Acquire sensors": _m("Sensors", "Acquire sensors", kind="toggle"),
|
||||
"Sensor update interval": _m("Sensors", "Sensor update interval", "seconds"),
|
||||
"Sensor comm loss timeout": _m("Sensors", "Sensor comm loss timeout", "seconds"),
|
||||
"Flyer clip point": _m("Sensors", "Flyer clip point", "0 = none"),
|
||||
"Filter size": _m("Sensors", "Filter size", "1–8"),
|
||||
"Disable flags": _m("Sensors", "Disable flags", "firmware internal"),
|
||||
# --- Alarms & beeper -------------------------------------------------
|
||||
"Do alarms?": _m("Alarms & beeper", "Do alarms?", kind="toggle"),
|
||||
"Beeper": _m("Alarms & beeper", "Beeper", "off = silent in alarm", kind="toggle"),
|
||||
"Beeper timeout": _m("Alarms & beeper", "Beeper timeout", "minutes"),
|
||||
"Max allowed button command": _m("Alarms & beeper", "Max allowed button command"),
|
||||
# --- OP05 ------------------------------------------------------------
|
||||
"OP05 mode": _m("OP05", "OP05 mode", "0–7"),
|
||||
"OP05 beeper timeout": _m("OP05", "OP05 beeper timeout", "minutes"),
|
||||
"OP05 strobe": _m(
|
||||
"OP05", "OP05 strobe", kind="choice", choices={"0": "Pulse", "1": "Solid"}
|
||||
),
|
||||
# --- Advanced (firmware internals) -----------------------------------
|
||||
"Boot Flags": _m("Advanced", "Boot flags", "firmware internal"),
|
||||
"NVRam Buffer Size": _m("Advanced", "NVRAM buffer size", "records"),
|
||||
"Service Tool Mode": _m("Advanced", "Service tool mode", "1 = remote"),
|
||||
"Remote Service Tool IP address": _m("Advanced", "Remote service tool IP address"),
|
||||
"Remote Service Tool port number": _m("Advanced", "Remote service tool port number"),
|
||||
}.items()
|
||||
}
|
||||
|
||||
|
||||
def meta_for(wire_label: str) -> SettingMeta:
|
||||
"""Metadata for a wire label; unknown labels land in Advanced, parens → hint."""
|
||||
known = META.get(setting_match_key(wire_label))
|
||||
if known is not None:
|
||||
return known
|
||||
match = _PAREN.search(wire_label)
|
||||
hint = match.group(1).strip() if match else ""
|
||||
label = _PAREN.sub("", wire_label).strip()
|
||||
return SettingMeta("Advanced", label, hint)
|
||||
@@ -1,8 +1,17 @@
|
||||
"""Station tab: editable key/value station settings (from 'D'/'E' messages)."""
|
||||
"""Station tab: grouped settings list (spec §5.5) fed by the wire's 'D'/'E' rows.
|
||||
|
||||
Structure (groups/rows/kinds) comes from ``station_settings_meta``; rows the meta
|
||||
table doesn't know land in Advanced so nothing the station sends disappears. The
|
||||
structure is rebuilt only when the wire row-set changes (the signature below);
|
||||
value updates flow through ``SettingsList.set_value`` (which never clobbers an open
|
||||
editor). Read-only comes from the wire (``type_code < 0``) and overrides the meta
|
||||
kind. Subnet keeps its modal dialog via the custom-row ``settingActivated`` path.
|
||||
In-cell ⓘ markers are retired — help is row tooltips.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
@@ -10,14 +19,18 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cim_suite.core.export import Cell, Sheet
|
||||
from cim_suite.core.ui.copy_menu import attach_copy_menu, set_extra_actions
|
||||
from cim_suite.core.ui.help import setting_match_key
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
from cim_suite.core.ui.kit import SettingsList
|
||||
from cim_suite.core.ui.theme import Space
|
||||
|
||||
from .. import help as _help
|
||||
from ..protocol.subnet import format_subnet_display
|
||||
from .station_settings_meta import GROUPS, meta_for
|
||||
from .subnet_dialog import SubnetDialog
|
||||
|
||||
# Keyed by setting_match_key so a verbose hardware label ("Subnet bits (0=class A
|
||||
@@ -25,30 +38,41 @@ from .subnet_dialog import SubnetDialog
|
||||
_SETTING_HELP = {setting_match_key(k): v for k, v in _help.STATION_SETTINGS.items()}
|
||||
_SUBNET_LABEL = setting_match_key("Subnet Bits")
|
||||
|
||||
HEADERS = ["Description of Setting", "Value"]
|
||||
HEADERS = ["Setting", "Value"] # export header row
|
||||
|
||||
|
||||
class StationTab(TableTab):
|
||||
class StationTab(QWidget):
|
||||
rebootRequested = Signal() # the banner's "Reboot Now" was clicked
|
||||
|
||||
def __init__(self, controller, *, repository=None) -> None:
|
||||
super().__init__(HEADERS, editable_cols=(1,)) # calls _build_toolbar below
|
||||
super().__init__()
|
||||
self._ctrl = controller
|
||||
self._repo = repository
|
||||
self._lines = [] # parallel list of SettingLine for the current rows
|
||||
self._lines: dict[str, object] = {} # key (str wire row) -> SettingLine
|
||||
self._signature: tuple | None = None # row-set fingerprint for rebuilds
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(Space.LG, Space.LG, Space.LG, Space.LG)
|
||||
layout.setSpacing(Space.MD)
|
||||
self._build_banner(layout)
|
||||
|
||||
self.settings_list = SettingsList(self)
|
||||
self.settings_list.settingEdited.connect(self._on_setting_edited)
|
||||
self.settings_list.settingActivated.connect(self._on_setting_activated)
|
||||
layout.addWidget(self.settings_list)
|
||||
|
||||
controller.settingsChanged.connect(self.rebuild)
|
||||
self.table.cellDoubleClicked.connect(self._on_cell_double_clicked)
|
||||
controller.rebootPendingChanged.connect(self._banner.setVisible)
|
||||
self._banner.setVisible(controller.reboot_pending)
|
||||
from cim_suite.core.ui.copy_menu import set_extra_actions
|
||||
|
||||
set_extra_actions(self.table, self._row_actions)
|
||||
attach_copy_menu(self.settings_list.table)
|
||||
set_extra_actions(self.settings_list.table, self._row_actions)
|
||||
self.rebuild()
|
||||
|
||||
def _build_toolbar(self, layout: QVBoxLayout) -> None:
|
||||
# A non-modal banner above the table, hidden until a reboot-requiring
|
||||
# (LAN) setting is changed. self._ctrl is not set yet here (the base
|
||||
# __init__ calls this), so only build the widget; wiring happens above.
|
||||
# --- banner (unchanged semantics from the TableTab version) -----------------
|
||||
def _build_banner(self, layout: QVBoxLayout) -> None:
|
||||
# A non-modal banner above the list, hidden until a reboot-requiring (LAN)
|
||||
# setting is changed.
|
||||
self._banner = QFrame()
|
||||
self._banner.setObjectName("RebootBanner")
|
||||
row = QHBoxLayout(self._banner)
|
||||
@@ -63,72 +87,119 @@ class StationTab(TableTab):
|
||||
self._banner.hide()
|
||||
layout.addWidget(self._banner)
|
||||
|
||||
# --- structure / values ------------------------------------------------------
|
||||
def rebuild(self) -> None:
|
||||
self._lines = self._ctrl.settings.all()
|
||||
rows = []
|
||||
for line in self._lines:
|
||||
desc, _, value = line.text.partition("\t")
|
||||
if setting_match_key(desc) == _SUBNET_LABEL:
|
||||
value = format_subnet_display(value)
|
||||
rows.append([desc, value])
|
||||
self.set_rows(rows)
|
||||
# mark read-only rows (type_code < 0) and the subnet cell (edited via modal)
|
||||
# non-editable so a double-click won't start the inline editor. setFlags emits
|
||||
# itemChanged, so guard with _loading: otherwise the phantom edit reaches
|
||||
# on_edit and (for the subnet row, whose type_code >= 0) writes the display
|
||||
# text back as a setting on every rebuild — spuriously flagging reboot-pending.
|
||||
self._loading = True
|
||||
try:
|
||||
for r, line in enumerate(self._lines):
|
||||
nl = setting_match_key(line.text.partition("\t")[0])
|
||||
if line.type_code < 0 or nl == _SUBNET_LABEL:
|
||||
item = self.table.item(r, 1)
|
||||
if item:
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
finally:
|
||||
self._loading = False
|
||||
for r, line in enumerate(self._lines):
|
||||
nl = setting_match_key(line.text.partition("\t")[0])
|
||||
text = _SETTING_HELP.get(nl)
|
||||
if text:
|
||||
self.mark_cell_help(r, 0, text)
|
||||
lines = list(self._ctrl.settings.all())
|
||||
# Signature = the row-set shape: (wire row, match key, is-read-only) per line.
|
||||
# A change here (new row, label change, RO flip) means the STRUCTURE must be
|
||||
# rebuilt; otherwise we only push fresh values, leaving items in place.
|
||||
# NOTE: for unknown (Advanced) rows the match key tracks label IDENTITY, not
|
||||
# the verbatim parenthetical — a changed parenthetical on an otherwise-identical
|
||||
# unknown label won't trigger a repaint until the row set itself changes. Known
|
||||
# labels use fixed meta text and are immune to this.
|
||||
signature = tuple(
|
||||
(line.row, setting_match_key(line.text.partition("\t")[0]), line.type_code < 0)
|
||||
for line in lines
|
||||
)
|
||||
self._lines = {str(line.row): line for line in lines}
|
||||
if signature != self._signature:
|
||||
self._rebuild_structure(lines)
|
||||
self._signature = signature
|
||||
for key, line in self._lines.items():
|
||||
self.settings_list.set_value(key, self._display_value(line))
|
||||
|
||||
def _on_cell_double_clicked(self, row: int, col: int) -> None:
|
||||
if row >= len(self._lines):
|
||||
return
|
||||
line = self._lines[row]
|
||||
def _display_value(self, line) -> str:
|
||||
desc, _, raw = line.text.partition("\t")
|
||||
if setting_match_key(desc) != _SUBNET_LABEL:
|
||||
return
|
||||
if line.type_code < 0: # read-only — never editable
|
||||
if setting_match_key(desc) == _SUBNET_LABEL:
|
||||
return format_subnet_display(raw)
|
||||
return raw
|
||||
|
||||
def _rebuild_structure(self, lines) -> None:
|
||||
self.settings_list.clear()
|
||||
# Bucket the lines by their meta group, keeping wire order within each group.
|
||||
by_group: dict[str, list] = {g: [] for g in GROUPS}
|
||||
for line in lines:
|
||||
meta = meta_for(line.text.partition("\t")[0])
|
||||
by_group.setdefault(meta.group, []).append((line, meta))
|
||||
for group in GROUPS:
|
||||
members = by_group.get(group) or []
|
||||
if not members:
|
||||
continue # empty groups are skipped
|
||||
self.settings_list.add_group(group)
|
||||
for line, meta in members:
|
||||
self._add_setting_row(line, meta)
|
||||
|
||||
def _add_setting_row(self, line, meta) -> None:
|
||||
key = str(line.row)
|
||||
label = meta.label
|
||||
hint = meta.hint
|
||||
read_only = line.type_code < 0 # the wire overrides the meta kind
|
||||
if read_only:
|
||||
self.settings_list.add_setting(key, label, hint=hint, read_only=True)
|
||||
elif meta.kind == "toggle":
|
||||
self.settings_list.add_toggle(key, label, hint=hint)
|
||||
elif meta.kind == "choice":
|
||||
self.settings_list.add_choice(key, label, meta.choices, hint=hint)
|
||||
elif meta.kind == "custom":
|
||||
self.settings_list.add_custom(key, label, hint=hint)
|
||||
else:
|
||||
self.settings_list.add_setting(key, label, hint=hint)
|
||||
help_text = _SETTING_HELP.get(setting_match_key(line.text.partition("\t")[0]))
|
||||
if help_text:
|
||||
self.settings_list.set_tooltip(key, help_text)
|
||||
|
||||
# --- edits -------------------------------------------------------------------
|
||||
def _on_setting_edited(self, key: str, raw: str) -> None:
|
||||
line = self._lines.get(key)
|
||||
if line is None or line.type_code < 0:
|
||||
return # read-only rows never write
|
||||
self._ctrl.set_station_setting(line.row, raw, line.type_code)
|
||||
|
||||
def _on_setting_activated(self, key: str) -> None:
|
||||
line = self._lines.get(key)
|
||||
if line is None or line.type_code < 0:
|
||||
return
|
||||
raw = line.text.partition("\t")[2]
|
||||
dialog = SubnetDialog(raw, self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
stored = dialog.stored_value()
|
||||
if stored is not None:
|
||||
self._ctrl.set_station_setting(line.row, str(stored), line.type_code)
|
||||
|
||||
def on_edit(self, row: int, col: int, value: str) -> None:
|
||||
if col != 1 or row >= len(self._lines):
|
||||
return
|
||||
line = self._lines[row]
|
||||
if line.type_code < 0:
|
||||
return
|
||||
if setting_match_key(line.text.partition("\t")[0]) == _SUBNET_LABEL:
|
||||
return # subnet is edited through SubnetDialog, never inline
|
||||
self._ctrl.set_station_setting(line.row, value, line.type_code)
|
||||
|
||||
# --- repo history right-click -----------------------------------------------
|
||||
def _row_actions(self, row: int, col: int):
|
||||
if self._repo is None or not (0 <= row < len(self._lines)):
|
||||
if self._repo is None:
|
||||
return []
|
||||
key = self.settings_list.key_at_row(row)
|
||||
line = self._lines.get(key) if key is not None else None
|
||||
if line is None:
|
||||
return []
|
||||
setting_row = self._lines[row].row
|
||||
from cim_suite.core.repository.ui.history_dialog import setting_history_action
|
||||
|
||||
from ..domain.repo_snapshot import setting_writer, station_mac
|
||||
|
||||
return [
|
||||
setting_history_action(
|
||||
self, self._repo, lambda: station_mac(self._ctrl),
|
||||
f"da12:station:{setting_row}:",
|
||||
writer_for=lambda key: setting_writer(self._ctrl, key),
|
||||
f"da12:station:{line.row}:",
|
||||
writer_for=lambda repo_key: setting_writer(self._ctrl, repo_key),
|
||||
)
|
||||
]
|
||||
|
||||
# --- export ------------------------------------------------------------------
|
||||
def export_sheet(self) -> Sheet | None:
|
||||
"""Snapshot the settings list: group headers as section rows, value rows as
|
||||
Setting/raw-Value pairs (toggles/choices export their raw wire value)."""
|
||||
by_group: dict[str, list] = {g: [] for g in GROUPS}
|
||||
for line in self._lines.values():
|
||||
meta = meta_for(line.text.partition("\t")[0])
|
||||
by_group.setdefault(meta.group, []).append((line, meta))
|
||||
rows: list[list[Cell]] = []
|
||||
for group in GROUPS:
|
||||
members = by_group.get(group) or []
|
||||
if not members:
|
||||
continue
|
||||
rows.append([Cell(group), Cell("")]) # group header → single-cell section
|
||||
for line, meta in members:
|
||||
rows.append([Cell(meta.label), Cell(self.settings_list.value(str(line.row)))])
|
||||
return Sheet(title="", headers=list(HEADERS), rows=rows)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from cim_suite.core.sensor_models import identify
|
||||
from cim_suite.core.ui.kit import CellKind
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
|
||||
HEADERS = ["#", "Serial", "Model", "Name", "Maximum", "Max Time", "Minimum", "Min Time",
|
||||
@@ -15,6 +16,14 @@ class StatisticsTab(TableTab):
|
||||
def __init__(self, controller) -> None:
|
||||
super().__init__(HEADERS, editable_cols=())
|
||||
self._ctrl = controller
|
||||
|
||||
self.delegate.set_column_kind(0, CellKind.NUMERIC)
|
||||
self.delegate.set_column_kind(1, CellKind.IDENTIFIER)
|
||||
for col in (4, 5, 6, 7, 8, 9):
|
||||
self.delegate.set_column_kind(col, CellKind.NUMERIC)
|
||||
self.set_column_units({"Max Time": "mm:ss", "Min Time": "mm:ss"})
|
||||
self.enable_summary() # read-only grid: no edit hint
|
||||
|
||||
controller.statsChanged.connect(self.rebuild)
|
||||
controller.sensorsChanged.connect(self.rebuild)
|
||||
self.rebuild()
|
||||
@@ -61,6 +70,7 @@ class StatisticsTab(TableTab):
|
||||
_fmt(st.average), _fmt(st.calculated),
|
||||
])
|
||||
self.set_rows(rows)
|
||||
self.summary.set_summary([(f"{len(rows)} sensors", None)])
|
||||
|
||||
def _clear(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
|
||||
@@ -28,7 +28,10 @@ class SuiteWindow(FramelessMainWindow):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle(_SUITE_NAME)
|
||||
self.resize(1100, 720)
|
||||
# §5.2: DA-12 toolbar sizeHint is ~1174px; below ~1180px trailing items
|
||||
# (Station commands ▾ and the PRIMARY Connect button) collapse into the
|
||||
# QToolBar overflow chevron. 1280px gives comfortable clearance.
|
||||
self.resize(1280, 760)
|
||||
|
||||
self._title_bar = ChromeTitleBar(self, show_theme_toggle=True)
|
||||
self._title_bar.set_breadcrumb(_SUITE_NAME, None)
|
||||
@@ -44,6 +47,7 @@ class SuiteWindow(FramelessMainWindow):
|
||||
self._modules = list(modules)
|
||||
self._warm: dict[str, QWidget] = {}
|
||||
self._active: Module | None = None
|
||||
self._suite_wired: set[str] = set() # modules whose suiteRequested is connected
|
||||
|
||||
self._stack = QStackedWidget()
|
||||
self._launcher = LauncherView(
|
||||
@@ -94,7 +98,13 @@ class SuiteWindow(FramelessMainWindow):
|
||||
self._warm[module.id] = widget
|
||||
self._stack.setCurrentWidget(widget)
|
||||
self._active = module
|
||||
self._back_action.setVisible(True)
|
||||
# Modules that own their own ← Suite button (suiteRequested signal) hide
|
||||
# the shell's ☰ Suite back action to avoid double navigation chrome.
|
||||
owns_suite = hasattr(widget, "suiteRequested")
|
||||
if owns_suite and module.id not in self._suite_wired:
|
||||
widget.suiteRequested.connect(self.show_launcher)
|
||||
self._suite_wired.add(module.id)
|
||||
self._back_action.setVisible(not owns_suite)
|
||||
self._title_bar.set_breadcrumb(_SUITE_NAME, module.title)
|
||||
self.setWindowTitle(f"{_SUITE_NAME} — {module.title}")
|
||||
|
||||
|
||||
@@ -281,6 +281,51 @@ editable cells (right-click history still works); IOModbus register grids keep t
|
||||
own pick-list delegate until Phase 6.
|
||||
Plan: `docs/superpowers/plans/2026-06-10-instrument-phase3-component-kit.md`.
|
||||
|
||||
### BL-DS-P4 — Instrument design system Phase 4 (DA-12 adoption) · P2 · DONE
|
||||
*Completed 2026-06-11.*
|
||||
DA-12 fully adopted on the Instrument system. Shipped: spec §5.2 toolbar (`← Suite`
|
||||
button replacing the shell's ☰ Suite action for DA-12, brand marks + kit `ConnectionChip`,
|
||||
confirm-guarded "Station commands ▾" menu holding Set Clock/Reboot, primary
|
||||
Connect/Disconnect far right driving `controller.stop()`); Station tab migrated to the kit
|
||||
`SettingsList` (spec §5.5 groups Identity/Communication/Sensors/Alarms & beeper/OP05 + an
|
||||
Advanced fallback so unknown wire labels never disappear; curated metadata in
|
||||
`da12/ui/station_settings_meta.py`; wire-governed read-only; subnet keeps its modal via
|
||||
the new custom-row `settingActivated` path; in-cell ⓘ markers retired for row tooltips);
|
||||
Sensors/Alarm Limits/Statistics/Calibration adopt the delegate kit (column kinds, §1.2
|
||||
status tags + `ALARM_ROW_ROLE` full-row treatment for alarm-class codes only — the old
|
||||
whole-row warn tint is gone BY SPEC, warnings show in the status tag; units header rows;
|
||||
summary strips; limits Enabled column is now a toggle; §5.6 write feedback as
|
||||
resolve-on-echo since the protocol has no NAK); history dialog restyled per §5.10 (surface
|
||||
plot, hair grid, mono faint ticks, 1.6px signal series, dashed limit lines with lows at
|
||||
55% opacity, top legend, LIVE TREND header strip, theme-switch repaint).
|
||||
|
||||
BL-DS-P3 carry-overs closed here: live refills no longer clobber an open editor
|
||||
(`TableTab.set_rows` + `SettingsList.set_value` skip the editing cell, regression-tested);
|
||||
group-band bar yields column 0 to the selection/alarm edge bar and uses `Metrics.EDGE_BAR_W`;
|
||||
`SettingsDelegate` exported; sensors severity moved off item brushes onto
|
||||
`STATUS_ROLE`/`ALARM_ROW_ROLE`; history-via-double-click decided (right-click is canonical;
|
||||
double-click still works on read-only cells); write feedback wired through `tab.delegate`.
|
||||
|
||||
Visual smoke findings fixed in-phase: default window 1100→1280 wide (the §5.2 toolbar
|
||||
needs ~1180px before Qt's overflow chevron hides the primary action), transparent toolbar
|
||||
label/spacer backgrounds, limit-line legend markers stay hidden after redraws.
|
||||
|
||||
Also: the group-band edge-bar precedence is shared code, so DA-07's channel grid quietly
|
||||
picks up the same improvement.
|
||||
- Plan: `docs/superpowers/plans/2026-06-11-instrument-phase4-da12-adoption.md`.
|
||||
|
||||
### BL-DS4 — SIM disconnect has no toolbar reconnect path · P3 · TODO
|
||||
*Added 2026-06-11 (deferred from BL-DS-P4).*
|
||||
After Disconnect in `--simulate`, the Connect… button opens the COM-port dialog; there is
|
||||
no way back to the simulator without returning to the launcher. Cosmetic/dev-mode only —
|
||||
real-hardware users always reconnect to a COM port.
|
||||
|
||||
### BL-DS5 — Toolbar responsiveness below ~1180px · P3 · TODO
|
||||
*Added 2026-06-11 (deferred from BL-DS-P4).*
|
||||
The §5.2 toolbar relies on Qt's overflow chevron when the window is narrowed below ~1180px.
|
||||
A deliberate compaction (icon-only actions, collapsing labels) is a Phase 5/6 candidate
|
||||
alongside DA-07/IOModbus toolbar adoption.
|
||||
|
||||
### BL-DS3 — Phase 3 microcaps via code, not QSS · P3 · DONE
|
||||
*Added 2026-06-10.*
|
||||
Qt ignores `text-transform` in QSS — the microcaps styles (tabs, column headers, group
|
||||
|
||||
@@ -9,7 +9,7 @@ standalone `.exe`) and docs. DA-12 is now **module #1 of the `cim_suite` monorep
|
||||
— the suite shell (card launcher + `SuiteWindow`) and shared `core` package exist.
|
||||
**DA-07 ("eLink") was rebuilt as module #2** and **IOModbus as module #3** (a
|
||||
config-driven Modbus RTU master — same five-layer pattern, against its own simulator;
|
||||
see Phases 8–9 below). **818 tests pass across `tests/core`, `tests/da12`,
|
||||
see Phases 8–9 below). **960 tests pass across `tests/core`, `tests/da12`,
|
||||
`tests/da07`, `tests/iomodbus`, and `tests/shell`; ruff is clean.**
|
||||
What remains is **verification against real DA-12, DA-07, and Modbus hardware** —
|
||||
guided by `docs/HARDWARE-VERIFICATION.md`.
|
||||
@@ -164,6 +164,7 @@ A standalone build also runs with no Python/VB6/OCX installed:
|
||||
| 11 — Instrument design system Phase 1 (foundation) | ✅ | Two-theme token system (light/dark), IBM Plex Sans/Mono fonts bundled, regenerated QSS from the token layer, theme manager with persistence, all painters migrated to live tokens, launcher dark/light toggle. Phases 2–6 (frameless chrome, component kit, per-module adoption, launcher redesign) pending — see `docs/superpowers/specs/2026-06-10-instrument-design-system-rollout-design.md`. |
|
||||
| 12 — Instrument design system Phase 2 (window chrome) | ✅ | Merged 2026-06-10. Frameless SuiteWindow (PySideSix-Frameless-Window 0.8.1) with the Instrument title bar (logo, breadcrumb, theme toggle, 42px window buttons); 2px brand accent strip; declarative StatusFooter adopted by DA-12/DA-07/IOModbus (legacy ConnPill QSS retired); Instrument chrome + breadcrumb titles on all 13 child dialogs; chrome-framed confirm/info/warning dialogs replacing QMessageBox. Frozen-exe selftest re-verified with the new frameless dependency (all three modules exit=0; no extra hiddenimports needed). Manual Windows 11 snap/drag/DPI checklist outstanding — tracked in `docs/superpowers/plans/2026-06-10-instrument-phase2-chrome.md` Task 11 Step 3. Phases 3–6 pending. |
|
||||
| 13 — Instrument design system Phase 3 (component kit) | ✅ | Merged 2026-06-10. The core component kit at `cim_suite/core/ui/kit/`: InstrumentDelegate (cell kinds, §1.2 status tags, edge bars, alarm tint, hover pencil chip, styled editor + validators, Tab-move, write-feedback flash), suite-wide single-click editing via TableTab (checkable columns now render as ToggleSwitches), UnitsHeaderView (microcaps headers + units line; ⓘ markers retired from headers), InstrumentTabWidget (microcaps tabs, closes BL-DS3), SummaryStrip, SettingsList, ActivityLogCard, Sidebar, tinted SVG icons. Bonus: fixed the QSS suppression of item-brush backgrounds and two pre-existing rebuild write-storms (hardware-relevant; see BL-DS-P3). Frozen-exe selftest re-verified (all three modules exit=0 with the bundled SVGs + QtSvg). Module adoption is Phases 4–6; the Phase 2 manual Win11 snap/DPI checklist remains the only open chrome item. Plan: `docs/superpowers/plans/2026-06-10-instrument-phase3-component-kit.md`. |
|
||||
| 14 — Instrument design system Phase 4 (DA-12 adoption) | ✅ | Merged 2026-06-11. DA-12 fully adopted on the Instrument system. §5.2 toolbar (`← Suite` button, `ConnectionChip`, confirm-guarded "Station commands ▾" menu, primary Connect/Disconnect); Station tab → `SettingsList` with spec §5.5 groups (Identity/Communication/Sensors/Alarms & beeper/OP05/Advanced) driven by curated metadata in `da12/ui/station_settings_meta.py`; Sensors/Alarm Limits/Statistics/Calibration adopt the delegate kit (status tags, `ALARM_ROW_ROLE` alarm-only row tint, units headers, summary strips, toggle for Enabled, resolve-on-echo write feedback); history dialog restyled per §5.10. BL-DS-P3 carry-overs closed: live-refill editor guard, group-band edge-bar precedence (DA-07 picks this up too), `SettingsDelegate` exported. Two follow-ups deferred: [BL-DS4](#bl-ds4--sim-disconnect-has-no-toolbar-reconnect-path--p3--todo) (SIM reconnect path) and [BL-DS5](#bl-ds5--toolbar-responsiveness-below-1180px--p3--todo) (toolbar compaction). Phases 5 (DA-07) and 6 (IOModbus + launcher) remain. See [BL-DS-P4](BACKLOG.md#bl-ds-p4--instrument-design-system-phase-4-da-12-adoption--p2--done). 960 tests pass; ruff clean. |
|
||||
|
||||
## What I could NOT do without hardware (your turn)
|
||||
|
||||
@@ -225,9 +226,11 @@ act on into a tracked backlog item.)*
|
||||
|
||||
## Next direction
|
||||
|
||||
The monorepo reshape is done and the first three VB6 tools are rebuilt: DA-12
|
||||
(module #1), DA-07 (module #2), and IOModbus (module #3). The outstanding milestone
|
||||
is **hardware verification** of all three (`docs/HARDWARE-VERIFICATION.md`). Further
|
||||
VB6 tools follow the same pattern — drop the source into
|
||||
`cim_suite/modules/<app>/legacy/`, then spec → plan → implement; `core` grows only as
|
||||
real sharing reveals itself. Design and sequencing: **`docs/SUITE-ARCHITECTURE.md`**.
|
||||
The monorepo reshape is done, the first three VB6 tools are rebuilt (DA-12, DA-07,
|
||||
IOModbus), and **DA-12 is now fully adopted on the Instrument design system** (Phase 4,
|
||||
2026-06-11 — [BL-DS-P4](BACKLOG.md#bl-ds-p4--instrument-design-system-phase-4-da-12-adoption--p2--done)).
|
||||
Next design-system work: **Phase 5** (DA-07 adoption) and **Phase 6** (IOModbus + launcher).
|
||||
The outstanding hardware milestone is **verification** of all three modules
|
||||
(`docs/HARDWARE-VERIFICATION.md`). Further VB6 tools follow the same pattern — drop the
|
||||
source into `cim_suite/modules/<app>/legacy/`, then spec → plan → implement; `core` grows
|
||||
only as real sharing reveals itself. Design and sequencing: **`docs/SUITE-ARCHITECTURE.md`**.
|
||||
|
||||
1886
docs/superpowers/plans/2026-06-11-instrument-phase4-da12-adoption.md
Normal file
1886
docs/superpowers/plans/2026-06-11-instrument-phase4-da12-adoption.md
Normal file
File diff suppressed because it is too large
Load Diff
51
tests/core/kit/test_connection_chip.py
Normal file
51
tests/core/kit/test_connection_chip.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""ConnectionChip: §5.2 ok pill — state drives dot/fill/text; tokens at paint time."""
|
||||
|
||||
from cim_suite.core.ui.kit import ConnectionChip
|
||||
from cim_suite.core.ui.theme import current
|
||||
|
||||
|
||||
def _image(chip):
|
||||
chip.resize(chip.sizeHint())
|
||||
return chip.grab().toImage()
|
||||
|
||||
|
||||
def test_connected_paints_ok_dot_and_source(qtbot, near):
|
||||
chip = ConnectionChip()
|
||||
qtbot.addWidget(chip)
|
||||
chip.set_state(True, "COM5")
|
||||
assert chip.text() == "Connected · COM5"
|
||||
img = _image(chip)
|
||||
# Probe the filled dot centre — derived from class constants, not magic numbers.
|
||||
cx = ConnectionChip._HPAD + ConnectionChip._DOT // 2
|
||||
dot = img.pixelColor(cx, img.height() // 2)
|
||||
assert near(dot, current().ok)
|
||||
|
||||
|
||||
def test_disconnected_is_quiet(qtbot, near):
|
||||
chip = ConnectionChip()
|
||||
qtbot.addWidget(chip)
|
||||
chip.set_state(False)
|
||||
assert chip.text() == "Not connected"
|
||||
img = _image(chip)
|
||||
# Probe the left edge of the hollow ring rather than the transparent centre.
|
||||
# The ring is 1.5px antialiased with a 0.75px inset, so the exact painted pixel
|
||||
# may land at _HPAD-1, _HPAD, or _HPAD+1 — accept any candidate to stay robust.
|
||||
cy = img.height() // 2
|
||||
ring_candidates = [
|
||||
img.pixelColor(x, cy)
|
||||
for x in (ConnectionChip._HPAD - 1, ConnectionChip._HPAD, ConnectionChip._HPAD + 1)
|
||||
]
|
||||
assert any(near(c, current().faint) for c in ring_candidates) # ring is faint-colored
|
||||
# The hollow centre should still carry none of the ok fill.
|
||||
cx = ConnectionChip._HPAD + ConnectionChip._DOT // 2
|
||||
center = img.pixelColor(cx, cy)
|
||||
assert not near(center, current().ok) # hollow dot — no ok fill
|
||||
|
||||
|
||||
def test_size_tracks_text(qtbot):
|
||||
chip = ConnectionChip()
|
||||
qtbot.addWidget(chip)
|
||||
chip.set_state(False)
|
||||
narrow = chip.sizeHint().width()
|
||||
chip.set_state(True, "COM5")
|
||||
assert chip.sizeHint().width() != narrow
|
||||
@@ -1,7 +1,7 @@
|
||||
"""SettingsList: groups, hints, RO rows, toggles, choice dropdowns (spec §5.5)."""
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from PySide6.QtWidgets import QAbstractItemView, QComboBox, QLineEdit
|
||||
|
||||
from cim_suite.core.ui.kit.settings_list import GROUP_ROLE, SettingsList
|
||||
|
||||
@@ -102,3 +102,67 @@ def test_choice_cells_render_right_aligned_mono(qtbot):
|
||||
sl.add_choice("m", "Mode", {"0": "Off", "1": "On"})
|
||||
item = sl.table.item(sl._rows["m"].row, 1)
|
||||
assert item.data(KIND_ROLE) == CellKind.NUMERIC # §5.5 value column treatment
|
||||
|
||||
|
||||
def test_clear_resets_structure(qtbot):
|
||||
sl = SettingsList()
|
||||
qtbot.addWidget(sl)
|
||||
sl.add_group("Identity")
|
||||
sl.add_setting("name", "Station name")
|
||||
sl.clear()
|
||||
assert sl.table.rowCount() == 0
|
||||
sl.add_group("Identity") # rebuild works after clear
|
||||
sl.add_setting("name", "Station name")
|
||||
sl.set_value("name", "DA-12 Demo")
|
||||
assert sl.value("name") == "DA-12 Demo"
|
||||
|
||||
|
||||
def test_custom_row_emits_activated_not_editor(qtbot):
|
||||
sl = SettingsList()
|
||||
qtbot.addWidget(sl)
|
||||
sl.add_custom("subnet", "Subnet bits", hint="click to edit")
|
||||
sl.set_value("subnet", "8")
|
||||
fired = []
|
||||
sl.settingActivated.connect(fired.append)
|
||||
row = sl._rows["subnet"].row
|
||||
sl._edit_on_click(row, 1)
|
||||
assert fired == ["subnet"]
|
||||
assert sl.table.state() != QAbstractItemView.State.EditingState # no inline editor
|
||||
|
||||
|
||||
def test_set_tooltip_lands_on_both_columns(qtbot):
|
||||
sl = SettingsList()
|
||||
qtbot.addWidget(sl)
|
||||
sl.add_setting("name", "Station name")
|
||||
sl.set_tooltip("name", "Name sent to the server.")
|
||||
row = sl._rows["name"].row
|
||||
assert sl.table.item(row, 0).toolTip() == "Name sent to the server."
|
||||
assert sl.table.item(row, 1).toolTip() == "Name sent to the server."
|
||||
|
||||
|
||||
def test_set_value_skips_cell_under_open_editor(qtbot):
|
||||
sl = SettingsList()
|
||||
qtbot.addWidget(sl)
|
||||
sl.add_setting("name", "Station name")
|
||||
sl.set_value("name", "old")
|
||||
entry = sl._rows["name"]
|
||||
item = sl.table.item(entry.row, 1)
|
||||
sl.table.setCurrentItem(item)
|
||||
sl.table.editItem(item)
|
||||
editor = sl.table.findChild(QLineEdit, "CellEditor")
|
||||
editor.setText("typed")
|
||||
sl.set_value("name", "refill") # live wire refresh while typing
|
||||
assert editor.text() == "typed"
|
||||
|
||||
|
||||
def test_settings_delegate_exported():
|
||||
from cim_suite.core.ui.kit import SettingsDelegate # noqa: F401
|
||||
|
||||
|
||||
def test_key_at_row_maps_table_row_back_to_key(qtbot):
|
||||
sl = _list(qtbot)
|
||||
# _list adds: group (row 0), then name/mac/beeper/mode value rows.
|
||||
assert sl.key_at_row(sl._rows["name"].row) == "name"
|
||||
assert sl.key_at_row(sl._rows["mode"].row) == "mode"
|
||||
assert sl.key_at_row(0) is None # the group header row has no setting key
|
||||
assert sl.key_at_row(999) is None # out-of-range
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from PySide6.QtCore import QRect
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem, QTableWidget, QTableWidgetItem
|
||||
|
||||
from cim_suite.core.ui.group_band_delegate import GroupBandDelegate
|
||||
from cim_suite.core.ui.kit import ALARM_ROW_ROLE
|
||||
from cim_suite.core.ui.theme import current, qcolor
|
||||
|
||||
|
||||
def test_set_groups_stores_rows_and_starts():
|
||||
@@ -8,3 +14,124 @@ def test_set_groups_stores_rows_and_starts():
|
||||
assert d.is_start(0) is True
|
||||
assert d.is_grouped(5) is False
|
||||
assert d.is_start(1) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paint-probe helpers (mirrors tests/core/kit/conftest.py but local so we
|
||||
# don't rely on that conftest being in the search path for this directory).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pin_light(monkeypatch):
|
||||
"""Force current() to the light theme tokens for deterministic pixel probes."""
|
||||
from cim_suite.core.ui.theme import manager
|
||||
monkeypatch.setattr(manager, "_current_name", "light")
|
||||
|
||||
|
||||
def _render_cell(delegate, index, *, state=None, size=(120, 30)):
|
||||
"""Paint one cell into a QImage and return it."""
|
||||
image = QImage(size[0], size[1], QImage.Format.Format_ARGB32_Premultiplied)
|
||||
image.fill(qcolor(current().surface))
|
||||
painter = QPainter(image)
|
||||
option = QStyleOptionViewItem()
|
||||
option.rect = QRect(0, 0, size[0], size[1])
|
||||
if state is not None:
|
||||
option.state = state
|
||||
delegate.paint(painter, option, index)
|
||||
painter.end()
|
||||
return image
|
||||
|
||||
|
||||
def _near(image_color, token, tol=40):
|
||||
"""Return True when image_color is within tol of the given hex/token color."""
|
||||
target = qcolor(token)
|
||||
return (
|
||||
abs(image_color.red() - target.red()) <= tol
|
||||
and abs(image_color.green() - target.green()) <= tol
|
||||
and abs(image_color.blue() - target.blue()) <= tol
|
||||
)
|
||||
|
||||
|
||||
def _make_table(qtbot, rows, cols):
|
||||
table = QTableWidget(rows, cols)
|
||||
qtbot.addWidget(table)
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
table.setItem(r, c, QTableWidgetItem("x"))
|
||||
return table
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §5.4 edge-bar precedence tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_grouped_col0_normal_row_paints_accent_bar(qtbot, monkeypatch):
|
||||
"""Baseline: a grouped, non-selected, non-alarm col-0 cell gets the accent bar."""
|
||||
_pin_light(monkeypatch)
|
||||
table = _make_table(qtbot, 2, 2)
|
||||
delegate = GroupBandDelegate(table)
|
||||
table.setItemDelegate(delegate)
|
||||
delegate.set_groups({0, 1}, {0})
|
||||
|
||||
state = QStyle.StateFlag.State_Enabled # not selected
|
||||
image = _render_cell(delegate, table.model().index(0, 0), state=state)
|
||||
|
||||
t = current()
|
||||
# Left 3 px must be near accent, not near signal
|
||||
assert _near(image.pixelColor(1, 15), t.accent, tol=30), (
|
||||
"Expected accent bar on grouped normal col-0 cell"
|
||||
)
|
||||
assert not _near(image.pixelColor(1, 15), t.signal, tol=30), (
|
||||
"Did not expect signal bar on grouped normal col-0 cell"
|
||||
)
|
||||
|
||||
|
||||
def test_grouped_col0_selected_row_yields_to_signal_edge_bar(qtbot, monkeypatch):
|
||||
"""§5.4: group band must NOT paint accent on col 0 when the row is selected.
|
||||
|
||||
The base InstrumentDelegate paints a `signal` edge bar for selected rows.
|
||||
The group band must yield — col-0 left edge should be near `signal`, not `accent`.
|
||||
"""
|
||||
_pin_light(monkeypatch)
|
||||
table = _make_table(qtbot, 2, 2)
|
||||
delegate = GroupBandDelegate(table)
|
||||
table.setItemDelegate(delegate)
|
||||
delegate.set_groups({0, 1}, {0})
|
||||
|
||||
state = QStyle.StateFlag.State_Selected | QStyle.StateFlag.State_Enabled
|
||||
image = _render_cell(delegate, table.model().index(0, 0), state=state)
|
||||
|
||||
t = current()
|
||||
# Left 3 px must be near signal (selection edge bar), NOT near accent (group band)
|
||||
assert _near(image.pixelColor(1, 15), t.signal, tol=30), (
|
||||
"Expected signal edge bar on selected grouped col-0 cell"
|
||||
)
|
||||
assert not _near(image.pixelColor(1, 15), t.accent, tol=30), (
|
||||
"Group band must not paint over the selection edge bar on col 0"
|
||||
)
|
||||
|
||||
|
||||
def test_grouped_col0_alarm_row_yields_to_alarm_edge_bar(qtbot, monkeypatch):
|
||||
"""§5.4: group band must NOT paint accent on col 0 when ALARM_ROW_ROLE is set.
|
||||
|
||||
The base InstrumentDelegate paints an `alarm` edge bar for alarm rows.
|
||||
The group band must yield — col-0 left edge should be near `alarm`, not `accent`.
|
||||
"""
|
||||
_pin_light(monkeypatch)
|
||||
table = _make_table(qtbot, 2, 2)
|
||||
delegate = GroupBandDelegate(table)
|
||||
table.setItemDelegate(delegate)
|
||||
delegate.set_groups({0, 1}, {0})
|
||||
# Mark the row-0 col-0 item as alarm
|
||||
table.item(0, 0).setData(ALARM_ROW_ROLE, True)
|
||||
|
||||
state = QStyle.StateFlag.State_Enabled # not selected, but alarm_row_role is set
|
||||
image = _render_cell(delegate, table.model().index(0, 0), state=state)
|
||||
|
||||
t = current()
|
||||
# Left 3 px must be near alarm (alarm edge bar), NOT near accent (group band)
|
||||
assert _near(image.pixelColor(1, 15), t.alarm, tol=30), (
|
||||
"Expected alarm edge bar on alarm grouped col-0 cell"
|
||||
)
|
||||
assert not _near(image.pixelColor(1, 15), t.accent, tol=30), (
|
||||
"Group band must not paint over the alarm edge bar on col 0"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QAbstractItemView, QHeaderView
|
||||
from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QLineEdit, QStyledItemDelegate
|
||||
|
||||
from cim_suite.core.ui.table_tab import TableTab
|
||||
|
||||
@@ -154,3 +154,49 @@ def test_kit_delegate_with_toggle_kind_is_installed(qtbot):
|
||||
qtbot.addWidget(tab)
|
||||
assert isinstance(tab.table.itemDelegate(), InstrumentDelegate)
|
||||
assert tab.delegate._kinds[1] == CellKind.TOGGLE # checkable col renders as a switch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Editor-preservation: live set_rows refills must not clobber open editors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _EditRecorder(TableTab):
|
||||
def __init__(self):
|
||||
super().__init__(["A", "B"], editable_cols=(1,))
|
||||
self.edits = []
|
||||
|
||||
def on_edit(self, row, col, value):
|
||||
self.edits.append((row, col, value))
|
||||
|
||||
|
||||
def _open_editor(tab, row, col):
|
||||
item = tab.table.item(row, col)
|
||||
tab.table.setCurrentItem(item)
|
||||
tab.table.editItem(item)
|
||||
editor = tab.table.findChild(QLineEdit, "CellEditor")
|
||||
assert editor is not None
|
||||
return editor
|
||||
|
||||
|
||||
def test_set_rows_preserves_open_editor_text(qtbot):
|
||||
"""A live refill while the user is typing must not clobber the editor (BL-DS-P3)."""
|
||||
tab = _EditRecorder()
|
||||
qtbot.addWidget(tab)
|
||||
tab.set_rows([["x", "1"], ["y", "2"]])
|
||||
editor = _open_editor(tab, 0, 1)
|
||||
editor.setText("42") # user mid-typing
|
||||
tab.set_rows([["x", "999"], ["y", "200"]]) # stream refill arrives
|
||||
assert editor.text() == "42" # typing survives
|
||||
assert tab.table.item(1, 1).text() == "200" # other cells still refresh
|
||||
|
||||
|
||||
def test_commit_after_refill_writes_user_text(qtbot):
|
||||
tab = _EditRecorder()
|
||||
qtbot.addWidget(tab)
|
||||
tab.set_rows([["x", "1"]])
|
||||
editor = _open_editor(tab, 0, 1)
|
||||
editor.setText("42")
|
||||
tab.set_rows([["x", "999"]])
|
||||
tab.table.commitData(editor)
|
||||
tab.table.closeEditor(editor, QStyledItemDelegate.EndEditHint.NoHint)
|
||||
assert (0, 1, "42") in tab.edits
|
||||
|
||||
113
tests/da12/test_alarm_limits_tab.py
Normal file
113
tests/da12/test_alarm_limits_tab.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Task 9: Alarm Limits tab — toggle column, write feedback, units, summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cim_suite.modules.da12.domain.controller import StationController
|
||||
from cim_suite.modules.da12.transport.simulator import SimulatedStation
|
||||
from cim_suite.modules.da12.ui.alarm_limits_tab import HEADERS, AlarmLimitsTab
|
||||
|
||||
|
||||
def _ctrl(sensors: int = 2):
|
||||
sim = SimulatedStation(sensors=sensors)
|
||||
c = StationController()
|
||||
c.attach(sim)
|
||||
sim.start()
|
||||
c.refresh()
|
||||
return c
|
||||
|
||||
|
||||
def _tab(qtbot, sensors: int = 2):
|
||||
ctrl = _ctrl(sensors)
|
||||
tab = AlarmLimitsTab(ctrl)
|
||||
qtbot.addWidget(tab)
|
||||
tab.rebuild()
|
||||
return tab, ctrl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_enabled_column_is_toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_enabled_column_is_toggle(qtbot):
|
||||
"""Enabled col (4) renders as a checkable toggle; NOT ItemIsEditable."""
|
||||
tab, ctrl = _tab(qtbot)
|
||||
# Seed enable=1 on first limit
|
||||
lims = ctrl.limits.all()
|
||||
lims[0].enable = 1
|
||||
tab.rebuild()
|
||||
|
||||
item = tab.table.item(0, 4)
|
||||
assert item is not None
|
||||
assert item.checkState() == Qt.CheckState.Checked
|
||||
assert not (item.flags() & Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_toggle_writes_wire_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_toggle_writes_wire_value(qtbot):
|
||||
"""Flipping the toggle calls set_limit_enable with '0' or '1'."""
|
||||
tab, ctrl = _tab(qtbot)
|
||||
lims = ctrl.limits.all()
|
||||
lims[0].enable = 1 # start checked
|
||||
tab.rebuild()
|
||||
|
||||
calls = []
|
||||
ctrl.set_limit_enable = lambda sid, v: calls.append((sid, v))
|
||||
|
||||
# Flip to unchecked via the item — exercises itemChanged → _item_changed → on_check
|
||||
sid = ctrl.limits.all()[0].id
|
||||
tab.table.item(0, 4).setCheckState(Qt.CheckState.Unchecked)
|
||||
assert calls == [(sid, "0")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_summary_counts_enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_summary_counts_enabled(qtbot):
|
||||
"""2 limits, 1 enabled → summary strip shows '2 SENSORS' and '1 ENABLED'."""
|
||||
tab, ctrl = _tab(qtbot, sensors=2)
|
||||
lims = ctrl.limits.all()
|
||||
lims[0].enable = 1
|
||||
lims[1].enable = 0
|
||||
tab.rebuild()
|
||||
|
||||
strip = tab.summary
|
||||
texts = {lbl.text() for lbl in strip.findChildren(QLabel) if lbl.objectName() == "SummaryItem"}
|
||||
assert "2 SENSORS" in texts
|
||||
assert "1 ENABLED" in texts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_threshold_edit_marks_pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_threshold_edit_marks_pending(qtbot):
|
||||
"""on_edit on a threshold col marks (row,col) pending; rebuild clears it."""
|
||||
tab, ctrl = _tab(qtbot)
|
||||
|
||||
calls = []
|
||||
ctrl.set_limit_lo_alarm = lambda sid, v: calls.append((sid, v))
|
||||
|
||||
tab.on_edit(0, 6, "5")
|
||||
assert (0, 6) in tab.delegate._pending
|
||||
|
||||
tab.rebuild()
|
||||
assert (0, 6) not in tab.delegate._pending
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_units_row_has_delay_seconds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_units_row_has_delay_seconds(qtbot):
|
||||
"""Units row shows 's' under the Delay column."""
|
||||
tab, ctrl = _tab(qtbot)
|
||||
header = tab.table.horizontalHeader()
|
||||
delay_idx = HEADERS.index("Delay")
|
||||
assert header._units.get(delay_idx) == "s"
|
||||
61
tests/da12/test_calibration_tab_ui.py
Normal file
61
tests/da12/test_calibration_tab_ui.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Task 10: Calibration tab — column kinds, units, record-count summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cim_suite.modules.da12.domain.calibration import CalRecord, append_csv
|
||||
from cim_suite.modules.da12.ui.calibration_tab import CalibrationTab
|
||||
|
||||
|
||||
def _make_controller(monkeypatch):
|
||||
"""Minimal stand-in: CalibrationTab only needs calibrationLogged signal."""
|
||||
from unittest.mock import MagicMock
|
||||
ctrl = MagicMock()
|
||||
# calibrationLogged.connect is called in __init__; MagicMock handles it.
|
||||
return ctrl
|
||||
|
||||
|
||||
def _tab(qtbot, tmp_path, records: int = 1):
|
||||
csv_path = tmp_path / "DACal.csv"
|
||||
for i in range(1, records + 1):
|
||||
append_csv(csv_path, CalRecord(i, f"SER{i:04d}", f"Name{i}",
|
||||
float(i), 1.0, 0.0, float(i) + 1.0, 1.0, 0.0, ""))
|
||||
ctrl = _make_controller(None)
|
||||
tab = CalibrationTab(ctrl, csv_path=csv_path)
|
||||
qtbot.addWidget(tab)
|
||||
return tab
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_summary_shows_record_count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_summary_shows_record_count(qtbot, tmp_path):
|
||||
"""1 seeded record → 'N RECORDS' in SummaryStrip; hint label empty."""
|
||||
tab = _tab(qtbot, tmp_path, records=1)
|
||||
|
||||
strip = tab.summary
|
||||
texts = {lbl.text() for lbl in strip.findChildren(QLabel) if lbl.objectName() == "SummaryItem"}
|
||||
assert "1 RECORDS" in texts
|
||||
|
||||
hint_labels = [
|
||||
lbl for lbl in strip.findChildren(QLabel)
|
||||
if lbl.objectName() == "SummaryHint"
|
||||
]
|
||||
for lbl in hint_labels:
|
||||
assert lbl.text() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_units_mapped_by_index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_units_mapped_by_index(qtbot, tmp_path):
|
||||
"""'×' at columns 5 and 8; '+' at columns 6 and 9 (duplicate header names)."""
|
||||
tab = _tab(qtbot, tmp_path, records=0)
|
||||
header = tab.table.horizontalHeader()
|
||||
assert header._units.get(5) == "×"
|
||||
assert header._units.get(8) == "×"
|
||||
assert header._units.get(6) == "+"
|
||||
assert header._units.get(9) == "+"
|
||||
@@ -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):
|
||||
|
||||
55
tests/da12/test_main_window_toolbar.py
Normal file
55
tests/da12/test_main_window_toolbar.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Spec §5.2 toolbar: suite handoff, connection chip, confirm-guarded commands."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cim_suite.core.ui.chrome import message_box
|
||||
from cim_suite.modules.da12.domain.controller import StationController
|
||||
from cim_suite.modules.da12.ui.main_window import MainWindow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(qtbot):
|
||||
ctrl = StationController()
|
||||
w = MainWindow(ctrl)
|
||||
qtbot.addWidget(w)
|
||||
return w
|
||||
|
||||
|
||||
def test_suite_button_emits_signal(window, qtbot):
|
||||
with qtbot.waitSignal(window.suiteRequested, timeout=1000):
|
||||
window.act_suite.trigger()
|
||||
|
||||
|
||||
def test_chip_tracks_connection(window):
|
||||
window.set_connection_source("COM5")
|
||||
window._ctrl.connectionChanged.emit(True)
|
||||
assert window.chip.text() == "Connected · COM5"
|
||||
assert window.act_connect.text().startswith("Disconnect")
|
||||
window._ctrl.connectionChanged.emit(False)
|
||||
assert window.chip.text() == "Not connected"
|
||||
assert window.act_connect.text().startswith("Connect")
|
||||
|
||||
|
||||
def test_station_commands_menu_holds_risky_actions(window):
|
||||
labels = [a.text() for a in window.station_menu.actions()]
|
||||
assert any("Set Clock" in t for t in labels)
|
||||
assert any("Reboot" in t for t in labels)
|
||||
|
||||
|
||||
def test_set_clock_is_confirm_guarded(window, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(window._ctrl, "set_clock", lambda *a, **k: sent.append("clock"))
|
||||
monkeypatch.setattr(message_box, "question", lambda *a, **k: False)
|
||||
window.act_clock.trigger()
|
||||
assert sent == []
|
||||
monkeypatch.setattr(message_box, "question", lambda *a, **k: True)
|
||||
window.act_clock.trigger()
|
||||
assert sent == ["clock"]
|
||||
|
||||
|
||||
def test_disconnect_stops_controller(window, monkeypatch):
|
||||
stopped = []
|
||||
monkeypatch.setattr(window._ctrl, "stop", lambda: stopped.append(True))
|
||||
window._ctrl.connectionChanged.emit(True)
|
||||
window.act_connect.trigger()
|
||||
assert stopped == [True]
|
||||
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) == "+"
|
||||
@@ -1,4 +1,3 @@
|
||||
from cim_suite.core.ui.help import HELP_MARK
|
||||
from cim_suite.modules.da12.domain.controller import StationController
|
||||
from cim_suite.modules.da12.transport.simulator import SimulatedStation
|
||||
from cim_suite.modules.da12.ui.station_tab import StationTab
|
||||
@@ -15,26 +14,29 @@ def _tab(qtbot):
|
||||
return tab
|
||||
|
||||
|
||||
def _row_for(tab, label):
|
||||
for r in range(tab.table.rowCount()):
|
||||
if label in tab.table.item(r, 0).text():
|
||||
return r
|
||||
def _key_for(tab, label):
|
||||
for key, line in tab._lines.items():
|
||||
if label in line.text.partition("\t")[0]:
|
||||
return key
|
||||
raise AssertionError(f"{label} not found")
|
||||
|
||||
|
||||
def test_known_setting_gets_marker_and_tooltip(qtbot):
|
||||
def _row_tooltip(tab, key):
|
||||
row = tab.settings_list._rows[key].row
|
||||
return tab.settings_list.table.item(row, 0).toolTip()
|
||||
|
||||
|
||||
def test_known_setting_gets_tooltip(qtbot):
|
||||
# The real station sends "Acquire sensors (0=off, 1=on)"; help still attaches
|
||||
# because setting_match_key strips the parenthetical hint before lookup.
|
||||
# because setting_match_key strips the parenthetical hint before lookup. Help is
|
||||
# now a row tooltip (the in-cell ⓘ marker retired in phase 4).
|
||||
tab = _tab(qtbot)
|
||||
r = _row_for(tab, "Acquire sensors")
|
||||
item = tab.table.item(r, 0)
|
||||
assert item.text().startswith(HELP_MARK)
|
||||
assert item.toolTip() == "Set to one (1) to acquire sensors."
|
||||
key = _key_for(tab, "Acquire sensors")
|
||||
assert _row_tooltip(tab, key) == "Set to one (1) to acquire sensors."
|
||||
|
||||
|
||||
def test_advanced_setting_has_no_marker(qtbot):
|
||||
def test_advanced_setting_has_no_tooltip(qtbot):
|
||||
# Firmware-internal fields (e.g. "Boot Flags") ship without help by design.
|
||||
tab = _tab(qtbot)
|
||||
r = _row_for(tab, "Boot Flags")
|
||||
assert not tab.table.item(r, 0).text().startswith(HELP_MARK)
|
||||
assert tab.table.item(r, 0).toolTip() == ""
|
||||
key = _key_for(tab, "Boot Flags")
|
||||
assert _row_tooltip(tab, key) == ""
|
||||
|
||||
43
tests/da12/test_station_settings_meta.py
Normal file
43
tests/da12/test_station_settings_meta.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""The station-settings meta table: every captured hardware label is mapped."""
|
||||
|
||||
from cim_suite.core.ui.help import setting_match_key
|
||||
from cim_suite.modules.da12.transport.simulator import _SEED_SETTINGS
|
||||
from cim_suite.modules.da12.ui.station_settings_meta import GROUPS, META, meta_for
|
||||
|
||||
|
||||
def test_every_captured_label_is_curated():
|
||||
for label, _ in _SEED_SETTINGS:
|
||||
# Direct META membership — the Advanced fallback also returns a valid
|
||||
# group, so group-membership alone could mask a typo'd key.
|
||||
assert setting_match_key(label) in META, label
|
||||
|
||||
|
||||
def test_unknown_label_falls_back_to_advanced():
|
||||
m = meta_for("Mystery future setting (0=off)")
|
||||
assert m.group == "Advanced"
|
||||
assert m.label == "Mystery future setting"
|
||||
assert m.hint == "0=off" # parenthetical becomes the hint
|
||||
assert m.kind == "text"
|
||||
|
||||
|
||||
def test_toggles_and_choices():
|
||||
assert meta_for("Do alarms? (0=no, 1=yes)").kind == "toggle"
|
||||
assert meta_for("Acquire sensors (0=off, 1=on)").kind == "toggle"
|
||||
assert meta_for("Beeper (0=silent, 1=active)").kind == "toggle"
|
||||
strobe = meta_for("OP05 strobe (0=pulse, 1=sold)") # "sold" [sic] — verbatim hardware label
|
||||
assert strobe.kind == "choice" and strobe.choices == {"0": "Pulse", "1": "Solid"}
|
||||
mode = meta_for("Sensor mode (0=all, 1=limited)")
|
||||
assert mode.kind == "choice" and mode.choices == {"0": "All", "1": "Limited"}
|
||||
# 0-7 enums stay text: no verified meaning table exists (VB6-fidelity rule).
|
||||
assert meta_for("Reporting mode (0 to 7)").kind == "text"
|
||||
assert meta_for("OP05 mode (0 to 7)").kind == "text"
|
||||
|
||||
|
||||
def test_groups_are_the_spec_set_plus_advanced():
|
||||
assert GROUPS == (
|
||||
"Identity", "Communication", "Sensors", "Alarms & beeper", "OP05", "Advanced",
|
||||
)
|
||||
|
||||
|
||||
def test_subnet_is_custom():
|
||||
assert meta_for("Subnet bits (0=class A B C)").kind == "custom"
|
||||
181
tests/da12/test_station_tab_groups.py
Normal file
181
tests/da12/test_station_tab_groups.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""StationTab grouped-settings migration (spec §5.5, Phase 4 Task 7)."""
|
||||
|
||||
from cim_suite.core.ui.kit.settings_list import GROUP_ROLE, RO_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.station_settings_meta import GROUPS
|
||||
from cim_suite.modules.da12.ui.station_tab import StationTab
|
||||
|
||||
import cim_suite.modules.da12.protocol.messages as m
|
||||
|
||||
|
||||
def _ready(qtbot, sensors=1):
|
||||
sim = SimulatedStation(sensors=sensors)
|
||||
c = StationController()
|
||||
c.attach(sim)
|
||||
sim.start()
|
||||
c.refresh()
|
||||
return c
|
||||
|
||||
|
||||
def _group_rows(tab):
|
||||
"""Group header titles in table order."""
|
||||
table = tab.settings_list.table
|
||||
return [
|
||||
table.item(r, 0).text()
|
||||
for r in range(table.rowCount())
|
||||
if table.item(r, 0) is not None and table.item(r, 0).data(GROUP_ROLE)
|
||||
]
|
||||
|
||||
|
||||
def test_groups_render_in_spec_order_and_unknowns_fall_to_advanced(qtbot):
|
||||
c = _ready(qtbot)
|
||||
# Inject an unknown label the meta table doesn't recognise -> must land in Advanced.
|
||||
rows = {line.row for line in c.settings.all()}
|
||||
new_row = max(rows) + 1
|
||||
c.settings.upsert(m.SettingLine(row=new_row, text="Mystery field (42)\t7", type_code=0))
|
||||
c.settingsChanged.emit()
|
||||
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
|
||||
groups = _group_rows(tab)
|
||||
# Rendered groups appear in canonical GROUPS order (subset -- empty groups skipped).
|
||||
# The delegate uppercases at paint time; the stored item text keeps GROUPS' casing.
|
||||
present = [g for g in GROUPS if g in groups]
|
||||
assert groups == present
|
||||
assert "Advanced" in groups
|
||||
|
||||
# The unknown row exists under Advanced with its cleaned label.
|
||||
key = str(new_row)
|
||||
assert key in tab._lines
|
||||
row = tab.settings_list._rows[key].row
|
||||
assert tab.settings_list.table.item(row, 0).text() == "Mystery field"
|
||||
|
||||
|
||||
def test_mac_row_is_read_only_with_ro_chip(qtbot):
|
||||
# The wire governs read-only via type_code < 0 ('D' lines). Seed one so the row
|
||||
# renders read-only regardless of the meta kind.
|
||||
c = _ready(qtbot)
|
||||
row = max(line.row for line in c.settings.all()) + 1
|
||||
c.settings.upsert(m.SettingLine(row=row, text="Station's mac address\t00:11:22", type_code=-1))
|
||||
c.settingsChanged.emit()
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
item = tab.settings_list.table.item(tab.settings_list._rows[str(row)].row, 1)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
assert item.data(RO_ROLE) is True
|
||||
assert not (item.flags() & Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
|
||||
def test_toggle_edit_writes_wire_value(qtbot, monkeypatch):
|
||||
c = _ready(qtbot)
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
|
||||
# Find the "Do alarms?" toggle row.
|
||||
key = next(
|
||||
k for k, line in tab._lines.items()
|
||||
if "Do alarms" in line.text.partition("\t")[0]
|
||||
)
|
||||
line = tab._lines[key]
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
c, "set_station_setting",
|
||||
lambda row, value, fmt: calls.append((row, value, fmt)),
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
item = tab.settings_list.table.item(tab.settings_list._rows[key].row, 1)
|
||||
# Flip the checkbox the way a user would; SettingsList emits settingEdited("1"/"0").
|
||||
current = item.checkState() == Qt.CheckState.Checked
|
||||
item.setCheckState(Qt.CheckState.Unchecked if current else Qt.CheckState.Checked)
|
||||
assert calls == [(line.row, "0" if current else "1", line.type_code)]
|
||||
|
||||
|
||||
def test_export_sheet_structure_and_raw_values(qtbot):
|
||||
"""export_sheet() must: correct headers, GROUPS order, raw toggle value, no empty-group rows."""
|
||||
c = _ready(qtbot)
|
||||
|
||||
# The simulator seeds rows across all six GROUPS including "Do alarms? (0=no, 1=yes)" = "1"
|
||||
# (a toggle in "Alarms & beeper"). We also inject an unknown label so Advanced is
|
||||
# definitely populated and the empty-group guarantee is exercised via the ordering check.
|
||||
rows = {line.row for line in c.settings.all()}
|
||||
new_row = max(rows) + 1
|
||||
c.settings.upsert(m.SettingLine(row=new_row, text="Mystery export field (99)\t7", type_code=0))
|
||||
c.settingsChanged.emit()
|
||||
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
|
||||
sheet = tab.export_sheet()
|
||||
assert sheet is not None
|
||||
|
||||
# Correct headers.
|
||||
assert sheet.headers == ["Setting", "Value"]
|
||||
|
||||
# Collect section-row labels: group-header rows have an empty second cell.
|
||||
section_labels = [row[0].text for row in sheet.rows if row[1].text == ""]
|
||||
|
||||
# Groups appear in canonical GROUPS order (subset -- empty groups skipped).
|
||||
# present is the ordered subset of GROUPS that actually appear; it must equal
|
||||
# section_labels exactly (correct order, no extras, no phantom empty-group rows).
|
||||
present = [g for g in GROUPS if g in section_labels]
|
||||
assert section_labels == present, (
|
||||
f"section rows out of GROUPS order or contain unknown groups: {section_labels}"
|
||||
)
|
||||
|
||||
# "Identity" precedes "Alarms & beeper" (cross-group ordering check).
|
||||
assert section_labels.index("Identity") < section_labels.index("Alarms & beeper")
|
||||
|
||||
# Empty-group guarantee: every section label must be a known GROUPS entry.
|
||||
# (meta_for() maps all unknown labels to "Advanced", never inventing new group names,
|
||||
# so an empty-by-wire GROUPS entry produces no section row.)
|
||||
assert all(label in GROUPS for label in section_labels)
|
||||
|
||||
# The "Do alarms?" toggle row exports its raw wire value ("1"), not a display string.
|
||||
data_rows = [row for row in sheet.rows if row[1].text != ""]
|
||||
alarms_row = next(
|
||||
(row for row in data_rows if row[0].text == "Do alarms?"),
|
||||
None,
|
||||
)
|
||||
assert alarms_row is not None, "export_sheet missing 'Do alarms?' row"
|
||||
assert alarms_row[1].text == "1", "toggle should export raw wire value, not display string"
|
||||
|
||||
|
||||
def test_structure_persists_across_value_updates(qtbot):
|
||||
c = _ready(qtbot)
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
table = tab.settings_list.table
|
||||
|
||||
# Strong refs to every cell item before a pure value refresh -- comparing the
|
||||
# actual objects (not id(), which Qt can recycle once an item is freed).
|
||||
before = {
|
||||
(r, col): table.item(r, col)
|
||||
for r in range(table.rowCount())
|
||||
for col in range(2)
|
||||
if table.item(r, col) is not None
|
||||
}
|
||||
rows_before = table.rowCount()
|
||||
|
||||
# Same row set again (no signature change) -> only set_value runs, items reused.
|
||||
c.settingsChanged.emit()
|
||||
assert table.rowCount() == rows_before
|
||||
for (r, col), item in before.items():
|
||||
assert table.item(r, col) is item # not a single item recreated
|
||||
|
||||
# Now add a new wire row -> signature changes -> structure rebuilt (fresh items).
|
||||
new_row = max(line.row for line in c.settings.all()) + 1
|
||||
c.settings.upsert(m.SettingLine(row=new_row, text="Late field\t9", type_code=0))
|
||||
c.settingsChanged.emit()
|
||||
assert table.rowCount() > rows_before
|
||||
old_items = list(before.values())
|
||||
rebuilt_any = any(
|
||||
all(table.item(r, col) is not old for old in old_items)
|
||||
for r in range(table.rowCount())
|
||||
for col in range(2)
|
||||
if table.item(r, col) is not None
|
||||
)
|
||||
assert rebuilt_any # the rebuild produced new item objects
|
||||
@@ -5,6 +5,15 @@ from cim_suite.modules.da12.transport.simulator import SimulatedStation
|
||||
from cim_suite.modules.da12.ui.station_tab import StationTab
|
||||
|
||||
|
||||
def _first_setting_row(tab):
|
||||
"""A table row that carries a setting (not a group header)."""
|
||||
table = tab.settings_list.table
|
||||
for r in range(table.rowCount()):
|
||||
if tab.settings_list.key_at_row(r) is not None:
|
||||
return r
|
||||
raise AssertionError("no setting rows")
|
||||
|
||||
|
||||
def test_station_row_actions_include_setting_history(qtbot):
|
||||
repo = DeviceRepository(":memory:")
|
||||
ctrl = StationController()
|
||||
@@ -16,10 +25,22 @@ def test_station_row_actions_include_setting_history(qtbot):
|
||||
|
||||
tab = StationTab(ctrl, repository=repo)
|
||||
qtbot.addWidget(tab)
|
||||
labels = [label for (label, _cb) in tab._row_actions(0, 0)]
|
||||
row = _first_setting_row(tab)
|
||||
labels = [label for (label, _cb) in tab._row_actions(row, 0)]
|
||||
assert "Setting history…" in labels
|
||||
|
||||
|
||||
def test_group_header_row_has_no_actions(qtbot):
|
||||
repo = DeviceRepository(":memory:")
|
||||
ctrl = StationController()
|
||||
ctrl.attach(SimulatedStation(sensors=4))
|
||||
ctrl.start()
|
||||
ctrl.refresh()
|
||||
tab = StationTab(ctrl, repository=repo)
|
||||
qtbot.addWidget(tab)
|
||||
assert tab._row_actions(0, 0) == [] # row 0 is a group header
|
||||
|
||||
|
||||
def test_station_tab_without_repo_constructs(qtbot):
|
||||
ctrl = StationController()
|
||||
tab = StationTab(ctrl) # no repo
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
from cim_suite.core.ui.kit.settings_list import RO_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 import station_tab as station_tab_mod
|
||||
@@ -18,17 +19,16 @@ def _tab(qtbot):
|
||||
return tab
|
||||
|
||||
|
||||
def _subnet_row(tab):
|
||||
for r in range(tab.table.rowCount()):
|
||||
if "Subnet bits" in tab.table.item(r, 0).text():
|
||||
return r
|
||||
def _subnet_key(tab):
|
||||
for key, line in tab._lines.items():
|
||||
if "Subnet bits" in line.text.partition("\t")[0]:
|
||||
return key
|
||||
raise AssertionError("Subnet bits row not found")
|
||||
|
||||
|
||||
def test_constructing_tab_does_not_write_or_flag_reboot(qtbot, monkeypatch):
|
||||
# Making the subnet cell non-editable calls setFlags, which emits itemChanged.
|
||||
# That must NOT reach on_edit and write the display string back as a setting
|
||||
# (which would also spuriously set reboot-pending). Regression guard.
|
||||
# Building the structure + pushing values must NOT reach set_station_setting
|
||||
# (which would spuriously flag reboot-pending). Regression guard.
|
||||
sim = SimulatedStation(sensors=1)
|
||||
c = StationController()
|
||||
c.attach(sim)
|
||||
@@ -47,18 +47,19 @@ def test_constructing_tab_does_not_write_or_flag_reboot(qtbot, monkeypatch):
|
||||
|
||||
def test_subnet_cell_shows_dual_form(qtbot):
|
||||
tab = _tab(qtbot)
|
||||
r = _subnet_row(tab)
|
||||
assert tab.table.item(r, 1).text() == "8 (mask 255.255.255.0)"
|
||||
key = _subnet_key(tab)
|
||||
assert tab.settings_list.value(key) == "8 (mask 255.255.255.0)"
|
||||
|
||||
|
||||
def test_subnet_cell_not_inline_editable(qtbot):
|
||||
tab = _tab(qtbot)
|
||||
r = _subnet_row(tab)
|
||||
flags = tab.table.item(r, 1).flags()
|
||||
key = _subnet_key(tab)
|
||||
row = tab.settings_list._rows[key].row
|
||||
flags = tab.settings_list.table.item(row, 1).flags()
|
||||
assert not (flags & Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
|
||||
def test_double_click_writes_host_bits(qtbot, monkeypatch):
|
||||
def test_activating_subnet_writes_host_bits(qtbot, monkeypatch):
|
||||
tab = _tab(qtbot)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
@@ -77,8 +78,8 @@ def test_double_click_writes_host_bits(qtbot, monkeypatch):
|
||||
return 16
|
||||
|
||||
monkeypatch.setattr(station_tab_mod, "SubnetDialog", FakeDialog)
|
||||
r = _subnet_row(tab)
|
||||
tab.table.cellDoubleClicked.emit(r, 1)
|
||||
key = _subnet_key(tab)
|
||||
tab.settings_list.settingActivated.emit(key)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == "16"
|
||||
|
||||
@@ -102,23 +103,17 @@ def test_cancel_writes_nothing(qtbot, monkeypatch):
|
||||
return 16
|
||||
|
||||
monkeypatch.setattr(station_tab_mod, "SubnetDialog", FakeDialog)
|
||||
r = _subnet_row(tab)
|
||||
tab.table.cellDoubleClicked.emit(r, 1)
|
||||
key = _subnet_key(tab)
|
||||
tab.settings_list.settingActivated.emit(key)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_double_click_other_row_does_not_open_dialog(qtbot, monkeypatch):
|
||||
def test_only_subnet_row_is_a_custom_activation_row(qtbot):
|
||||
# The custom (modal-dialog) path is reserved for subnet — other rows edit inline.
|
||||
tab = _tab(qtbot)
|
||||
opened = []
|
||||
monkeypatch.setattr(
|
||||
station_tab_mod, "SubnetDialog",
|
||||
lambda raw, parent=None: opened.append(raw),
|
||||
)
|
||||
# Pick any row that is NOT the subnet row; double-clicking it must not open the dialog.
|
||||
subnet = _subnet_row(tab)
|
||||
non_subnet = next(r for r in range(tab.table.rowCount()) if r != subnet)
|
||||
tab.table.cellDoubleClicked.emit(non_subnet, 1)
|
||||
assert opened == []
|
||||
subnet = _subnet_key(tab)
|
||||
custom_keys = [k for k, r in tab.settings_list._rows.items() if r.kind == "custom"]
|
||||
assert custom_keys == [subnet]
|
||||
|
||||
|
||||
def test_accepted_with_none_stored_value_writes_nothing(qtbot, monkeypatch):
|
||||
@@ -140,6 +135,26 @@ def test_accepted_with_none_stored_value_writes_nothing(qtbot, monkeypatch):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(station_tab_mod, "SubnetDialog", FakeDialog)
|
||||
r = _subnet_row(tab)
|
||||
tab.table.cellDoubleClicked.emit(r, 1)
|
||||
key = _subnet_key(tab)
|
||||
tab.settings_list.settingActivated.emit(key)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_read_only_row_renders_ro_chip(qtbot):
|
||||
# A wire line with type_code < 0 ('D' line) renders read-only: RO chip, not editable.
|
||||
import cim_suite.modules.da12.protocol.messages as m
|
||||
|
||||
sim = SimulatedStation(sensors=1)
|
||||
c = StationController()
|
||||
c.attach(sim)
|
||||
sim.start()
|
||||
c.refresh()
|
||||
row = max(line.row for line in c.settings.all()) + 1
|
||||
c.settings.upsert(m.SettingLine(row=row, text="Firmware version\tv1.2", type_code=-1))
|
||||
c.settingsChanged.emit()
|
||||
tab = StationTab(c)
|
||||
qtbot.addWidget(tab)
|
||||
|
||||
item = tab.settings_list.table.item(tab.settings_list._rows[str(row)].row, 1)
|
||||
assert item.data(RO_ROLE) is True
|
||||
assert not (item.flags() & Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
62
tests/da12/test_statistics_tab.py
Normal file
62
tests/da12/test_statistics_tab.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Task 9: Statistics tab — column kinds, units, summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cim_suite.modules.da12.domain.controller import StationController
|
||||
from cim_suite.modules.da12.transport.simulator import SimulatedStation
|
||||
from cim_suite.modules.da12.ui.statistics_tab import HEADERS, StatisticsTab
|
||||
|
||||
|
||||
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 = StatisticsTab(ctrl)
|
||||
qtbot.addWidget(tab)
|
||||
tab.rebuild()
|
||||
return tab, ctrl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_summary_shows_sensor_count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_summary_shows_sensor_count(qtbot):
|
||||
"""N stats rows → 'N SENSORS' label present; no edit hint label."""
|
||||
tab, ctrl = _tab(qtbot, sensors=3)
|
||||
|
||||
strip = tab.summary
|
||||
texts = {lbl.text() for lbl in strip.findChildren(QLabel) if lbl.objectName() == "SummaryItem"}
|
||||
assert "3 SENSORS" in texts
|
||||
|
||||
# No edit hint: the hint label should be absent or empty
|
||||
hint_labels = [
|
||||
lbl for lbl in strip.findChildren(QLabel)
|
||||
if lbl.objectName() == "SummaryHint"
|
||||
]
|
||||
# Either no hint label at all, or it is empty
|
||||
for lbl in hint_labels:
|
||||
assert lbl.text() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_units_for_time_columns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_units_for_time_columns(qtbot):
|
||||
"""'mm:ss' under Max Time and Min Time columns."""
|
||||
tab, ctrl = _tab(qtbot)
|
||||
header = tab.table.horizontalHeader()
|
||||
max_time_idx = HEADERS.index("Max Time")
|
||||
min_time_idx = HEADERS.index("Min Time")
|
||||
assert header._units.get(max_time_idx) == "mm:ss"
|
||||
assert header._units.get(min_time_idx) == "mm:ss"
|
||||
@@ -36,7 +36,9 @@ def test_open_module_builds_and_shows_widget(qtbot):
|
||||
win.open_module(win._modules[0])
|
||||
assert win._stack.count() == 2
|
||||
assert win._stack.currentWidget() is win._modules[0]._window
|
||||
assert win._back_action.isVisible() is True
|
||||
# DA-12 owns its own ← Suite button (suiteRequested signal), so the shell's
|
||||
# ☰ Suite back action is hidden for it — the module handles navigation itself.
|
||||
assert win._back_action.isVisible() is False
|
||||
|
||||
|
||||
class _WarmFake:
|
||||
|
||||
54
tests/shell/test_suite_handoff.py
Normal file
54
tests/shell/test_suite_handoff.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Modules with their own ← Suite button hide the shell's ☰ Suite back action."""
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from cim_suite.shell.window import SuiteWindow
|
||||
|
||||
|
||||
class _OwnSuiteWidget(QWidget):
|
||||
suiteRequested = Signal()
|
||||
|
||||
|
||||
class _StubModule:
|
||||
available = True
|
||||
icon = None
|
||||
|
||||
def __init__(self, id_, widget_cls):
|
||||
self.id = id_
|
||||
self.title = id_
|
||||
self.summary = ""
|
||||
self._cls = widget_cls
|
||||
|
||||
def create_widget(self, parent):
|
||||
return self._cls(parent)
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def suspend(self):
|
||||
pass
|
||||
|
||||
def resume(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_back_action_hidden_for_module_with_own_suite_button(qtbot):
|
||||
own = _StubModule("own", _OwnSuiteWidget)
|
||||
plain = _StubModule("plain", QWidget)
|
||||
win = SuiteWindow([own, plain], simulate=True, scan_fn=lambda **k: [])
|
||||
qtbot.addWidget(win)
|
||||
win.show()
|
||||
win.open_module(own)
|
||||
assert not win._back_action.isVisible()
|
||||
win.open_module(plain)
|
||||
assert win._back_action.isVisible()
|
||||
|
||||
|
||||
def test_module_suite_signal_returns_to_launcher(qtbot):
|
||||
own = _StubModule("own", _OwnSuiteWidget)
|
||||
win = SuiteWindow([own], simulate=True, scan_fn=lambda **k: [])
|
||||
qtbot.addWidget(win)
|
||||
win.open_module(own)
|
||||
win._warm["own"].suiteRequested.emit()
|
||||
assert win._stack.currentWidget() is win._launcher
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from cim_suite.modules.da12.domain.controller import StationController
|
||||
from cim_suite.modules.da12.protocol import messages as m
|
||||
from cim_suite.modules.da12.ui.history_dialog import HistoryDialog
|
||||
@@ -85,3 +87,99 @@ def test_dialog_expected_interval_reads_setting_6(qtbot):
|
||||
dlg = HistoryDialog(ctrl, _record(sid=1), None)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._expected_interval_ms() == 30000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §5.10 chart restyling tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chart_styled_per_spec(qtbot):
|
||||
from PySide6.QtCore import Qt
|
||||
from cim_suite.core.ui.theme import current, qcolor
|
||||
|
||||
ctrl = StationController()
|
||||
dlg = HistoryDialog(ctrl, _record(), None)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
t = current()
|
||||
|
||||
# Average series pen width and color
|
||||
assert dlg._avg.pen().widthF() == pytest.approx(1.6)
|
||||
assert dlg._avg.pen().color().name() == qcolor(t.chart["average"]).name()
|
||||
|
||||
# Chart background is surface
|
||||
assert dlg._chart.backgroundBrush().color().name() == qcolor(t.surface).name()
|
||||
|
||||
# All four limit-line series use CustomDashLine
|
||||
for ls in dlg._limit_series:
|
||||
assert ls.pen().style() == Qt.PenStyle.CustomDashLine
|
||||
|
||||
# Low lines (indices 0 and 1) have ~55% opacity; hi lines (2 and 3) are opaque
|
||||
assert dlg._limit_series[0].pen().color().alphaF() == pytest.approx(0.55, abs=0.02)
|
||||
assert dlg._limit_series[1].pen().color().alphaF() == pytest.approx(0.55, abs=0.02)
|
||||
assert dlg._limit_series[3].pen().color().alphaF() == pytest.approx(1.0, abs=0.02)
|
||||
|
||||
|
||||
def test_header_strip_names_the_sensor(qtbot):
|
||||
ctrl = StationController()
|
||||
dlg = HistoryDialog(ctrl, _record(), None)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._header.text().startswith("LIVE TREND — #")
|
||||
|
||||
|
||||
def test_axis_ticks_are_faint(qtbot):
|
||||
from cim_suite.core.ui.theme import current, qcolor
|
||||
|
||||
ctrl = StationController()
|
||||
dlg = HistoryDialog(ctrl, _record(), None)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
t = current()
|
||||
expected = qcolor(t.faint).name()
|
||||
assert dlg._axis_x.labelsBrush().color().name() == expected
|
||||
|
||||
|
||||
def test_limit_legend_markers_hidden_after_draw(qtbot):
|
||||
"""Regression: §5.10 limit-guide series must never appear in the legend.
|
||||
|
||||
QtCharts re-shows a series' legend marker when setVisible(True) is called;
|
||||
_draw_limit_lines must suppress those markers after each draw.
|
||||
"""
|
||||
ctrl = StationController()
|
||||
ctrl.limits.upsert(m.AlarmLimits(id=1, enable=1, delay=0,
|
||||
lo_alarm=0.0, lo_warn=2.0, hi_warn=20.0, hi_alarm=25.0))
|
||||
dlg = HistoryDialog(ctrl, _record(sid=1), None)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Give the x-axis a non-zero range so the limit lines become visible.
|
||||
ctrl.history.record_live(1, current=10.0, timestamp=1000000)
|
||||
ctrl.history.record_live(1, current=11.0, timestamp=1000060)
|
||||
ctrl.historyChanged.emit(1)
|
||||
|
||||
# All four limit series are visible (confirmed by the existing test above).
|
||||
assert all(ls.isVisible() for ls in dlg._limit_series)
|
||||
|
||||
# Every legend marker for each limit series must be hidden.
|
||||
legend = dlg._chart.legend()
|
||||
for ls in dlg._limit_series:
|
||||
for marker in legend.markers(ls):
|
||||
assert not marker.isVisible(), (
|
||||
"Limit-series legend marker should be hidden but isVisible()=True"
|
||||
)
|
||||
|
||||
|
||||
def test_theme_switch_restyles(qtbot, monkeypatch):
|
||||
import cim_suite.core.ui.theme.manager as _mgr
|
||||
from cim_suite.core.ui.theme import qcolor
|
||||
from cim_suite.core.ui.theme.tokens import DARK
|
||||
|
||||
ctrl = StationController()
|
||||
dlg = HistoryDialog(ctrl, _record(), None)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Switch to dark theme by monkeypatching the module-level name.
|
||||
monkeypatch.setattr(_mgr, "_current_name", "dark")
|
||||
dlg._apply_chart_theme()
|
||||
|
||||
expected_avg_color = qcolor(DARK.chart["average"]).name()
|
||||
assert dlg._avg.pen().color().name() == expected_avg_color
|
||||
|
||||
@@ -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