Compare commits

...

19 Commits

Author SHA1 Message Date
1f72c34725 Merge feat/instrument-da12: Instrument design system phase 4 - DA-12 adoption (spec 5.2 toolbar with connection chip and station-commands menu, settings-list station tab, status tags and alarm rows, units rows, summary strips, write feedback, spec 5.10 chart restyle) 2026-06-11 11:17:37 -04:00
9537112854 docs: record Instrument phase 4 (DA-12 adoption); BL-DS-P4 closed 2026-06-11 11:17:04 -04:00
7977ab74ee fix(da12): visual smoke findings - default width fits the toolbar, transparent toolbar marks, hidden limit-line legend markers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:10:01 -04:00
294b462734 feat(da12): history chart restyled per spec 5.10 with theme-switch repaint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:45:39 -04:00
0e89274be9 feat(da12): calibration grid kinds, units, record count summary 2026-06-11 10:37:06 -04:00
f2e353fd0d test(da12): drive the enable toggle through the itemChanged signal chain 2026-06-11 10:32:21 -04:00
ca4631f5a0 feat(da12): alarm-limits toggle + units/summary; statistics kinds + summary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:26:07 -04:00
0d7a4ac101 feat(da12): sensors grid adopts status tags, alarm rows, units, summary, write feedback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:11:26 -04:00
06dbf24856 test(da12): cover StationTab.export_sheet; clarify signature staleness note
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:03:10 -04:00
78dd76c22e feat(da12): station tab migrates to the grouped settings list (spec 5.5)
Replace the TableTab key/value grid with the kit SettingsList: rows group by
station_settings_meta (unknown labels fall to Advanced), structure rebuilds only
when the wire row-set signature changes, values stream via set_value. 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 info markers retire
in favour of row tooltips. Reboot banner, rebootRequested, repo history right-click
and xlsx export all preserved.

Adds SettingsList.key_at_row so module code maps a table row back to a key without
poking the private _rows map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:47:00 -04:00
71522b9493 test(da12): assert seed labels resolve to curated META entries, not the fallback 2026-06-11 09:38:12 -04:00
af766ab26c feat(da12): curated station-settings metadata for the 5.5 settings list 2026-06-11 09:30:14 -04:00
9d451db795 feat(da12): spec 5.2 toolbar - suite button, connection chip, station-commands menu, disconnect
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:19:16 -04:00
323affac12 test(kit): probe the chip's hollow ring; derive probe coords from class constants 2026-06-11 09:11:41 -04:00
6931914fd4 feat(kit): ConnectionChip pill for the spec 5.2 toolbar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:05:41 -04:00
e4ff6def82 fix(kit): group band yields column 0 to the selection/alarm edge bar
Replace private _BAR_WIDTH with Metrics.EDGE_BAR_W; skip the accent bar on
col 0 when the row is selected or has ALARM_ROW_ROLE set, so the base
InstrumentDelegate edge bar (signal/alarm) is never painted over. TDD: three
new paint-probe tests cover normal, selected, and alarm cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:59:21 -04:00
5497dd19ca feat(kit): SettingsList clear/custom rows/tooltips + editor guard; export SettingsDelegate 2026-06-11 08:47:37 -04:00
ea579e6c44 fix(kit): live set_rows refills skip the cell under an open editor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:40:01 -04:00
f6b89f580a docs: phase 4 (DA-12 adoption) implementation plan 2026-06-11 08:36:40 -04:00
38 changed files with 3890 additions and 245 deletions

View File

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

View File

@@ -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",

View 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()
)

View File

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

View File

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

View File

@@ -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};

View File

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

View File

@@ -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}"

View File

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

View File

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

View File

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

View File

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

View 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", "07"),
"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", "18"),
"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", "07"),
"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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 89 below). **818 tests pass across `tests/core`, `tests/da12`,
see Phases 89 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 26 (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 36 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 46; 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`**.

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

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

View File

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

View 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"

View 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) == "+"

View File

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

View File

@@ -0,0 +1,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]

View File

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

View File

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

View 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"

View 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

View File

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

View File

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

View 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"

View File

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

View 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

View File

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

View File

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