From b2ed937bdc5c73a2d9666bc5c505eb6eb584cbf8 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 08:40:55 +0200 Subject: [PATCH 1/9] feat(quick-260519-bs4-01): add TagStatusTableWindow class + pure-logic tests - New TagStatusTableWindow class in libs/FastSenseCompanion/ (handle, classical figure) - Static helpers buildRow_(tag) and filterRows_(rows, query) for unit-testable pure logic - buildRow_ handles every Tag subclass (sensor/state/monitor/composite/derived) plus the error case via try/catch (em-dash placeholders for dynamic columns) - filterRows_ is case-insensitive substring match on columns Key + Name - 11/11 function-style tests pass in tests/test_companion_tag_status_table.m - ThrowingTagStub helper class in tests/helpers/ exercises the error-recovery path Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FastSenseCompanion/TagStatusTableWindow.m | 535 ++++++++++++++++++ tests/helpers/ThrowingTagStub.m | 44 ++ tests/test_companion_tag_status_table.m | 285 ++++++++++ 3 files changed, 864 insertions(+) create mode 100644 libs/FastSenseCompanion/TagStatusTableWindow.m create mode 100644 tests/helpers/ThrowingTagStub.m create mode 100644 tests/test_companion_tag_status_table.m diff --git a/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m new file mode 100644 index 00000000..09b2e336 --- /dev/null +++ b/libs/FastSenseCompanion/TagStatusTableWindow.m @@ -0,0 +1,535 @@ +classdef TagStatusTableWindow < handle +%TAGSTATUSTABLEWINDOW Detached classical-figure window showing live status of all TagRegistry tags. +% +% Standalone classical `figure` (NOT a uifigure -- the companion owns the +% only uifigure). Constructed by FastSenseCompanion.openTagStatusTable(). +% Pulls the initial row set from TagRegistry, then refreshes only dirty +% rows when the companion's scanLiveTagUpdates_ calls markTagsDirty(keys). +% +% Lifecycle: +% w = TagStatusTableWindow(); +% w.openWith(registry, theme, companion); % builds the figure, fills the table +% w.markTagsDirty({'press_a','temp_b'}); % rebuild only those rows; re-apply filter +% w.applyTheme(theme); % live theme switch +% w.close(); % programmatic close; fires DetachClosed +% +% Events fired: +% DetachClosed -- fired exactly once when the window closes (user X click, +% programmatic close(), or companion teardown). The +% companion listens so it can call detachStatusTable_(w). +% +% See also FastSenseCompanion, LiveLogPane, TagRegistry, CompanionTheme. + + events + DetachClosed + end + + properties (SetAccess = private) + IsOpen logical = false + end + + properties (Access = private) + hFig_ = [] % classical figure handle + hTable_ = [] % uitable handle (uicontrol-style, in classical figure) + hSearch_ = [] % uicontrol 'edit' (substring filter) + hStatusLbl_ = [] % "N tags" footer label + hSearchLbl_ = [] % "Search:" label + hHeaderLbl_ = [] % "Tags" right-side header label + Registry_ = [] % TagRegistry handle (or class name placeholder) + Theme_ = [] % resolved CompanionTheme struct + Companion_ = [] % FastSenseCompanion handle (uialert parent + detach) + RowBuffer_ = cell(0, 10) + KeyToRow_ = [] % containers.Map(key -> row index into RowBuffer_) + Listeners_ = {} % addlistener handles; deleted in close() + end + + methods (Access = public) + + function obj = TagStatusTableWindow() + obj.RowBuffer_ = cell(0, 10); + obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); + end + + function openWith(obj, registry, theme, companion) + %OPENWITH Build the classical figure + uitable; fill from registry. + % registry -- TagRegistry handle (or any object; only used as a + % pass-through for parity with companion patterns). + % theme -- resolved CompanionTheme struct. + % companion -- FastSenseCompanion handle (for uialert parent + + % programmatic detach calls). + % Idempotent: if already open, brings the figure forward and returns. + if ~isstruct(theme) + error('FastSenseCompanion:tagStatusTableInvalidTheme', ... + 'TagStatusTableWindow.openWith requires a CompanionTheme struct.'); + end + % registry MAY be empty -- buildRow_/rebuildAll_ fall back to the + % TagRegistry static API. We do not strictly require a handle. + obj.Registry_ = registry; + obj.Theme_ = theme; + obj.Companion_ = companion; + if obj.IsOpen && ~isempty(obj.hFig_) && isvalid(obj.hFig_) + figure(obj.hFig_); + return; + end + + t = theme; + + % --- Classical figure window (NOT a uifigure). --- + obj.hFig_ = figure( ... + 'Name', 'Tag Status -- FastSense Companion', ... + 'NumberTitle', 'off', ... + 'MenuBar', 'none', ... + 'ToolBar', 'none', ... + 'Color', t.WidgetBackground, ... + 'Position', [100 100 1100 520], ... + 'CloseRequestFcn', @(~,~) obj.onCloseRequest_()); + movegui(obj.hFig_, 'center'); + + % --- Top search strip (normalized units) --- + obj.hSearchLbl_ = uicontrol(obj.hFig_, ... + 'Style', 'text', ... + 'Units', 'normalized', ... + 'Position', [0.01 0.93 0.06 0.05], ... + 'String', 'Search:', ... + 'HorizontalAlignment', 'left', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.ForegroundColor, ... + 'FontSize', 10); + + obj.hSearch_ = uicontrol(obj.hFig_, ... + 'Style', 'edit', ... + 'Units', 'normalized', ... + 'Position', [0.07 0.93 0.43 0.055], ... + 'String', '', ... + 'HorizontalAlignment', 'left', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.ForegroundColor, ... + 'FontSize', 10, ... + 'Callback', @(~,~) obj.applyFilter_()); + + obj.hHeaderLbl_ = uicontrol(obj.hFig_, ... + 'Style', 'text', ... + 'Units', 'normalized', ... + 'Position', [0.55 0.93 0.44 0.05], ... + 'String', 'Tags', ... + 'HorizontalAlignment', 'right', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.ForegroundColor, ... + 'FontSize', 10, ... + 'FontWeight', 'bold'); + + % --- Striped pair derived from theme (mirrors LiveLogPane). --- + stripePair = obj.stripePairFromTheme_(t); + + % --- Center uitable. --- + obj.hTable_ = uitable(obj.hFig_, ... + 'Units', 'normalized', ... + 'Position', [0.01 0.06 0.98 0.86], ... + 'ColumnName', {'Key', 'Name', 'Type', 'Criticality', 'Units', ... + 'Latest', 'Status', 'Last updated', 'Samples', 'Labels'}, ... + 'ColumnWidth', {130, 200, 75, 80, 60, 90, 80, 140, 70, 'auto'}, ... + 'ColumnEditable', false(1, 10), ... + 'RowName', {}, ... + 'FontName', 'Menlo', ... + 'FontSize', 10, ... + 'BackgroundColor', stripePair, ... + 'ForegroundColor', t.ForegroundColor, ... + 'Data', cell(0, 10)); + + % --- Footer "N tags" label. --- + obj.hStatusLbl_ = uicontrol(obj.hFig_, ... + 'Style', 'text', ... + 'Units', 'normalized', ... + 'Position', [0.01 0.005 0.98 0.04], ... + 'String', '0 / 0 tags', ... + 'HorizontalAlignment', 'left', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.PlaceholderTextColor, ... + 'FontSize', 10); + + % --- Fill from registry + apply (initial empty) filter. --- + obj.rebuildAll_(); + obj.applyFilter_(); + + obj.IsOpen = true; + end + + function markTagsDirty(obj, keys) + %MARKTAGSDIRTY Refresh only rows for the listed tag keys. + % keys -- cellstr or single char. No-op when ~IsOpen. Whole body + % wrapped in try/catch so a live tick can never crash via this path. + if ~obj.IsOpen; return; end + if isempty(keys); return; end + if ischar(keys); keys = {keys}; end + if ~iscell(keys); return; end + try + changed = false; + for k = 1:numel(keys) + key = char(keys{k}); + if isempty(key); continue; end + tag = obj.resolveTag_(key); + if isempty(tag); continue; end + row = TagStatusTableWindow.buildRow_(tag); + if obj.KeyToRow_.isKey(key) + idx = obj.KeyToRow_(key); + obj.RowBuffer_(idx, :) = row; + else + obj.RowBuffer_ = [obj.RowBuffer_; row]; + obj.KeyToRow_(key) = size(obj.RowBuffer_, 1); + end + changed = true; + end + if changed + obj.applyFilter_(); + end + catch + % Never propagate -- caller is the live tick. + end + end + + function applyTheme(obj, theme) + %APPLYTHEME Live theme switch. No-op when ~IsOpen. + if ~isstruct(theme); return; end + obj.Theme_ = theme; + if ~obj.IsOpen || isempty(obj.hFig_) || ~isvalid(obj.hFig_); return; end + try + t = theme; + obj.hFig_.Color = t.WidgetBackground; + if ~isempty(obj.hSearchLbl_) && isvalid(obj.hSearchLbl_) + obj.hSearchLbl_.BackgroundColor = t.WidgetBackground; + obj.hSearchLbl_.ForegroundColor = t.ForegroundColor; + end + if ~isempty(obj.hSearch_) && isvalid(obj.hSearch_) + obj.hSearch_.BackgroundColor = t.WidgetBackground; + obj.hSearch_.ForegroundColor = t.ForegroundColor; + end + if ~isempty(obj.hHeaderLbl_) && isvalid(obj.hHeaderLbl_) + obj.hHeaderLbl_.BackgroundColor = t.WidgetBackground; + obj.hHeaderLbl_.ForegroundColor = t.ForegroundColor; + end + if ~isempty(obj.hStatusLbl_) && isvalid(obj.hStatusLbl_) + obj.hStatusLbl_.BackgroundColor = t.WidgetBackground; + obj.hStatusLbl_.ForegroundColor = t.PlaceholderTextColor; + end + if ~isempty(obj.hTable_) && isvalid(obj.hTable_) + stripePair = obj.stripePairFromTheme_(t); + obj.hTable_.BackgroundColor = stripePair; + obj.hTable_.ForegroundColor = t.ForegroundColor; + end + catch + % Theme propagation must never throw. + end + end + + function close(obj) + %CLOSE Programmatic close; routes through onCloseRequest_ for parity. + if obj.IsOpen && ~isempty(obj.hFig_) && isvalid(obj.hFig_) + obj.onCloseRequest_(); + end + end + + function delete(obj) + %DELETE Handle-class destructor; ensure close(). + try + if obj.IsOpen + obj.close(); + end + catch + % Destructor must never throw. + end + end + + % --- Test helpers (Access=public so unit tests can reach them) --- + + function n = bufferSize(obj) + n = size(obj.RowBuffer_, 1); + end + + function row = peekRow(obj, idx) + row = obj.RowBuffer_(idx, :); + end + + function tf = isAttached(obj) + tf = obj.IsOpen; + end + + function hf = getFigForTest(obj) + hf = obj.hFig_; + end + + end + + methods (Access = private) + + function onCloseRequest_(obj) + %ONCLOSEREQUEST_ Order: drop listeners -> notify DetachClosed -> delete figure. + try + for ii = 1:numel(obj.Listeners_) + try + lh = obj.Listeners_{ii}; + if isobject(lh) && isvalid(lh) + delete(lh); + end + catch + end + end + obj.Listeners_ = {}; + catch + end + try + notify(obj, 'DetachClosed'); + catch + end + try + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + delete(obj.hFig_); + end + catch + end + obj.hFig_ = []; + obj.hTable_ = []; + obj.hSearch_ = []; + obj.hStatusLbl_ = []; + obj.hSearchLbl_ = []; + obj.hHeaderLbl_ = []; + obj.IsOpen = false; + end + + function rebuildAll_(obj) + %REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted). + obj.RowBuffer_ = cell(0, 10); + obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); + try + tags = TagRegistry.find(@(t) true); + catch + tags = {}; + end + keys = cell(1, numel(tags)); + for k = 1:numel(tags) + try + keys{k} = char(tags{k}.Key); + catch + keys{k} = ''; + end + end + % Drop tags without a usable Key. + mask = ~cellfun('isempty', keys); + keys = keys(mask); + tags = tags(mask); + % Sort by key for deterministic order. + [keysSorted, ord] = sort(keys); + tags = tags(ord); + % Preallocate the buffer up front. + nTags = numel(tags); + obj.RowBuffer_ = cell(nTags, 10); + for k = 1:nTags + obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_(tags{k}); + obj.KeyToRow_(keysSorted{k}) = k; + end + end + + function applyFilter_(obj) + %APPLYFILTER_ Push RowBuffer_ (filtered) into hTable_.Data + update footer. + if isempty(obj.hTable_) || ~isvalid(obj.hTable_); return; end + qry = ''; + if ~isempty(obj.hSearch_) && isvalid(obj.hSearch_) + qry = obj.hSearch_.String; + end + rows = TagStatusTableWindow.filterRows_(obj.RowBuffer_, qry); + obj.hTable_.Data = rows; + if ~isempty(obj.hStatusLbl_) && isvalid(obj.hStatusLbl_) + obj.hStatusLbl_.String = sprintf('%d / %d tags', ... + size(rows, 1), size(obj.RowBuffer_, 1)); + end + end + + function tag = resolveTag_(~, key) + %RESOLVETAG_ Look up a tag by key in the registry singleton. + try + tag = TagRegistry.get(key); + catch + tag = []; + end + end + + function pair = stripePairFromTheme_(~, t) + %STRIPEPAIRFROMTHEME_ 2x3 stripe pair derived from theme brightness. + isDark = mean(t.DashboardBackground) < 0.5; + if isDark + pair = [0.13 0.13 0.13; 0.20 0.20 0.20]; + else + pair = [1.00 1.00 1.00; 0.94 0.94 0.94]; + end + end + + end + + methods (Static, Access = public) + + function row = buildRow_(tag) + %BUILDROW_ Return a 1x10 cell row describing tag's current status. + % Columns: Key, Name, Type, Criticality, Units, Latest, Status, + % Last updated, Samples, Labels. + % Never throws -- a tag whose getXY/valueAt fails renders em-dash + % placeholders for the dynamic columns. + em = char(8212); + key = ''; + name = ''; + kind = ''; + crit = ''; + units = ''; + labelStr = ''; + try + key = char(tag.Key); + name = char(tag.Name); + kind = char(tag.getKind()); + crit = char(tag.Criticality); + units = char(tag.Units); + if ~isempty(tag.Labels) && iscell(tag.Labels) + labelStr = strjoin(tag.Labels, ', '); + end + catch + % Tag-level metadata read failed; best-effort defaults remain. + end + typeLabel = capitalize_(kind); + + latestTxt = em; + statusTxt = em; + lastUpdatedTxt = em; + samplesTxt = '0'; + + try + [X, Y] = tag.getXY(); + n = numel(Y); + samplesTxt = sprintf('%d', n); + if n > 0 + % --- Latest --- + if iscell(Y) + latestTxt = char(Y{end}); + elseif isnumeric(Y) && isfinite(Y(end)) + latestTxt = formatNumber_(Y(end)); + end + % --- Last updated --- + if isnumeric(X) && isfinite(X(end)) + lastUpdatedTxt = formatLastUpdated_(X(end)); + end + % --- Status (kind-aware) --- + switch kind + case 'monitor' + if isnumeric(Y) && Y(end) > 0.5 + statusTxt = 'ALARM'; + else + statusTxt = 'OK'; + end + case 'state' + stateTxt = em; + try + v = tag.valueAt(X(end)); + if iscell(v) + if ~isempty(v); stateTxt = char(v{1}); end + elseif ischar(v) || (isstring(v) && isscalar(v)) + stateTxt = char(v); + elseif isnumeric(v) && isscalar(v) && isfinite(v) + stateTxt = formatNumber_(v); + end + catch + if iscell(Y) + stateTxt = char(Y{end}); + end + end + statusTxt = stateTxt; + otherwise + statusTxt = em; + end + end + catch + % Leave placeholders; never throw. + end + + row = {key, name, typeLabel, crit, units, ... + latestTxt, statusTxt, lastUpdatedTxt, samplesTxt, labelStr}; + end + + function out = filterRows_(rows, query) + %FILTERROWS_ Case-insensitive substring filter on columns Key + Name. + % Empty/whitespace query returns rows unchanged. + if isempty(rows) + out = rows; + return; + end + qry = ''; + if ischar(query) + qry = strtrim(query); + elseif isstring(query) && isscalar(query) + qry = strtrim(char(query)); + end + if isempty(qry) + out = rows; + return; + end + qLow = lower(qry); + keep = false(size(rows, 1), 1); + for i = 1:size(rows, 1) + k = ''; + n = ''; + try + k = lower(rows{i, 1}); + n = lower(rows{i, 2}); + catch + % Row missing string columns -- skip without crashing. + end + if ~isempty(strfind(k, qLow)) || ~isempty(strfind(n, qLow)) %#ok + keep(i) = true; + end + end + out = rows(keep, :); + end + + end +end + +% =================== Local helper subfunctions =================== + +function s = capitalize_(str) +%CAPITALIZE_ ASCII first-letter capitalize; empty in/out unchanged. + if isempty(str) + s = ''; + return; + end + str = char(str); + s = [upper(str(1)), lower(str(2:end))]; +end + +function s = formatNumber_(v) +%FORMATNUMBER_ Mirror LiveLogPane.addLiveLogEntry number-formatting rules. + s = '0'; + if ~isnumeric(v) || ~isscalar(v) || ~isfinite(v) + return; + end + a = abs(v); + if a == 0 + s = '0'; + elseif a >= 1000 || a < 0.01 + s = sprintf('%.3g', v); + elseif a >= 100 + s = sprintf('%.0f', v); + elseif a >= 10 + s = sprintf('%.2f', v); + else + s = sprintf('%.3f', v); + end +end + +function s = formatLastUpdated_(x) +%FORMATLASTUPDATED_ Treat x as MATLAB datenum if in [1971, 2100], else %.3f. + s = sprintf('%.3f', x); + try + dt = datetime(x, 'ConvertFrom', 'datenum'); + y = year(dt); + if y >= 1971 && y <= 2100 + s = char(dt, 'yyyy-MM-dd HH:mm:ss'); + end + catch + % Keep numeric fallback. + end +end diff --git a/tests/helpers/ThrowingTagStub.m b/tests/helpers/ThrowingTagStub.m new file mode 100644 index 00000000..d75a5466 --- /dev/null +++ b/tests/helpers/ThrowingTagStub.m @@ -0,0 +1,44 @@ +classdef ThrowingTagStub < Tag +%THROWINGTAGSTUB Test-only Tag subclass whose getXY() throws. +% Used by test_companion_tag_status_table to verify TagStatusTableWindow.buildRow_ +% absorbs throws and renders em-dash placeholders without aborting. +% +% Reports itself as a 'sensor' kind so the type/criticality columns render +% normally; only the dynamic columns should fall back to em-dash. +% +% See also Tag, TagStatusTableWindow, test_companion_tag_status_table. + + methods + function obj = ThrowingTagStub(key) + obj@Tag(key); + end + + function [X, Y] = getXY(obj) %#ok + %GETXY Always throws — exercises buildRow_ error-recovery path. + error('ThrowingTagStub:intentional', ... + 'getXY intentionally throws for test_companion_tag_status_table.'); + end + + function v = valueAt(obj, t) %#ok + %VALUEAT Always throws. + error('ThrowingTagStub:intentional', ... + 'valueAt intentionally throws for test_companion_tag_status_table.'); + end + + function [tMin, tMax] = getTimeRange(obj) %#ok + %GETTIMERANGE Empty range. + tMin = NaN; + tMax = NaN; + end + + function k = getKind(obj) %#ok + %GETKIND Pretend to be a sensor so the type column has a real value. + k = 'sensor'; + end + + function s = toStruct(obj) %#ok + %TOSTRUCT Not needed for these tests. + s = struct('kind', 'sensor'); + end + end +end diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m new file mode 100644 index 00000000..b6f40561 --- /dev/null +++ b/tests/test_companion_tag_status_table.m @@ -0,0 +1,285 @@ +function test_companion_tag_status_table() +%TEST_COMPANION_TAG_STATUS_TABLE Pure-logic unit tests for TagStatusTableWindow. +% Function-style tests (Octave-compatible) for the two static helper methods +% on TagStatusTableWindow: +% - buildRow_(tag) : 1x10 cell row, handles every Tag subclass + throwing tags. +% - filterRows_(rows, query) : case-insensitive substring filter on Key+Name. +% +% NO UI is built; tests do not require a uifigure or graphical environment. +% +% See also TagStatusTableWindow, test_companion_filter_tags. + + add_companion_path(); + + % Cache + restore TagRegistry across the whole run so we don't pollute the + % caller. Each test resets the registry at entry via the local helper. + cleanup = onCleanup(@() TagRegistry.clear()); + + nPassed = 0; nFailed = 0; + tests = { ... + @testBuildRowForSensorTag_basic, ... + @testBuildRowForSensorTag_emptyData, ... + @testBuildRowForMonitorTag_alarm, ... + @testBuildRowForMonitorTag_ok, ... + @testBuildRowForStateTag, ... + @testBuildRowForStateTag_emptyValueAt, ... + @testBuildRowForCompositeTag, ... + @testBuildRowForDerivedTag, ... + @testBuildRow_getXYThrows, ... + @testFilterRows_caseInsensitive, ... + @testFilterRows_matchesKeyOrName }; + for i = 1:numel(tests) + name = func2str(tests{i}); + try + tests{i}(); + nPassed = nPassed + 1; + fprintf(' PASS: %s\n', name); + catch err + nFailed = nFailed + 1; + fprintf(2, ' FAIL: %s\n %s\n', name, err.message); + end + end + fprintf(' %d passed, %d failed.\n', nPassed, nFailed); + if nFailed > 0 + error('test_companion_tag_status_table:failures', ... + '%d test(s) failed.', nFailed); + end +end + +% ===================== Tests ===================== + +function testBuildRowForSensorTag_basic() + resetRegistry_(); + tag = SensorTag('k', 'Name', 'SensorName', 'Units', 'V', ... + 'Criticality', 'medium', 'Labels', {}); + tag.updateData([1 2 3], [10 20 30]); + + row = TagStatusTableWindow.buildRow_(tag); + assertSize_(row, [1 10]); + em = char(8212); + assertEqual_(row{1}, 'k', 'Key'); + assertEqual_(row{2}, 'SensorName', 'Name'); + assertEqual_(row{3}, 'Sensor', 'Type'); + assertEqual_(row{4}, 'medium', 'Criticality'); + assertEqual_(row{5}, 'V', 'Units'); + assertEqual_(row{6}, '30.00', 'Latest'); + assertEqual_(row{7}, em, 'Status'); + % Last updated is iso-style "yyyy-mm-dd HH:MM:SS" since X=3 lies inside + % the valid datenum range (year 1900+). The exact value depends on how + % datetime interprets the scalar 3 as a datenum — Year 1 (or similar) + % is OUT of the [1971, 2100] band, so the formatter falls back to %.3f. + assertEqual_(row{8}, sprintf('%.3f', 3), 'Last updated (numeric fallback)'); + assertEqual_(row{9}, '3', 'Samples'); + assertEqual_(row{10}, '', 'Labels'); +end + +function testBuildRowForSensorTag_emptyData() + resetRegistry_(); + tag = SensorTag('k_empty', 'Name', 'Empty', 'Units', 'A'); + + row = TagStatusTableWindow.buildRow_(tag); + em = char(8212); + assertEqual_(row{1}, 'k_empty', 'Key'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, '0', 'Samples'); +end + +function testBuildRowForMonitorTag_alarm() + resetRegistry_(); + parent = SensorTag('parent_a', 'Name', 'Parent A'); + parent.updateData([1 2 3], [0 0 1]); + mt = MonitorTag('mon_a', parent, @(x, y) y > 0.5, 'Name', 'Mon A'); + + row = TagStatusTableWindow.buildRow_(mt); + assertEqual_(row{1}, 'mon_a', 'Key'); + assertEqual_(row{3}, 'Monitor', 'Type'); + assertEqual_(row{7}, 'ALARM', 'Status'); + % Latest should be the numeric 1 formatted via the number rule. + assertTrue_(any(strcmp(row{6}, {'1', '1.00', '1.000'})), ... + sprintf('Latest expected ''1''/''1.00''/''1.000'', got ''%s''', row{6})); +end + +function testBuildRowForMonitorTag_ok() + resetRegistry_(); + parent = SensorTag('parent_b', 'Name', 'Parent B'); + parent.updateData([1 2 3], [1 1 1]); + % AlarmOff condition trivially false -> never turns off after on; but here + % we just want the OK case: y(end) = 0. + parent.updateData([1 2 3], [1 0 0]); + mt = MonitorTag('mon_b', parent, @(x, y) y > 0.5); + + row = TagStatusTableWindow.buildRow_(mt); + assertEqual_(row{7}, 'OK', 'Status'); +end + +function testBuildRowForStateTag() + resetRegistry_(); + st = StateTag('st_a', 'Name', 'StateA', ... + 'X', [1 2 3], 'Y', {'idle', 'run', 'stop'}); + + row = TagStatusTableWindow.buildRow_(st); + assertEqual_(row{1}, 'st_a', 'Key'); + assertEqual_(row{3}, 'State', 'Type'); + assertEqual_(row{6}, 'stop', 'Latest'); + assertEqual_(row{7}, 'stop', 'Status'); +end + +function testBuildRowForStateTag_emptyValueAt() + resetRegistry_(); + % StateTag with no data: valueAt throws StateTag:emptyState. + % buildRow_ must absorb the throw and render em-dashes. + st = StateTag('st_empty', 'Name', 'StateEmpty'); + + row = TagStatusTableWindow.buildRow_(st); + em = char(8212); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, '0', 'Samples'); +end + +function testBuildRowForCompositeTag() + resetRegistry_(); + parent = SensorTag('parent_c', 'Name', 'Parent C'); + parent.updateData([1 2 3], [0 1 0]); + mt = MonitorTag('mon_c', parent, @(x, y) y > 0.5); + ct = CompositeTag('cmp_a', 'or', 'Name', 'Composite A'); + ct.addChild(mt); + + row = TagStatusTableWindow.buildRow_(ct); + em = char(8212); + assertEqual_(row{1}, 'cmp_a', 'Key'); + assertEqual_(row{3}, 'Composite', 'Type'); + assertEqual_(row{7}, em, 'Status'); +end + +function testBuildRowForDerivedTag() + resetRegistry_(); + parent = SensorTag('parent_d', 'Name', 'Parent D'); + parent.updateData([1 2 3], [10 20 30]); + dt = DerivedTag('der_a', {parent}, ... + @(parents) localDoubleY(parents), 'Name', 'Derived A'); + + row = TagStatusTableWindow.buildRow_(dt); + em = char(8212); + assertEqual_(row{1}, 'der_a', 'Key'); + assertEqual_(row{3}, 'Derived', 'Type'); + assertEqual_(row{7}, em, 'Status'); +end + +function testBuildRow_getXYThrows() + resetRegistry_(); + stub = ThrowingTagStub_('throw_tag'); + + row = TagStatusTableWindow.buildRow_(stub); + em = char(8212); + assertEqual_(row{1}, 'throw_tag', 'Key'); + % Type/Crit/Units/Labels are from the stub's properties — still readable. + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, '0', 'Samples'); +end + +function testFilterRows_caseInsensitive() + rows = { ... + 'press_a', 'Pressure A', 'Sensor', 'medium', '', '', '', '', '', ''; ... + 'temp_b', 'Temp B', 'Sensor', 'medium', '', '', '', '', '', '' }; + + kept = TagStatusTableWindow.filterRows_(rows, 'PRESS'); + assertEqual_(size(kept, 1), 1, 'filter PRESS keeps 1 row'); + assertEqual_(kept{1, 1}, 'press_a', 'kept row Key'); + + kept2 = TagStatusTableWindow.filterRows_(rows, ''); + assertEqual_(size(kept2, 1), 2, 'empty filter keeps all rows'); + + kept3 = TagStatusTableWindow.filterRows_(rows, ' '); + assertEqual_(size(kept3, 1), 2, 'whitespace-only filter keeps all rows'); +end + +function testFilterRows_matchesKeyOrName() + rows = {'tag_x', 'Foo', 'Sensor', '', '', '', '', '', '', ''}; + + keptName = TagStatusTableWindow.filterRows_(rows, 'foo'); + assertEqual_(size(keptName, 1), 1, 'filter ''foo'' matches Name'); + + keptKey = TagStatusTableWindow.filterRows_(rows, 'tag'); + assertEqual_(size(keptKey, 1), 1, 'filter ''tag'' matches Key'); + + keptNone = TagStatusTableWindow.filterRows_(rows, 'zzz'); + assertEqual_(size(keptNone, 1), 0, 'filter ''zzz'' matches nothing'); +end + +% ===================== Helpers ===================== + +function add_companion_path() + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); + install(); +end + +function resetRegistry_() + TagRegistry.clear(); +end + +function out = localDoubleY(parents) + [X, Y] = parents{1}.getXY(); + out = struct('x', X, 'y', 2 * Y); +end + +function stub = ThrowingTagStub_(key) + % ThrowingTagStub lives in tests/helpers/. install() adds tests/ via + % addpath(genpath(...)), so the helpers/ subdirectory is on path. + stub = ThrowingTagStub(key); +end + +function assertSize_(actual, expected) + sz = size(actual); + if ~isequal(sz, expected) + error('assertSize_:mismatch', ... + 'size mismatch: got [%s], expected [%s]', ... + num2str(sz), num2str(expected)); + end +end + +function assertEqual_(actual, expected, label) + if nargin < 3, label = ''; end + if isnumeric(actual) && isnumeric(expected) && isequal(actual, expected) + return; + end + if ischar(actual) && ischar(expected) && strcmp(actual, expected) + return; + end + if isnumeric(actual) && isscalar(actual) && isnumeric(expected) && isscalar(expected) ... + && actual == expected + return; + end + if isequal(actual, expected) + return; + end + error('assertEqual_:mismatch', ... + '%s: got <%s>, expected <%s>', ... + label, valueToString_(actual), valueToString_(expected)); +end + +function assertTrue_(cond, msg) + if ~cond + if nargin < 2, msg = 'expected true'; end + error('assertTrue_:false', '%s', msg); + end +end + +function s = valueToString_(v) + if ischar(v) + s = ['''', v, '''']; + elseif isnumeric(v) && isscalar(v) + s = num2str(v); + elseif isnumeric(v) + s = ['[', num2str(v(:)'), ']']; + elseif iscell(v) + s = sprintf('<%dx%d cell>', size(v, 1), size(v, 2)); + else + s = sprintf('<%s>', class(v)); + end +end From e8a1be528368c78a644090aa38ec41d5eebe80d9 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 08:44:37 +0200 Subject: [PATCH 2/9] feat(quick-260519-bs4-02): wire TagStatusTableWindow into FastSenseCompanion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toolbar grid 1x4 -> 1x5 with new "Tags ↗" button (Tag='CompanionTagStatusBtn') in col 3 - New public method openTagStatusTable() returns the singleton window handle - Three private hooks: attachStatusTable_, detachStatusTable_, markStatusTableDirty_ - New private one-liner shouldScanForStatusTable_ gates the scan scope - scanLiveTagUpdates_ now scans ALL Tag kinds when the status table is open (was Sensor/State only); LiveLogPane.addLiveLogEntry gated to Sensor/State - updatedKeys collected per tick and batched to markStatusTableDirty_ at end - close() and setProject() both tear the window down (companion is the registered DetachClosed listener, so the window's onCloseRequest_ cleanup fires the companion's detachStatusTable_ for us) - Hidden test seams: tagStatusTableWindowForTest_, scanLiveTagUpdatesForTest_ - 7/7 class-based UI lifecycle tests pass in tests/suite/TestTagStatusTableWindow.m - TestFastSenseCompanion 64/64 still pass (no regression) - pure-logic suite 11/11 still pass Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 153 ++++++++++++- tests/suite/TestTagStatusTableWindow.m | 222 +++++++++++++++++++ 2 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 tests/suite/TestTagStatusTableWindow.m diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 249db577..09f3554a 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -91,6 +91,9 @@ OriginalLogRowHeight_ = 360 % captured at construction; restored when at least one pane is Inline EventStore_ = [] % EventStore handle resolved via constructor option or auto-discovery EventViewer_ = [] % CompanionEventViewer handle (single-instance) or [] (Task 13 wires it) + % Quick task 260519-bs4 -- Tag Status table window. + TagStatusTableWindow_ = [] % TagStatusTableWindow handle (or []) + hTagStatusBtn_ = [] % toolbar 'Tags' button (cached for theme reapply) end methods (Access = public) @@ -224,11 +227,11 @@ obj.hToolbarPanel_.Layout.Column = [1 3]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; - % Inner 1x4 grid — col 1 = Events viewer button (Task 13); - % col 2 = Live: ON/OFF button; col 3 = flex spacer; - % col 4 = gear button. - hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 4]); - hToolbarGrid.ColumnWidth = {110, 110, '1x', 36}; + % Inner 1x5 grid — col 1 = Events viewer button (Task 13); + % col 2 = Live: ON/OFF button; col 3 = Tags table launch + % (quick task 260519-bs4); col 4 = flex spacer; col 5 = gear. + hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 5]); + hToolbarGrid.ColumnWidth = {110, 110, 110, '1x', 36}; hToolbarGrid.RowHeight = {'1x'}; hToolbarGrid.Padding = [4 0 4 0]; hToolbarGrid.ColumnSpacing = 8; @@ -259,10 +262,21 @@ obj.hLiveBtn_.Tooltip = 'Toggle live refresh of the inspector'; obj.hLiveBtn_.ButtonPushedFcn = @(~,~) obj.toggleLiveMode(); - % Col 4 — Settings gear. + % Col 3 — Tag Status table launch (quick task 260519-bs4). + obj.hTagStatusBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hTagStatusBtn_.Layout.Row = 1; + obj.hTagStatusBtn_.Layout.Column = 3; + obj.hTagStatusBtn_.Text = ['Tags ', char(8599)]; % ↗ + obj.hTagStatusBtn_.FontSize = 11; + obj.hTagStatusBtn_.FontWeight = 'bold'; + obj.hTagStatusBtn_.Tag = 'CompanionTagStatusBtn'; + obj.hTagStatusBtn_.Tooltip = 'Open the tag status table'; + obj.hTagStatusBtn_.ButtonPushedFcn = @(~,~) obj.openTagStatusTable(); + + % Col 5 — Settings gear. obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push'); obj.hSettingsBtn_.Layout.Row = 1; - obj.hSettingsBtn_.Layout.Column = 4; + obj.hSettingsBtn_.Layout.Column = 5; obj.hSettingsBtn_.Text = char(9881); % gear glyph obj.hSettingsBtn_.FontSize = 14; obj.hSettingsBtn_.Tooltip = 'Companion settings'; @@ -409,6 +423,18 @@ function close(obj) end obj.LiveTimer_ = []; obj.IsLive = false; + % Tear down the Tag Status table window (quick task 260519-bs4). + % Independent try/catch so a stale window handle can't block the + % rest of teardown. + try + if ~isempty(obj.TagStatusTableWindow_) && isvalid(obj.TagStatusTableWindow_) + obj.TagStatusTableWindow_.close(); + delete(obj.TagStatusTableWindow_); + end + catch err + fprintf(2, '[FastSenseCompanion] TagStatusTableWindow cleanup failed: %s\n', err.message); + end + obj.TagStatusTableWindow_ = []; % Detach panes (releases their listeners + debounce timers). try if ~isempty(obj.CatalogPane_) && isvalid(obj.CatalogPane_) @@ -534,6 +560,17 @@ function setProject(obj, dashboards, registry) obj.SelectedDashboardIdx_ = 0; obj.LastInteraction_ = ''; obj.SelectedTagKeys_ = {}; + % Quick task 260519-bs4: close any open Tag Status table -- the + % registry identity may change with the project, so the open + % table's row-set is no longer valid. + try + if ~isempty(obj.TagStatusTableWindow_) && isvalid(obj.TagStatusTableWindow_) + obj.TagStatusTableWindow_.close(); + delete(obj.TagStatusTableWindow_); + end + catch + end + obj.TagStatusTableWindow_ = []; % Rebuild pane placeholders (detach + reattach clears children and re-creates labels) obj.CatalogPane_.detach(); obj.ListPane_.detach(); @@ -824,6 +861,37 @@ function openSettings(obj) obj.SettingsDlg_ = CompanionSettingsDialog(obj); end + function w = openTagStatusTable(obj) + %OPENTAGSTATUSTABLE Open or focus the singleton TagStatusTableWindow. + % Returns the handle so tests and external callers can drive it. + % Quick task 260519-bs4. + if ~isempty(obj.TagStatusTableWindow_) && isvalid(obj.TagStatusTableWindow_) ... + && obj.TagStatusTableWindow_.IsOpen + w = obj.TagStatusTableWindow_; + % Bring the existing classical figure to the front. + try + hf = w.getFigForTest(); + if ~isempty(hf) && isvalid(hf) + figure(hf); + end + catch + end + return; + end + try + w = TagStatusTableWindow(); + w.openWith(obj.Registry_, obj.Theme_, obj); + obj.attachStatusTable_(w); + catch err + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, ... + sprintf('Failed to open Tag Status table: %s', err.message), ... + 'Tag Status', 'Icon', 'error'); + end + w = []; + end + end + % --- Test helpers (Phase 1027.1) -- do not call from production --- % These accessors expose private state to TestFastSenseCompanion only. % They are intentionally public (MATLAB has no friend-class scope), but @@ -911,6 +979,20 @@ function openEventViewer_internalForTest(obj) f = obj.hFig_; end + % --- Quick task 260519-bs4 test seams (Hidden, do not call from production) --- + + function w = tagStatusTableWindowForTest_(obj) + %TAGSTATUSTABLEWINDOWFORTEST_ Test getter: TagStatusTableWindow_ handle (or []). + w = obj.TagStatusTableWindow_; + end + + function scanLiveTagUpdatesForTest_(obj) + %SCANLIVETAGUPDATESFORTEST_ Test seam: invoke the private live-tag scanner directly. + % Test-only API; production callers use the LiveTimer_ -> onLiveTick_ -> + % scanLiveTagUpdates_ chain. + obj.scanLiveTagUpdates_(); + end + end methods (Access = private) @@ -1100,7 +1182,7 @@ function rebalanceLogStrip_(obj) end function scanLiveTagUpdates_(obj) - %SCANLIVETAGUPDATES_ Walk SensorTag/StateTag in TagRegistry; log size deltas. + %SCANLIVETAGUPDATES_ Walk tags in TagRegistry; log size deltas + push to status table. % Guard for the truly-uninitialized state (property default is []). % Do NOT use isempty() here — isempty(containers.Map) returns true % whenever the map has 0 entries, and the map only acquires keys @@ -1109,12 +1191,22 @@ function scanLiveTagUpdates_(obj) % time the timer fires, LiveSampleCount_ is always a % containers.Map handle. if ~isa(obj.LiveSampleCount_, 'containers.Map'); return; end - if isempty(obj.LiveLogPane_) || ~isvalid(obj.LiveLogPane_); return; end + % Decide the scope: + % - When the Tag Status table is open, scan ALL Tag kinds so + % monitor / composite / derived rows refresh too. + % - Otherwise stick to the original SensorTag / StateTag scope so + % the live log is not flooded with derived-tag noise. + scanAll = obj.shouldScanForStatusTable_(); try - tags = TagRegistry.find(@(t) isa(t, 'SensorTag') || isa(t, 'StateTag')); + if scanAll + tags = TagRegistry.find(@(t) isa(t, 'Tag')); + else + tags = TagRegistry.find(@(t) isa(t, 'SensorTag') || isa(t, 'StateTag')); + end catch return; end + updatedKeys = {}; for k = 1:numel(tags) tg = tags{k}; try @@ -1133,14 +1225,53 @@ function scanLiveTagUpdates_(obj) if ~isempty(y) if iscell(y); latestY = y{end}; else; latestY = y(end); end end - if last > 0 % skip the first-seen baseline log + % Live log emission is intentionally scoped to Sensor / + % State (the existing audience for the live log). + if last > 0 && (isa(tg, 'SensorTag') || isa(tg, 'StateTag')) ... + && ~isempty(obj.LiveLogPane_) && isvalid(obj.LiveLogPane_) obj.LiveLogPane_.addLiveLogEntry(key, delta, latestY); end obj.LiveSampleCount_(key) = n; + updatedKeys{end+1} = key; %#ok end catch end end + if ~isempty(updatedKeys) + obj.markStatusTableDirty_(updatedKeys); + end + end + + function tf = shouldScanForStatusTable_(obj) + %SHOULDSCANFORSTATUSTABLE_ True when a TagStatusTableWindow is attached + open. + tf = ~isempty(obj.TagStatusTableWindow_) ... + && isvalid(obj.TagStatusTableWindow_) ... + && obj.TagStatusTableWindow_.IsOpen; + end + + function attachStatusTable_(obj, w) + %ATTACHSTATUSTABLE_ Register a TagStatusTableWindow; wire its DetachClosed listener. + % Companion forwards scanLiveTagUpdates_ deltas to w.markTagsDirty while attached. + obj.TagStatusTableWindow_ = w; + obj.Listeners_{end+1} = addlistener(w, 'DetachClosed', ... + @(~,~) obj.detachStatusTable_(w)); + end + + function detachStatusTable_(obj, w) %#ok + %DETACHSTATUSTABLE_ Drop reference so scanLiveTagUpdates_ stops pushing rows. + % Called by the DetachClosed listener; safe under double-fire. + obj.TagStatusTableWindow_ = []; + end + + function markStatusTableDirty_(obj, keys) + %MARKSTATUSTABLEDIRTY_ Forward updated tag keys to the attached window, if any. + if isempty(obj.TagStatusTableWindow_) || ~isvalid(obj.TagStatusTableWindow_); return; end + if ~obj.TagStatusTableWindow_.IsOpen; return; end + try + obj.TagStatusTableWindow_.markTagsDirty(keys); + catch + % markTagsDirty must never crash the live tick. + end end function updateLiveButton_(obj) diff --git a/tests/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m new file mode 100644 index 00000000..c91b3a5d --- /dev/null +++ b/tests/suite/TestTagStatusTableWindow.m @@ -0,0 +1,222 @@ +classdef TestTagStatusTableWindow < matlab.unittest.TestCase +%TESTTAGSTATUSTABLEWINDOW Class-based UI lifecycle tests for TagStatusTableWindow. +% Covers quick-task 260519-bs4 BS4-01..03: +% - opening the window from a real FastSenseCompanion +% - singleton semantics (twice -> same handle) +% - in-place markTagsDirty after scanLiveTagUpdates_ +% - lifecycle: window-close -> companion drops reference +% - lifecycle: companion close() / setProject() tears the window down +% - toolbar Tag button presence (Tag = 'CompanionTagStatusBtn') +% +% See also TagStatusTableWindow, FastSenseCompanion, run_all_tests. + + methods (TestClassSetup) + function gateModernMatlab(testCase) + if exist('OCTAVE_VERSION', 'builtin'); return; end + testCase.assumeTrue(~verLessThan('matlab', '9.10'), ... + 'TagStatusTableWindow suite requires MATLAB R2021a+ uifigure features'); + end + + function gateHeadlessLinux(testCase) + %GATEHEADLESSLINUX Skip on Linux CI runners -- uifigure + % construction + interaction is unreliable without a real + % X server. macOS / Windows CI cover this suite. + if exist('OCTAVE_VERSION', 'builtin'); return; end + isHeadlessLinux = ~ispc && ~ismac && ~usejava('desktop'); + testCase.assumeFalse(isHeadlessLinux, ... + 'TestTagStatusTableWindow uifigure paths fail on headless Linux -- covered on macOS/Windows CI'); + end + + function addPaths(testCase) %#ok + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (TestMethodSetup) + function skipOnOctave(testCase) + % FastSenseCompanion is MATLAB-only. Skip entire suite on Octave. + testCase.assumeFalse( ... + exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestTagStatusTableWindow: skipped on Octave (uifigure not available)'); + end + + function resetRegistry(testCase) %#ok + TagRegistry.clear(); + end + end + + methods (Test) + + function testOpenFromCompanion(testCase) + %TESTOPENFROMCOMPANION openTagStatusTable returns a valid window with 2 rows. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + + testCase.verifyClass(w, 'TagStatusTableWindow', ... + 'testOpenFromCompanion: openTagStatusTable must return a TagStatusTableWindow'); + testCase.verifyTrue(isvalid(w), ... + 'testOpenFromCompanion: returned window must be valid'); + testCase.verifyTrue(w.IsOpen, ... + 'testOpenFromCompanion: window IsOpen must be true'); + testCase.verifyEqual(w.bufferSize(), 2, ... + 'testOpenFromCompanion: 2 SensorTags registered -> buffer size 2'); + end + + function testTwiceReturnsSameWindow(testCase) + %TESTTWICERETURNSSAMEWINDOW Two openTagStatusTable calls return the SAME handle. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w1 = app.openTagStatusTable(); + w2 = app.openTagStatusTable(); + + testCase.verifyTrue(w1 == w2, ... + 'testTwiceReturnsSameWindow: second call must return the same singleton handle'); + + % Verify there is only ONE Tag-Status figure registered with root. + figs = findall(groot, 'Type', 'figure', '-and', 'Name', ... + 'Tag Status -- FastSense Companion'); + testCase.verifyEqual(numel(figs), 1, ... + 'testTwiceReturnsSameWindow: only one Tag Status figure should exist'); + end + + function testMarkTagsDirty_updatesRow(testCase) + %TESTMARKTAGSDIRTY_UPDATESROW scanLiveTagUpdates_ pushes new sample counts. + tA = SensorTag('tag_a', 'Name', 'TagA'); + tA.updateData([1 2 3], [10 20 30]); + tB = SensorTag('tag_b', 'Name', 'TagB'); + tB.updateData([1 2], [100 200]); + TagRegistry.register('tag_a', tA); + TagRegistry.register('tag_b', tB); + + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + % Seed the LiveSampleCount_ via an initial scan so we see the + % delta on the next call (first scan only baselines the counts). + app.scanLiveTagUpdatesForTest_(); + % Grow tag_a's sample count. + tA.updateData([1 2 3 4 5], [10 20 30 40 55]); + + app.scanLiveTagUpdatesForTest_(); + + % Find tag_a row in the buffer; samples column (index 9) should + % now read '5' and latest (index 6) should reflect 55. + rowA = findRowByKey_(w, 'tag_a'); + testCase.verifyNotEmpty(rowA, ... + 'testMarkTagsDirty_updatesRow: tag_a row must exist in buffer'); + testCase.verifyEqual(rowA{9}, '5', ... + 'testMarkTagsDirty_updatesRow: Samples must reflect new count after tick'); + testCase.verifyTrue(any(strcmp(rowA{6}, {'55.00', '55', '55.000'})), ... + sprintf(['testMarkTagsDirty_updatesRow: Latest must reflect new ' ... + 'value 55, got ''%s'''], rowA{6})); + end + + function testClose_deregisters(testCase) + %TESTCLOSE_DEREGISTERS Window close clears companion ref; further ticks do not throw. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + testCase.verifyTrue(isvalid(w), 'precondition: window valid'); + w.close(); + + testCase.verifyEmpty(app.tagStatusTableWindowForTest_(), ... + 'testClose_deregisters: companion TagStatusTableWindow_ must be empty after close'); + % A subsequent live tick must not throw. + app.scanLiveTagUpdatesForTest_(); + end + + function testCompanionCloseTearsDown(testCase) + %TESTCOMPANIONCLOSETEARSDOWN Closing the companion closes any open Tag Status window. + registerTwoSensors_(); + app = FastSenseCompanion(); + % Open window, then close the companion. + w = app.openTagStatusTable(); %#ok + app.close(); + testCase.addTeardown(@() TagRegistry.clear()); + + figs = findall(groot, 'Type', 'figure', '-and', 'Name', ... + 'Tag Status -- FastSense Companion'); + testCase.verifyEmpty(figs, ... + 'testCompanionCloseTearsDown: no Tag Status figure should remain after companion close'); + end + + function testSetProject_tearsDown(testCase) + %TESTSETPROJECT_TEARSDOWN setProject closes any open Tag Status window. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + testCase.verifyTrue(isvalid(w), 'precondition: window valid'); + + app.setProject({}, TagRegistry); + + testCase.verifyEmpty(app.tagStatusTableWindowForTest_(), ... + 'testSetProject_tearsDown: companion ref must be cleared after setProject'); + figs = findall(groot, 'Type', 'figure', '-and', 'Name', ... + 'Tag Status -- FastSense Companion'); + testCase.verifyEmpty(figs, ... + 'testSetProject_tearsDown: no Tag Status figure should remain after setProject'); + end + + function testButtonExistsOnToolbar(testCase) + %TESTBUTTONEXISTSONTOOLBAR Toolbar must carry a uibutton tagged 'CompanionTagStatusBtn'. + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + + hFig = app.getFigForTest_(); + btn = findall(hFig, 'Tag', 'CompanionTagStatusBtn'); + testCase.verifyNotEmpty(btn, ... + 'testButtonExistsOnToolbar: toolbar must contain a CompanionTagStatusBtn'); + testCase.verifyEqual(numel(btn), 1, ... + 'testButtonExistsOnToolbar: exactly one CompanionTagStatusBtn must exist'); + end + + end +end + +% ===================== local helpers ===================== + +function registerTwoSensors_() + TagRegistry.clear(); + s1 = SensorTag('press_a', 'Name', 'Pressure A', 'Units', 'bar'); + s1.updateData([1 2 3], [10 11 12]); + s2 = SensorTag('temp_b', 'Name', 'Temp B', 'Units', 'C'); + s2.updateData([1 2 3], [20 21 22]); + TagRegistry.register('press_a', s1); + TagRegistry.register('temp_b', s2); +end + +function safeClose_(app) + try + if isvalid(app) + app.close(); + end + catch + end +end + +function row = findRowByKey_(w, key) + row = {}; + for i = 1:w.bufferSize() + r = w.peekRow(i); + if strcmp(r{1}, key) + row = r; + return; + end + end +end From 43d2d3bf5871d414d6aa6c927c6c83ec4f2470e4 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 08:58:07 +0200 Subject: [PATCH 3/9] feat(quick-260519-bs4-03): add Activity column + window-owned refresh timer (user feedback) User feedback after first verification: the Tag Status window must keep "Latest" / "Last updated" accurate even when companion is NOT in Live mode, and tags with no update in the last 5 minutes should render as Inactive. Changes (all scoped to TagStatusTableWindow and its two test files; no changes to FastSenseCompanion.m or the existing push-on-write hook): - New "Activity" column at index 9 (between "Last updated" and "Samples"). Values: "Live" if (now - X(end)) < 300s in posixtime, else "Inactive". Empty / NaN / unanchored / future X defensively renders "Inactive". Time-base inference mirrors InspectorPane.formatXTick_ (posixtime >1e9, datenum >7e5). - New window-owned RefreshTimer_ (1s period, fixedSpacing, BusyMode='drop', unique name "TagStatusTable-"). Starts in openWith after IsOpen=true; stopped+deleted in onCloseRequest_ BEFORE Listeners cleanup. Callback in try/catch; logs via warning (not uialert); self-stops after 2 consecutive failures to prevent log noise storms. Independent of companion Live mode -- guarantees Activity/Last updated stay accurate even when companion is idle. - buildRow_(tag) -> buildRow_(tag, nowSeconds) -- the nowSeconds parameter makes the static helper pure/unit-testable for the Activity column. Backward-compatible: nargin<2 falls back to TagStatusTableWindow.nowSeconds_. - RowBuffer_ / table data widened to 11 cols. All existing test assertions updated for the new column positions (Samples now at idx 10, Labels at 11). - The original FastSenseCompanion.scanLiveTagUpdates_ -> markStatusTableDirty_ push path is unchanged; both mechanisms now run in parallel. Tests: - test_companion_tag_status_table.m: 11 existing + 5 new (Activity Live / Inactive across posix/datenum/empty/future/filter regression) = 16/16 pass. - TestTagStatusTableWindow.m: 7 existing + 2 new (Activity flip without Live mode, RefreshTimer_ stopped+deleted on close via timerfindall sweep) = 9/9 pass. - TestFastSenseCompanion regression: 64/64 pass. - checkcode / mh_lint / mh_metric: clean (informational notices on now/datenum match codebase convention in InspectorPane and LiveLogPane). --- .../FastSenseCompanion/TagStatusTableWindow.m | 253 ++++++++++++++++-- tests/suite/TestTagStatusTableWindow.m | 100 ++++++- tests/test_companion_tag_status_table.m | 129 +++++++-- 3 files changed, 437 insertions(+), 45 deletions(-) diff --git a/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m index 09b2e336..a01ab62c 100644 --- a/libs/FastSenseCompanion/TagStatusTableWindow.m +++ b/libs/FastSenseCompanion/TagStatusTableWindow.m @@ -3,15 +3,27 @@ % % Standalone classical `figure` (NOT a uifigure -- the companion owns the % only uifigure). Constructed by FastSenseCompanion.openTagStatusTable(). -% Pulls the initial row set from TagRegistry, then refreshes only dirty -% rows when the companion's scanLiveTagUpdates_ calls markTagsDirty(keys). +% Pulls the initial row set from TagRegistry, then refreshes rows via TWO +% complementary mechanisms: +% 1. Push-on-write: companion.scanLiveTagUpdates_ calls markTagsDirty(keys) +% whenever sample counts grow (zero-cost when window is closed). +% 2. Window-owned RefreshTimer_: ticks every RefreshPeriod_ seconds while +% the window is open and re-queries every tracked tag. This guarantees +% the table reflects reality even when the companion is NOT in Live +% mode (e.g. user just wants to monitor activity without running the +% full live pipeline). Quick task 260519-bs4 follow-up patch. +% +% The "Activity" column (between "Last updated" and "Samples") shows +% "Live" when X(end) is within InactiveThresholdSeconds_ of the current +% wall-clock time (using the same time-base conversion as +% formatLastUpdated_ -- datenum or posixtime). Otherwise "Inactive". % % Lifecycle: % w = TagStatusTableWindow(); -% w.openWith(registry, theme, companion); % builds the figure, fills the table +% w.openWith(registry, theme, companion); % builds the figure, fills the table, starts timer % w.markTagsDirty({'press_a','temp_b'}); % rebuild only those rows; re-apply filter % w.applyTheme(theme); % live theme switch -% w.close(); % programmatic close; fires DetachClosed +% w.close(); % programmatic close; stops timer; fires DetachClosed % % Events fired: % DetachClosed -- fired exactly once when the window closes (user X click, @@ -38,15 +50,22 @@ Registry_ = [] % TagRegistry handle (or class name placeholder) Theme_ = [] % resolved CompanionTheme struct Companion_ = [] % FastSenseCompanion handle (uialert parent + detach) - RowBuffer_ = cell(0, 10) + RowBuffer_ = cell(0, 11) KeyToRow_ = [] % containers.Map(key -> row index into RowBuffer_) Listeners_ = {} % addlistener handles; deleted in close() + RefreshTimer_ = [] % timer driving periodic re-query (window-owned; 260519-bs4 patch) + RefreshErrCount_ = 0 % consecutive errors in onRefreshTick_; auto-stops at 2 + end + + properties (Constant, Access = private) + RefreshPeriod_ = 1.0 % seconds between RefreshTimer_ ticks + InactiveThresholdSeconds_ = 300 % >= 5 min since last sample -> Activity = "Inactive" end methods (Access = public) function obj = TagStatusTableWindow() - obj.RowBuffer_ = cell(0, 10); + obj.RowBuffer_ = cell(0, 11); obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); end @@ -122,19 +141,21 @@ function openWith(obj, registry, theme, companion) stripePair = obj.stripePairFromTheme_(t); % --- Center uitable. --- + % 11 columns: Activity is column 9 (between Last updated and Samples). obj.hTable_ = uitable(obj.hFig_, ... 'Units', 'normalized', ... 'Position', [0.01 0.06 0.98 0.86], ... 'ColumnName', {'Key', 'Name', 'Type', 'Criticality', 'Units', ... - 'Latest', 'Status', 'Last updated', 'Samples', 'Labels'}, ... - 'ColumnWidth', {130, 200, 75, 80, 60, 90, 80, 140, 70, 'auto'}, ... - 'ColumnEditable', false(1, 10), ... + 'Latest', 'Status', 'Last updated', 'Activity', ... + 'Samples', 'Labels'}, ... + 'ColumnWidth', {130, 200, 75, 80, 60, 90, 80, 140, 70, 70, 'auto'}, ... + 'ColumnEditable', false(1, 11), ... 'RowName', {}, ... 'FontName', 'Menlo', ... 'FontSize', 10, ... 'BackgroundColor', stripePair, ... 'ForegroundColor', t.ForegroundColor, ... - 'Data', cell(0, 10)); + 'Data', cell(0, 11)); % --- Footer "N tags" label. --- obj.hStatusLbl_ = uicontrol(obj.hFig_, ... @@ -152,6 +173,11 @@ function openWith(obj, registry, theme, companion) obj.applyFilter_(); obj.IsOpen = true; + + % --- Start the window-owned refresh timer. --- + % Independent of companion Live mode so Activity / Last updated + % stay accurate even when the companion is idle. 260519-bs4 patch. + obj.startRefreshTimer_(); end function markTagsDirty(obj, keys) @@ -163,13 +189,14 @@ function markTagsDirty(obj, keys) if ischar(keys); keys = {keys}; end if ~iscell(keys); return; end try + nowSec = TagStatusTableWindow.nowSeconds_(); changed = false; for k = 1:numel(keys) key = char(keys{k}); if isempty(key); continue; end tag = obj.resolveTag_(key); if isempty(tag); continue; end - row = TagStatusTableWindow.buildRow_(tag); + row = TagStatusTableWindow.buildRow_(tag, nowSec); if obj.KeyToRow_.isKey(key) idx = obj.KeyToRow_(key); obj.RowBuffer_(idx, :) = row; @@ -262,7 +289,11 @@ function delete(obj) methods (Access = private) function onCloseRequest_(obj) - %ONCLOSEREQUEST_ Order: drop listeners -> notify DetachClosed -> delete figure. + %ONCLOSEREQUEST_ Order: stop+delete timer -> drop listeners -> notify DetachClosed -> delete figure. + % --- Stop and delete the refresh timer BEFORE listener cleanup. --- + % stop(t) then delete(t) order is required by the project's + % cross-cutting engineering constraint (Phase 1018 lock). + obj.stopRefreshTimer_(); try for ii = 1:numel(obj.Listeners_) try @@ -297,7 +328,7 @@ function onCloseRequest_(obj) function rebuildAll_(obj) %REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted). - obj.RowBuffer_ = cell(0, 10); + obj.RowBuffer_ = cell(0, 11); obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); try tags = TagRegistry.find(@(t) true); @@ -321,9 +352,10 @@ function rebuildAll_(obj) tags = tags(ord); % Preallocate the buffer up front. nTags = numel(tags); - obj.RowBuffer_ = cell(nTags, 10); + obj.RowBuffer_ = cell(nTags, 11); + nowSec = TagStatusTableWindow.nowSeconds_(); for k = 1:nTags - obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_(tags{k}); + obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_(tags{k}, nowSec); obj.KeyToRow_(keysSorted{k}) = k; end end @@ -362,16 +394,124 @@ function applyFilter_(obj) end end + function startRefreshTimer_(obj) + %STARTREFRESHTIMER_ Create and start the window-owned refresh timer. + % Independent of companion Live mode -- guarantees the table + % re-queries every tag every RefreshPeriod_ seconds while open, + % so Activity / Last updated stay accurate even when the + % companion is idle. Wrapped in try/catch; failure to construct + % the timer (e.g. on a stripped-down environment) is non-fatal: + % the push-on-write path from scanLiveTagUpdates_ still works. + % 260519-bs4 patch. + obj.RefreshErrCount_ = 0; + try + if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_) + stop(obj.RefreshTimer_); + delete(obj.RefreshTimer_); + end + % Unique name so orphan timers from crashed tests can be + % discovered via timerfindall and cleaned up. + tName = sprintf('TagStatusTable-%s', randomTimerSuffix_()); + obj.RefreshTimer_ = timer( ... + 'Name', tName, ... + 'Period', obj.RefreshPeriod_, ... + 'ExecutionMode', 'fixedSpacing', ... + 'BusyMode', 'drop', ... + 'TimerFcn', @(~, ~) obj.onRefreshTick_()); + start(obj.RefreshTimer_); + catch err + warning('FastSenseCompanion:tagStatusTableTimerStart', ... + 'TagStatusTableWindow: failed to start refresh timer: %s', ... + err.message); + obj.RefreshTimer_ = []; + end + end + + function stopRefreshTimer_(obj) + %STOPREFRESHTIMER_ Stop and delete the refresh timer in stop+delete order. + try + if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_) + try + stop(obj.RefreshTimer_); + catch + end + delete(obj.RefreshTimer_); + end + catch + % Teardown must never throw. + end + obj.RefreshTimer_ = []; + end + + function onRefreshTick_(obj) + %ONREFRESHTICK_ Re-query every tracked tag; only repaint when data changed. + % Wrapped in try/catch; logs via `warning` rather than uialert + % (uialert per tick would be noise-storm). After 2 consecutive + % ticks throw, the timer self-stops to prevent log flooding. + if ~obj.IsOpen + return; + end + try + nowSec = TagStatusTableWindow.nowSeconds_(); + changed = false; + keys = obj.KeyToRow_.keys(); + for k = 1:numel(keys) + key = keys{k}; + if ~obj.KeyToRow_.isKey(key); continue; end + idx = obj.KeyToRow_(key); + tag = obj.resolveTag_(key); + if isempty(tag); continue; end + newRow = TagStatusTableWindow.buildRow_(tag, nowSec); + oldRow = obj.RowBuffer_(idx, :); + if ~isequal(newRow, oldRow) + obj.RowBuffer_(idx, :) = newRow; + changed = true; + end + end + if changed + obj.applyFilter_(); + end + obj.RefreshErrCount_ = 0; % reset on a clean tick + catch err + obj.RefreshErrCount_ = obj.RefreshErrCount_ + 1; + warning('FastSenseCompanion:tagStatusTableTickFailed', ... + 'TagStatusTableWindow refresh tick failed: %s', err.message); + if obj.RefreshErrCount_ >= 2 + warning('FastSenseCompanion:tagStatusTableTickAborted', ... + ['TagStatusTableWindow refresh timer self-stopped ' ... + 'after 2 consecutive failures.']); + obj.stopRefreshTimer_(); + end + end + end + end methods (Static, Access = public) - function row = buildRow_(tag) - %BUILDROW_ Return a 1x10 cell row describing tag's current status. + function row = buildRow_(tag, nowSeconds) + %BUILDROW_ Return a 1x11 cell row describing tag's current status. % Columns: Key, Name, Type, Criticality, Units, Latest, Status, - % Last updated, Samples, Labels. + % Last updated, Activity, Samples, Labels. + % + % Inputs: + % tag -- Tag handle (any subclass; tolerant of throws) + % nowSeconds -- (optional) current wall-clock time as posix + % seconds, used for the Activity column. When + % omitted, TagStatusTableWindow.nowSeconds_() is + % queried (slightly more expensive). Tests pass + % an explicit value for determinism. 260519-bs4 patch. + % + % The Activity column is "Live" when X(end) is within + % InactiveThresholdSeconds_ (5 minutes) of nowSeconds in the same + % time base, else "Inactive". Empty / unconvertible / future X + % defensively renders "Inactive". + % % Never throws -- a tag whose getXY/valueAt fails renders em-dash - % placeholders for the dynamic columns. + % placeholders for the dynamic columns AND "Inactive" for Activity. + if nargin < 2 || isempty(nowSeconds) + nowSeconds = TagStatusTableWindow.nowSeconds_(); + end em = char(8212); key = ''; name = ''; @@ -396,6 +536,7 @@ function applyFilter_(obj) latestTxt = em; statusTxt = em; lastUpdatedTxt = em; + activityTxt = 'Inactive'; samplesTxt = '0'; try @@ -409,9 +550,11 @@ function applyFilter_(obj) elseif isnumeric(Y) && isfinite(Y(end)) latestTxt = formatNumber_(Y(end)); end - % --- Last updated --- + % --- Last updated + Activity --- if isnumeric(X) && isfinite(X(end)) lastUpdatedTxt = formatLastUpdated_(X(end)); + activityTxt = computeActivity_(X(end), nowSeconds, ... + TagStatusTableWindow.InactiveThresholdSeconds_); end % --- Status (kind-aware) --- switch kind @@ -447,7 +590,27 @@ function applyFilter_(obj) end row = {key, name, typeLabel, crit, units, ... - latestTxt, statusTxt, lastUpdatedTxt, samplesTxt, labelStr}; + latestTxt, statusTxt, lastUpdatedTxt, activityTxt, ... + samplesTxt, labelStr}; + end + + function s = nowSeconds_() + %NOWSECONDS_ Return current wall-clock time as posix seconds. + % Used as the reference for the Activity column. Posix-seconds + % is chosen because it composes cleanly with both posixtime + % (s > 1e9) and datenum (s > 7e5) X bases via computeActivity_. + % Falls back to 0 if datetime/posixtime are not available, in + % which case all rows render "Inactive" (defensive). 260519-bs4. + try + s = posixtime(datetime('now')); + catch + try + % Octave fallback: compute posix from now() (datenum). + s = (now - datenum(1970, 1, 1)) * 86400; + catch + s = 0; + end + end end function out = filterRows_(rows, query) @@ -533,3 +696,51 @@ function applyFilter_(obj) % Keep numeric fallback. end end + +function s = computeActivity_(xLast, nowSec, thresholdSec) +%COMPUTEACTIVITY_ Return 'Live' or 'Inactive' based on xLast vs nowSec. +% Time-base inference mirrors InspectorPane.formatXTick_: +% xLast > 1e9 -> posixtime seconds (compare directly to nowSec) +% xLast > 7e5 -> MATLAB datenum days (convert to posix seconds) +% else -> "seconds-since-something" we cannot anchor; Inactive. +% Defensive cases: NaN / non-finite / future timestamp -> Inactive. +% 260519-bs4 patch. + s = 'Inactive'; + if ~isnumeric(xLast) || ~isscalar(xLast) || ~isfinite(xLast) + return; + end + if ~isnumeric(nowSec) || ~isscalar(nowSec) || ~isfinite(nowSec) || nowSec <= 0 + return; + end + xPosix = NaN; + if xLast > 1e9 + xPosix = xLast; + elseif xLast > 7e5 + % datenum days -> posix seconds. + xPosix = (xLast - datenum(1970, 1, 1)) * 86400; + end + if ~isfinite(xPosix) + return; + end + deltaSec = nowSec - xPosix; + % Negative delta = future timestamp (clock skew or test fixture); + % treat defensively as Inactive. + if deltaSec < 0 + return; + end + if deltaSec < thresholdSec + s = 'Live'; + end +end + +function s = randomTimerSuffix_() +%RANDOMTIMERSUFFIX_ Short unique suffix for the refresh timer name. +% Used so multiple concurrent windows / orphans from crashed tests can +% be discovered via `timerfindall('Name','TagStatusTable-*')`. + try + s = char(java.util.UUID.randomUUID().toString()); + catch + % Fallback: timestamp + random digits (no Java). + s = sprintf('%.0f-%d', now * 86400, randi(1e6)); + end +end diff --git a/tests/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m index c91b3a5d..22d1f845 100644 --- a/tests/suite/TestTagStatusTableWindow.m +++ b/tests/suite/TestTagStatusTableWindow.m @@ -109,12 +109,13 @@ function testMarkTagsDirty_updatesRow(testCase) app.scanLiveTagUpdatesForTest_(); - % Find tag_a row in the buffer; samples column (index 9) should - % now read '5' and latest (index 6) should reflect 55. + % Find tag_a row in the buffer; samples column (now index 10 after + % Activity column was inserted at 9) should read '5' and latest + % (index 6) should reflect 55. rowA = findRowByKey_(w, 'tag_a'); testCase.verifyNotEmpty(rowA, ... 'testMarkTagsDirty_updatesRow: tag_a row must exist in buffer'); - testCase.verifyEqual(rowA{9}, '5', ... + testCase.verifyEqual(rowA{10}, '5', ... 'testMarkTagsDirty_updatesRow: Samples must reflect new count after tick'); testCase.verifyTrue(any(strcmp(rowA{6}, {'55.00', '55', '55.000'})), ... sprintf(['testMarkTagsDirty_updatesRow: Latest must reflect new ' ... @@ -186,6 +187,55 @@ function testButtonExistsOnToolbar(testCase) 'testButtonExistsOnToolbar: exactly one CompanionTagStatusBtn must exist'); end + function testActivityFlipsWithoutLiveMode(testCase) + %TESTACTIVITYFLIPSWITHOUTLIVEMODE buildRow_ flips Live->Inactive as nowSeconds advances. + % This is the integration-level proxy for "window's own timer keeps + % Activity accurate when companion is NOT in Live mode": we drive + % the pure-logic seam (buildRow_'s nowSeconds parameter) the same + % way the timer's onRefreshTick_ does internally. + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + + tag = SensorTag('ax', 'Name', 'Anchor X'); + xLast = 1.7e9; % posix-time-ish anchor + tag.updateData([xLast - 1, xLast], [1 2]); + + % t = xLast + 10s -> within threshold -> Live. + rowLive = TagStatusTableWindow.buildRow_(tag, xLast + 10); + testCase.verifyEqual(rowLive{9}, 'Live', ... + 'testActivityFlipsWithoutLiveMode: must be Live at +10s'); + + % t = xLast + 301s -> beyond threshold (300s) -> Inactive. + rowInactive = TagStatusTableWindow.buildRow_(tag, xLast + 301); + testCase.verifyEqual(rowInactive{9}, 'Inactive', ... + 'testActivityFlipsWithoutLiveMode: must be Inactive at +301s'); + end + + function testRefreshTimerStoppedAndDeletedOnClose(testCase) + %TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + testCase.addTeardown(@() cleanupLeakedTimers_()); + + w = app.openTagStatusTable(); + + % Snapshot the window's timer set immediately after open. + timersBefore = findStatusTableTimers_(); + testCase.verifyNotEmpty(timersBefore, ... + ['testRefreshTimerStoppedAndDeletedOnClose: a TagStatusTable-* ' ... + 'timer must exist after openWith']); + + w.close(); + + % After close, no TagStatusTable-* timers should remain. + timersAfter = findStatusTableTimers_(); + testCase.verifyEmpty(timersAfter, ... + ['testRefreshTimerStoppedAndDeletedOnClose: all TagStatusTable-* ' ... + 'timers must be stopped+deleted after close']); + end + end end @@ -220,3 +270,47 @@ function safeClose_(app) end end end + +function cleanupLeakedTimers_() + % Defensive sweep: stop+delete any TagStatusTable-* timers that survived + % a test failure mid-execution. Required because timers persist across + % MATLAB scope (root-owned), so a panic'd test could otherwise leave + % the next test's findStatusTableTimers_ polluted. + try + leaked = findStatusTableTimers_(); + for k = 1:numel(leaked) + try + stop(leaked(k)); + catch + end + try + delete(leaked(k)); + catch + end + end + catch + end +end + +function out = findStatusTableTimers_() + % timerfindall does not accept '-regexp', so we get all timers and + % filter by Name prefix. Returns an empty array if nothing matches. + out = []; + try + all = timerfindall; + if isempty(all); return; end + keep = false(1, numel(all)); + for k = 1:numel(all) + try + nm = get(all(k), 'Name'); + if ischar(nm) && strncmp(nm, 'TagStatusTable-', 15) + keep(k) = true; + end + catch + end + end + out = all(keep); + catch + out = []; + end +end diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m index b6f40561..1e6a02c3 100644 --- a/tests/test_companion_tag_status_table.m +++ b/tests/test_companion_tag_status_table.m @@ -27,7 +27,12 @@ function test_companion_tag_status_table() @testBuildRowForDerivedTag, ... @testBuildRow_getXYThrows, ... @testFilterRows_caseInsensitive, ... - @testFilterRows_matchesKeyOrName }; + @testFilterRows_matchesKeyOrName, ... + @testActivityLive_recentPosixTimestamp, ... + @testActivityInactive_oldDatenumTimestamp, ... + @testActivityInactive_emptyXY, ... + @testActivityInactive_futureTimestamp, ... + @testFilterRows_subsetFixture }; for i = 1:numel(tests) name = func2str(tests{i}); try @@ -55,7 +60,7 @@ function testBuildRowForSensorTag_basic() tag.updateData([1 2 3], [10 20 30]); row = TagStatusTableWindow.buildRow_(tag); - assertSize_(row, [1 10]); + assertSize_(row, [1 11]); em = char(8212); assertEqual_(row{1}, 'k', 'Key'); assertEqual_(row{2}, 'SensorName', 'Name'); @@ -69,8 +74,12 @@ function testBuildRowForSensorTag_basic() % datetime interprets the scalar 3 as a datenum — Year 1 (or similar) % is OUT of the [1971, 2100] band, so the formatter falls back to %.3f. assertEqual_(row{8}, sprintf('%.3f', 3), 'Last updated (numeric fallback)'); - assertEqual_(row{9}, '3', 'Samples'); - assertEqual_(row{10}, '', 'Labels'); + % Activity is "Inactive" because X(end)=3 is below 7e5 (the + % datenum-or-posix anchor threshold in computeActivity_), so we cannot + % map it to a wall-clock time and defensively render "Inactive". + assertEqual_(row{9}, 'Inactive', 'Activity (unanchored X)'); + assertEqual_(row{10}, '3', 'Samples'); + assertEqual_(row{11}, '', 'Labels'); end function testBuildRowForSensorTag_emptyData() @@ -79,11 +88,12 @@ function testBuildRowForSensorTag_emptyData() row = TagStatusTableWindow.buildRow_(tag); em = char(8212); - assertEqual_(row{1}, 'k_empty', 'Key'); - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, '0', 'Samples'); + assertEqual_(row{1}, 'k_empty', 'Key'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (empty XY)'); + assertEqual_(row{10}, '0', 'Samples'); end function testBuildRowForMonitorTag_alarm() @@ -134,10 +144,11 @@ function testBuildRowForStateTag_emptyValueAt() row = TagStatusTableWindow.buildRow_(st); em = char(8212); - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, '0', 'Samples'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (empty state)'); + assertEqual_(row{10}, '0', 'Samples'); end function testBuildRowForCompositeTag() @@ -175,18 +186,19 @@ function testBuildRow_getXYThrows() row = TagStatusTableWindow.buildRow_(stub); em = char(8212); - assertEqual_(row{1}, 'throw_tag', 'Key'); + assertEqual_(row{1}, 'throw_tag', 'Key'); % Type/Crit/Units/Labels are from the stub's properties — still readable. - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, '0', 'Samples'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (throwing getXY)'); + assertEqual_(row{10}, '0', 'Samples'); end function testFilterRows_caseInsensitive() rows = { ... - 'press_a', 'Pressure A', 'Sensor', 'medium', '', '', '', '', '', ''; ... - 'temp_b', 'Temp B', 'Sensor', 'medium', '', '', '', '', '', '' }; + 'press_a', 'Pressure A', 'Sensor', 'medium', '', '', '', '', '', '', ''; ... + 'temp_b', 'Temp B', 'Sensor', 'medium', '', '', '', '', '', '', '' }; kept = TagStatusTableWindow.filterRows_(rows, 'PRESS'); assertEqual_(size(kept, 1), 1, 'filter PRESS keeps 1 row'); @@ -200,7 +212,7 @@ function testFilterRows_caseInsensitive() end function testFilterRows_matchesKeyOrName() - rows = {'tag_x', 'Foo', 'Sensor', '', '', '', '', '', '', ''}; + rows = {'tag_x', 'Foo', 'Sensor', '', '', '', '', '', '', '', ''}; keptName = TagStatusTableWindow.filterRows_(rows, 'foo'); assertEqual_(size(keptName, 1), 1, 'filter ''foo'' matches Name'); @@ -212,6 +224,81 @@ function testFilterRows_matchesKeyOrName() assertEqual_(size(keptNone, 1), 0, 'filter ''zzz'' matches nothing'); end +% ===================== Activity column (260519-bs4 patch) ===================== + +function testActivityLive_recentPosixTimestamp() + % SensorTag whose X(end) is a recent posix-time timestamp -> Activity='Live'. + % Use a fake "now" we control via the nowSeconds optional arg. + resetRegistry_(); + tag = SensorTag('live_recent', 'Name', 'Live Recent'); + nowSec = 1.7e9; % posix-time-ish (definitely > 1e9) + xLast = nowSec - 30; % 30 s ago, well under 300 s threshold + tag.updateData([xLast - 2, xLast - 1, xLast], [1 2 3]); + + row = TagStatusTableWindow.buildRow_(tag, nowSec); + assertEqual_(row{9}, 'Live', 'Activity should be Live for recent posix X'); +end + +function testActivityInactive_oldDatenumTimestamp() + % SensorTag with X(end) ~ 10 minutes old in datenum days -> Activity='Inactive'. + resetRegistry_(); + tag = SensorTag('inactive_old', 'Name', 'Inactive Old'); + nowSec = 1.7e9; + % Build datenum days such that (xPosix = (xDays - epoch)*86400) is 10min behind nowSec. + epochDays = datenum(1970, 1, 1); + xDays = epochDays + (nowSec - 600) / 86400; % 10 min < 300 s threshold? NO, 600 > 300. + tag.updateData([xDays - 1, xDays], [1 2]); + + row = TagStatusTableWindow.buildRow_(tag, nowSec); + assertEqual_(row{9}, 'Inactive', 'Activity should be Inactive for 10min-old datenum X'); +end + +function testActivityInactive_emptyXY() + % SensorTag with no data -> Activity='Inactive' (samples=0 branch). + resetRegistry_(); + tag = SensorTag('no_data', 'Name', 'No Data'); + + row = TagStatusTableWindow.buildRow_(tag, 1.7e9); + assertEqual_(row{9}, 'Inactive', 'Activity should be Inactive when XY is empty'); + assertEqual_(row{10}, '0', 'Samples should be 0'); +end + +function testActivityInactive_futureTimestamp() + % X(end) in the future (clock skew) -> defensively render Inactive. + resetRegistry_(); + tag = SensorTag('future_ts', 'Name', 'Future TS'); + nowSec = 1.7e9; + xLast = nowSec + 60; % 60 s in the future + tag.updateData([xLast - 1, xLast], [1 2]); + + row = TagStatusTableWindow.buildRow_(tag, nowSec); + assertEqual_(row{9}, 'Inactive', 'Activity should be Inactive for future X (defensive)'); +end + +function testFilterRows_subsetFixture() + % Regression guard for the search field: filter on a multi-row fixture + % and confirm we get exactly the expected subset. + rows = { ... + 'pressure_inlet', 'Pressure Inlet', 'Sensor', '', '', '', '', '', '', '', ''; ... + 'pressure_outlet', 'Pressure Outlet', 'Sensor', '', '', '', '', '', '', '', ''; ... + 'temp_a', 'Temperature A', 'Sensor', '', '', '', '', '', '', '', ''; ... + 'state_machine', 'State Machine', 'State', '', '', '', '', '', '', '', ''; ... + 'alarm_high', 'Alarm High', 'Monitor', '', '', '', '', '', '', '', '' }; + + kept = TagStatusTableWindow.filterRows_(rows, 'pressure'); + assertEqual_(size(kept, 1), 2, 'filter ''pressure'' keeps 2 rows'); + assertEqual_(kept{1, 1}, 'pressure_inlet', 'first kept Key'); + assertEqual_(kept{2, 1}, 'pressure_outlet', 'second kept Key'); + + keptUpper = TagStatusTableWindow.filterRows_(rows, 'TEMP'); + assertEqual_(size(keptUpper, 1), 1, 'filter ''TEMP'' case-insensitive'); + assertEqual_(keptUpper{1, 1}, 'temp_a', 'matched Key for ''TEMP'''); + + keptNameOnly = TagStatusTableWindow.filterRows_(rows, 'machine'); + assertEqual_(size(keptNameOnly, 1), 1, 'filter ''machine'' matches Name'); + assertEqual_(keptNameOnly{1, 1}, 'state_machine', 'matched via Name field'); +end + % ===================== Helpers ===================== function add_companion_path() From 2a249651dec360b1cc611d0420dc2a74d45e5cab Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 09:06:07 +0200 Subject: [PATCH 4/9] feat(quick-260519-bs4-04): add last-refreshed header + Type/Criticality/Activity chips + broader search - TagStatusTableWindow: new "Last refreshed: HH:MM:SS" label at top of figure; seeded on openWith, updated on every clean onRefreshTick_ (even when no rows changed, so it acts as a heartbeat). - Three chip groups above the table (Type / Criticality / Activity), multi-toggle within each group, default-all-active so first-open shows the same 18 rows as today. Active chips use theme.Accent + bold, matching TagCatalogPane.applyPillStyle_. - Broadened free-text search from Key+Name to Key+Name+Units+Labels (Labels are stored comma-joined in column 11). filterRows_ signature extended to (rows, query, activeTypes, activeCrits, activeActivities) with full backward compatibility on the 2-arg form via nargin defaults and an iscell sentinel ([] = skip, {} = exclude-all). Combined semantics: AND across dimensions, OR within each chip group; a chip group with zero active entries excludes ALL rows. - Layout: window 520->580 px tall; five vertical strips (label, search, chips, table, footer); window stays resizable. - All widgets are uicontrol 'pushbutton'/'text' so we stay inside the classical-figure widget family (uibutton is uifigure-only). - onCloseRequest_ unchanged in ordering: stop+delete RefreshTimer_ BEFORE Listeners cleanup. New handles cleared on close. - Tests: +4 pure-logic cases (Units search, Labels search, chip AND/OR semantics, zero-chip-group excludes all) + 2 UI cases (label exists on open, label updates after tick) via new test seams lastRefreshedLabelForTest / tickForTest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FastSenseCompanion/TagStatusTableWindow.m | 407 ++++++++++++++++-- tests/suite/TestTagStatusTableWindow.m | 46 ++ tests/test_companion_tag_status_table.m | 105 ++++- 3 files changed, 527 insertions(+), 31 deletions(-) diff --git a/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m index a01ab62c..c760ce4e 100644 --- a/libs/FastSenseCompanion/TagStatusTableWindow.m +++ b/libs/FastSenseCompanion/TagStatusTableWindow.m @@ -47,6 +47,13 @@ hStatusLbl_ = [] % "N tags" footer label hSearchLbl_ = [] % "Search:" label hHeaderLbl_ = [] % "Tags" right-side header label + hLastRefreshLbl_ = [] % "Last refreshed: HH:MM:SS" label (260519-bs4-04 patch) + hChipsType_ = [] % 1x5 array of uicontrol pushbuttons (Sensor/Monitor/Composite/State/Derived) + hChipsCrit_ = [] % 1x4 array of uicontrol pushbuttons (Low/Medium/High/Safety) + hChipsActivity_ = [] % 1x2 array of uicontrol pushbuttons (Live/Inactive) + ActiveTypeChips_ = {} % active type keys; subset of TypeChipKeys_; empty = none-selected -> excludes all + ActiveCritChips_ = {} % active criticality keys; subset of CritChipKeys_ + ActiveActivityChips_ = {} % active activity keys; subset of ActivityChipKeys_ Registry_ = [] % TagRegistry handle (or class name placeholder) Theme_ = [] % resolved CompanionTheme struct Companion_ = [] % FastSenseCompanion handle (uialert parent + detach) @@ -60,6 +67,14 @@ properties (Constant, Access = private) RefreshPeriod_ = 1.0 % seconds between RefreshTimer_ ticks InactiveThresholdSeconds_ = 300 % >= 5 min since last sample -> Activity = "Inactive" + % Chip filter dimensions (260519-bs4-04 patch). Keys are stored + % lower-case (canonical). Labels are display-cased. + TypeChipKeys_ = {'sensor', 'monitor', 'composite', 'state', 'derived'} + TypeChipLabels_ = {'Sensor', 'Monitor', 'Composite', 'State', 'Derived'} + CritChipKeys_ = {'low', 'medium', 'high', 'safety'} + CritChipLabels_ = {'Low', 'Medium', 'High', 'Safety'} + ActivityChipKeys_ = {'live', 'inactive'} + ActivityChipLabels_ = {'Live', 'Inactive'} end methods (Access = public) @@ -67,6 +82,11 @@ function obj = TagStatusTableWindow() obj.RowBuffer_ = cell(0, 11); obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); + % Default chip state: every chip ACTIVE (first-open shows + % everything). 260519-bs4-04 patch. + obj.ActiveTypeChips_ = obj.TypeChipKeys_; + obj.ActiveCritChips_ = obj.CritChipKeys_; + obj.ActiveActivityChips_ = obj.ActivityChipKeys_; end function openWith(obj, registry, theme, companion) @@ -94,21 +114,48 @@ function openWith(obj, registry, theme, companion) t = theme; % --- Classical figure window (NOT a uifigure). --- + % Window slightly taller than the original 520px to fit the + % new last-refreshed label + chip strip introduced by the + % 260519-bs4-04 patch without squeezing the table. Window + % stays resizable (no 'Resize','off'). obj.hFig_ = figure( ... 'Name', 'Tag Status -- FastSense Companion', ... 'NumberTitle', 'off', ... 'MenuBar', 'none', ... 'ToolBar', 'none', ... 'Color', t.WidgetBackground, ... - 'Position', [100 100 1100 520], ... + 'Position', [100 100 1100 580], ... 'CloseRequestFcn', @(~,~) obj.onCloseRequest_()); movegui(obj.hFig_, 'center'); - % --- Top search strip (normalized units) --- + % --- Vertical strip layout (top -> bottom): + % Last refreshed label : y=0.945 .. 0.985 (~4%) + % Search strip : y=0.890 .. 0.940 (~5%) + % Chip strip : y=0.840 .. 0.885 (~4.5%) + % Table : y=0.055 .. 0.835 (~78%) + % Footer "N / M tags" : y=0.005 .. 0.045 (~4%) + % Adding the label + chip strip leaves enough room for the + % uitable at the default ~580px window height (260519-bs4-04). + + % --- Last-refreshed label (top-left, small muted text). --- + % Style mirrors EventsLogPane.setLastUpdated convention: + % small font, Menlo monospace, PlaceholderTextColor. + obj.hLastRefreshLbl_ = uicontrol(obj.hFig_, ... + 'Style', 'text', ... + 'Units', 'normalized', ... + 'Position', [0.01 0.945 0.98 0.04], ... + 'String', 'Last refreshed: --:--:--', ... + 'HorizontalAlignment', 'left', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.PlaceholderTextColor, ... + 'FontName', 'Menlo', ... + 'FontSize', 10); + + % --- Search strip --- obj.hSearchLbl_ = uicontrol(obj.hFig_, ... 'Style', 'text', ... 'Units', 'normalized', ... - 'Position', [0.01 0.93 0.06 0.05], ... + 'Position', [0.01 0.89 0.06 0.05], ... 'String', 'Search:', ... 'HorizontalAlignment', 'left', ... 'BackgroundColor', t.WidgetBackground, ... @@ -118,7 +165,7 @@ function openWith(obj, registry, theme, companion) obj.hSearch_ = uicontrol(obj.hFig_, ... 'Style', 'edit', ... 'Units', 'normalized', ... - 'Position', [0.07 0.93 0.43 0.055], ... + 'Position', [0.07 0.89 0.43 0.05], ... 'String', '', ... 'HorizontalAlignment', 'left', ... 'BackgroundColor', t.WidgetBackground, ... @@ -129,7 +176,7 @@ function openWith(obj, registry, theme, companion) obj.hHeaderLbl_ = uicontrol(obj.hFig_, ... 'Style', 'text', ... 'Units', 'normalized', ... - 'Position', [0.55 0.93 0.44 0.05], ... + 'Position', [0.55 0.89 0.44 0.05], ... 'String', 'Tags', ... 'HorizontalAlignment', 'right', ... 'BackgroundColor', t.WidgetBackground, ... @@ -137,6 +184,9 @@ function openWith(obj, registry, theme, companion) 'FontSize', 10, ... 'FontWeight', 'bold'); + % --- Chip strip: Type / Criticality / Activity --- + obj.buildChipStrip_(t); + % --- Striped pair derived from theme (mirrors LiveLogPane). --- stripePair = obj.stripePairFromTheme_(t); @@ -144,7 +194,7 @@ function openWith(obj, registry, theme, companion) % 11 columns: Activity is column 9 (between Last updated and Samples). obj.hTable_ = uitable(obj.hFig_, ... 'Units', 'normalized', ... - 'Position', [0.01 0.06 0.98 0.86], ... + 'Position', [0.01 0.055 0.98 0.78], ... 'ColumnName', {'Key', 'Name', 'Type', 'Criticality', 'Units', ... 'Latest', 'Status', 'Last updated', 'Activity', ... 'Samples', 'Labels'}, ... @@ -168,10 +218,22 @@ function openWith(obj, registry, theme, companion) 'ForegroundColor', t.PlaceholderTextColor, ... 'FontSize', 10); + % --- Reset chip filter state to "show everything" on every + % openWith (defensive in case the singleton is reused). + obj.ActiveTypeChips_ = obj.TypeChipKeys_; + obj.ActiveCritChips_ = obj.CritChipKeys_; + obj.ActiveActivityChips_ = obj.ActivityChipKeys_; + obj.applyChipStyles_(); + % --- Fill from registry + apply (initial empty) filter. --- obj.rebuildAll_(); obj.applyFilter_(); + % --- Seed the "Last refreshed" label to window-open time so + % the user immediately sees a concrete HH:MM:SS rather + % than the "--:--:--" placeholder. + obj.setLastRefreshedNow_(); + obj.IsOpen = true; % --- Start the window-owned refresh timer. --- @@ -238,6 +300,13 @@ function applyTheme(obj, theme) obj.hStatusLbl_.BackgroundColor = t.WidgetBackground; obj.hStatusLbl_.ForegroundColor = t.PlaceholderTextColor; end + if ~isempty(obj.hLastRefreshLbl_) && isvalid(obj.hLastRefreshLbl_) + obj.hLastRefreshLbl_.BackgroundColor = t.WidgetBackground; + obj.hLastRefreshLbl_.ForegroundColor = t.PlaceholderTextColor; + end + % Re-apply chip active/inactive styling -- pulls Accent + % from the freshly-stored theme. + obj.applyChipStyles_(); if ~isempty(obj.hTable_) && isvalid(obj.hTable_) stripePair = obj.stripePairFromTheme_(t); obj.hTable_.BackgroundColor = stripePair; @@ -284,6 +353,23 @@ function delete(obj) hf = obj.hFig_; end + function s = lastRefreshedLabelForTest(obj) + %LASTREFRESHEDLABELFORTEST Test helper: read the "Last refreshed:" label String. + % Returns '' when the window is detached or the label is invalid. + s = ''; + if ~isempty(obj.hLastRefreshLbl_) && isvalid(obj.hLastRefreshLbl_) + s = obj.hLastRefreshLbl_.String; + end + end + + function tickForTest(obj) + %TICKFORTEST Test helper: drive a single onRefreshTick_ synchronously. + % Mirrors what the RefreshTimer_ does on its 1s cadence -- used by + % the UI test that asserts the "Last refreshed" label updates + % after a simulated refresh tick. 260519-bs4-04 patch. + obj.onRefreshTick_(); + end + end methods (Access = private) @@ -317,13 +403,17 @@ function onCloseRequest_(obj) end catch end - obj.hFig_ = []; - obj.hTable_ = []; - obj.hSearch_ = []; - obj.hStatusLbl_ = []; - obj.hSearchLbl_ = []; - obj.hHeaderLbl_ = []; - obj.IsOpen = false; + obj.hFig_ = []; + obj.hTable_ = []; + obj.hSearch_ = []; + obj.hStatusLbl_ = []; + obj.hSearchLbl_ = []; + obj.hHeaderLbl_ = []; + obj.hLastRefreshLbl_ = []; + obj.hChipsType_ = []; + obj.hChipsCrit_ = []; + obj.hChipsActivity_ = []; + obj.IsOpen = false; end function rebuildAll_(obj) @@ -362,12 +452,15 @@ function rebuildAll_(obj) function applyFilter_(obj) %APPLYFILTER_ Push RowBuffer_ (filtered) into hTable_.Data + update footer. + % Combines the case-insensitive substring search with the three + % chip groups (Type / Criticality / Activity). 260519-bs4-04 patch. if isempty(obj.hTable_) || ~isvalid(obj.hTable_); return; end qry = ''; if ~isempty(obj.hSearch_) && isvalid(obj.hSearch_) qry = obj.hSearch_.String; end - rows = TagStatusTableWindow.filterRows_(obj.RowBuffer_, qry); + rows = TagStatusTableWindow.filterRows_(obj.RowBuffer_, qry, ... + obj.ActiveTypeChips_, obj.ActiveCritChips_, obj.ActiveActivityChips_); obj.hTable_.Data = rows; if ~isempty(obj.hStatusLbl_) && isvalid(obj.hStatusLbl_) obj.hStatusLbl_.String = sprintf('%d / %d tags', ... @@ -394,6 +487,143 @@ function applyFilter_(obj) end end + function buildChipStrip_(obj, t) + %BUILDCHIPSTRIP_ Build the three chip groups (Type / Crit / Activity). + % Layout: 5 Type chips on the left, 4 Criticality chips in the + % middle, 2 Activity chips on the right -- with small visual + % gaps between groups. All chips toggle on click. + % Mirrors the multi-toggle pill pattern from TagCatalogPane. + % 260519-bs4-04 patch. + nType = numel(obj.TypeChipKeys_); + nCrit = numel(obj.CritChipKeys_); + nAct = numel(obj.ActivityChipKeys_); + + % Strip allocation: 0.01 .. 0.99 = 0.98 wide. + stripL = 0.01; + stripW = 0.98; + y = 0.84; + h = 0.045; + % Groups occupy roughly 5/11, 4/11, 2/11 of the strip width + % with small inter-group gutters. + gutter = 0.012; + usable = stripW - 2 * gutter; + wType = usable * nType / (nType + nCrit + nAct); + wCrit = usable * nCrit / (nType + nCrit + nAct); + wAct = usable * nAct / (nType + nCrit + nAct); + + % --- Type chips --- + obj.hChipsType_ = obj.makeChipRow_(t, ... + stripL, y, wType, h, ... + obj.TypeChipLabels_, obj.TypeChipKeys_, ... + @(key) obj.onTypeChip_(key)); + + % --- Criticality chips --- + obj.hChipsCrit_ = obj.makeChipRow_(t, ... + stripL + wType + gutter, y, wCrit, h, ... + obj.CritChipLabels_, obj.CritChipKeys_, ... + @(key) obj.onCritChip_(key)); + + % --- Activity chips --- + obj.hChipsActivity_ = obj.makeChipRow_(t, ... + stripL + wType + wCrit + 2 * gutter, y, wAct, h, ... + obj.ActivityChipLabels_, obj.ActivityChipKeys_, ... + @(key) obj.onActivityChip_(key)); + + % Apply initial styling (all active). + obj.applyChipStyles_(); + end + + function btns = makeChipRow_(obj, t, xLeft, yBottom, width, height, ... + labels, keys, callbackFn) + %MAKECHIPROW_ Create a row of equally-spaced uicontrol pushbuttons. + % Returns a 1xN array of uicontrol handles, one per label. + n = numel(labels); + btns = gobjects(1, n); + chipW = width / n; + for i = 1:n + x = xLeft + (i - 1) * chipW; + btns(i) = uicontrol(obj.hFig_, ... + 'Style', 'pushbutton', ... + 'Units', 'normalized', ... + 'Position', [x yBottom chipW * 0.96 height], ... + 'String', labels{i}, ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.ForegroundColor, ... + 'FontSize', 10, ... + 'Callback', chipCallback_(callbackFn, keys{i})); + end + end + + function applyChipStyles_(obj) + %APPLYCHIPSTYLES_ Apply active/inactive visual style to all chip groups. + % Active chips use theme Accent background + bold; inactive chips + % use the theme WidgetBackground + normal weight. No-op when the + % chip arrays are empty (e.g. window detached). + if isempty(obj.Theme_); return; end + t = obj.Theme_; + obj.applyChipStyleGroup_(obj.hChipsType_, obj.TypeChipKeys_, obj.ActiveTypeChips_, t); + obj.applyChipStyleGroup_(obj.hChipsCrit_, obj.CritChipKeys_, obj.ActiveCritChips_, t); + obj.applyChipStyleGroup_(obj.hChipsActivity_, obj.ActivityChipKeys_, obj.ActiveActivityChips_, t); + end + + function applyChipStyleGroup_(~, hChips, allKeys, activeKeys, t) + %APPLYCHIPSTYLEGROUP_ Apply per-chip styling for a single group. + if isempty(hChips); return; end + % Active-state colors mirror TagCatalogPane.applyPillStyle_: + % accent bg + dark fg + bold for active; normal otherwise. + for i = 1:numel(hChips) + btn = hChips(i); + if ~isgraphics(btn) || ~isvalid(btn); continue; end + isActive = any(strcmp(activeKeys, allKeys{i})); + if isActive + btn.BackgroundColor = t.Accent; + btn.ForegroundColor = t.DashboardBackground; + btn.FontWeight = 'bold'; + else + btn.BackgroundColor = t.WidgetBackground; + btn.ForegroundColor = t.ForegroundColor; + btn.FontWeight = 'normal'; + end + end + end + + function onTypeChip_(obj, key) + %ONTYPECHIP_ Toggle a Type chip and re-apply the filter. + obj.ActiveTypeChips_ = toggleKey_(obj.ActiveTypeChips_, key); + obj.applyChipStyles_(); + obj.applyFilter_(); + end + + function onCritChip_(obj, key) + %ONCRITCHIP_ Toggle a Criticality chip and re-apply the filter. + obj.ActiveCritChips_ = toggleKey_(obj.ActiveCritChips_, key); + obj.applyChipStyles_(); + obj.applyFilter_(); + end + + function onActivityChip_(obj, key) + %ONACTIVITYCHIP_ Toggle an Activity chip and re-apply the filter. + obj.ActiveActivityChips_ = toggleKey_(obj.ActiveActivityChips_, key); + obj.applyChipStyles_(); + obj.applyFilter_(); + end + + function setLastRefreshedNow_(obj) + %SETLASTREFRESHEDNOW_ Update the "Last refreshed: HH:MM:SS" label to now. + % 24h clock, second precision, local time. No-op when the label + % is invalid (window detached). 260519-bs4-04 patch. + if isempty(obj.hLastRefreshLbl_) || ~isvalid(obj.hLastRefreshLbl_) + return; + end + try + ts = char(datetime('now', 'Format', 'HH:mm:ss')); + catch + % Octave / stripped MATLAB fallback. + ts = datestr(now, 'HH:MM:SS'); %#ok + end + obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s', ts); + end + function startRefreshTimer_(obj) %STARTREFRESHTIMER_ Create and start the window-owned refresh timer. % Independent of companion Live mode -- guarantees the table @@ -471,6 +701,11 @@ function onRefreshTick_(obj) if changed obj.applyFilter_(); end + % Always update the "Last refreshed" label after a clean + % tick -- even when no rows changed. Proves the polling + % is alive and matches the user's expectation that the + % label is a heartbeat indicator. 260519-bs4-04 patch. + obj.setLastRefreshedNow_(); obj.RefreshErrCount_ = 0; % reset on a clean tick catch err obj.RefreshErrCount_ = obj.RefreshErrCount_ + 1; @@ -613,36 +848,84 @@ function onRefreshTick_(obj) end end - function out = filterRows_(rows, query) - %FILTERROWS_ Case-insensitive substring filter on columns Key + Name. - % Empty/whitespace query returns rows unchanged. + function out = filterRows_(rows, query, activeTypes, activeCrits, activeActivities) + %FILTERROWS_ Combined search + chip filter over a buffer of rows. + % filterRows_(rows, query) -- search-only (backward-compatible signature). + % filterRows_(rows, query, activeTypes, activeCrits, activeActivities) + % applies all four dimensions. + % + % Inputs: + % rows -- cell(N, 11) buffer (TagStatusTableWindow.RowBuffer_). + % query -- char/string; empty / whitespace = no search filter. + % activeTypes -- cellstr subset of {sensor, monitor, + % composite, state, derived}. Omitted = all kept. + % activeCrits -- cellstr subset of {low, medium, high, safety}. + % Omitted = all kept. + % activeActivities -- cellstr subset of {live, inactive}. + % Omitted = all kept. + % + % Semantics: + % -- search: substring match (case-insensitive) on Key, Name, + % Units, OR Labels (Labels are stored as a comma-joined string + % in column 11). + % -- chip groups: AND across groups (a row passes only if it + % matches the active set of every group), OR within a group + % (a row matches if its value is in the active set). + % -- A chip group with ZERO active entries excludes ALL rows + % for that dimension (the "no selection -> show nothing" + % rule called out in the patch spec). + % + % Returns the filtered cell array (preserves row ordering). + % 260519-bs4-04 patch. + if nargin < 3, activeTypes = []; end + if nargin < 4, activeCrits = []; end + if nargin < 5, activeActivities = []; end if isempty(rows) out = rows; return; end + % --- Search query --- qry = ''; if ischar(query) qry = strtrim(query); elseif isstring(query) && isscalar(query) qry = strtrim(char(query)); end - if isempty(qry) - out = rows; + qLow = lower(qry); + haveQuery = ~isempty(qLow); + % --- Chip-state interpretation --- + % nargin convention separates "argument omitted (skip chip + % filter)" from "argument supplied as empty (= zero chips + % active -> exclude all)". The internal sentinels NaN/[] + % below encode this. + applyTypes = ~isempty(activeTypes) || (nargin >= 3 && iscell(activeTypes)); + applyCrits = ~isempty(activeCrits) || (nargin >= 4 && iscell(activeCrits)); + applyAct = ~isempty(activeActivities) || (nargin >= 5 && iscell(activeActivities)); + % "Zero chips selected" short-circuit: if any group is applied + % AND empty, the table is empty. + if (applyTypes && isempty(activeTypes)) || ... + (applyCrits && isempty(activeCrits)) || ... + (applyAct && isempty(activeActivities)) + out = cell(0, size(rows, 2)); return; end - qLow = lower(qry); - keep = false(size(rows, 1), 1); + keep = true(size(rows, 1), 1); for i = 1:size(rows, 1) - k = ''; - n = ''; - try - k = lower(rows{i, 1}); - n = lower(rows{i, 2}); - catch - % Row missing string columns -- skip without crashing. + if haveQuery && ~rowMatchesSearch_(rows(i, :), qLow) + keep(i) = false; + continue; + end + if applyTypes && ~rowMatchesType_(rows(i, :), activeTypes) + keep(i) = false; + continue; end - if ~isempty(strfind(k, qLow)) || ~isempty(strfind(n, qLow)) %#ok - keep(i) = true; + if applyCrits && ~rowMatchesCrit_(rows(i, :), activeCrits) + keep(i) = false; + continue; + end + if applyAct && ~rowMatchesActivity_(rows(i, :), activeActivities) + keep(i) = false; + continue; end end out = rows(keep, :); @@ -744,3 +1027,67 @@ function onRefreshTick_(obj) s = sprintf('%.0f-%d', now * 86400, randi(1e6)); end end + +function fn = chipCallback_(callbackFn, key) +%CHIPCALLBACK_ Build a 2-arg uicontrol Callback that closes over a chip key. +% Captures `key` at chip-construction time so a single chip-handler +% method on the class can route per-chip clicks via a closure. Mirrors +% the closure trick used in TagCatalogPane's loop over kindKeys/critKeys. + fn = @(~, ~) callbackFn(key); +end + +function out = toggleKey_(activeKeys, key) +%TOGGLEKEY_ Add `key` to `activeKeys` if absent, else remove it. + if any(strcmp(activeKeys, key)) + out = activeKeys(~strcmp(activeKeys, key)); + else + out = [activeKeys, {key}]; + end +end + +function tf = rowMatchesSearch_(row, qLow) +%ROWMATCHESSEARCH_ Case-insensitive substring match on Key+Name+Units+Labels. +% row -- 1x11 cell. Columns 1 (Key), 2 (Name), 5 (Units), 11 (Labels). +% qLow -- already-lowercased query. +% Tolerates rows with missing / non-char columns (defensive try/catch). + tf = false; + try + for c = [1, 2, 5, 11] + val = row{c}; + if ~ischar(val); continue; end + if ~isempty(strfind(lower(val), qLow)) %#ok + tf = true; + return; + end + end + catch + % Malformed row -- treat as non-match. + end +end + +function tf = rowMatchesType_(row, activeTypes) +%ROWMATCHESTYPE_ Column 3 (Type, e.g. 'Sensor') in activeTypes (lowercase)? + tf = false; + try + tf = any(strcmpi(activeTypes, row{3})); + catch + end +end + +function tf = rowMatchesCrit_(row, activeCrits) +%ROWMATCHESCRIT_ Column 4 (Criticality, lowercase) in activeCrits? + tf = false; + try + tf = any(strcmpi(activeCrits, row{4})); + catch + end +end + +function tf = rowMatchesActivity_(row, activeActivities) +%ROWMATCHESACTIVITY_ Column 9 (Activity, 'Live'/'Inactive') in activeActivities? + tf = false; + try + tf = any(strcmpi(activeActivities, row{9})); + catch + end +end diff --git a/tests/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m index 22d1f845..c24b2e02 100644 --- a/tests/suite/TestTagStatusTableWindow.m +++ b/tests/suite/TestTagStatusTableWindow.m @@ -211,6 +211,52 @@ function testActivityFlipsWithoutLiveMode(testCase) 'testActivityFlipsWithoutLiveMode: must be Inactive at +301s'); end + function testLastRefreshedLabelOnOpen(testCase) + %TESTLASTREFRESHEDLABELONOPEN On open, header label begins with "Last refreshed:". + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + lbl = w.lastRefreshedLabelForTest(); + + testCase.verifyTrue(startsWith(lbl, 'Last refreshed:'), ... + sprintf(['testLastRefreshedLabelOnOpen: label must begin with ' ... + '''Last refreshed:'', got ''%s'''], lbl)); + % On openWith we seed the label to wall-clock time so it + % should NOT be the "--:--:--" placeholder. + testCase.verifyFalse(contains(lbl, '--:--:--'), ... + 'testLastRefreshedLabelOnOpen: label must be seeded to a concrete time on open'); + end + + function testLastRefreshedLabelUpdatesAfterTick(testCase) + %TESTLASTREFRESHEDLABELUPDATESAFTERTICK After a simulated refresh tick, + % the label must show a new HH:MM:SS. Because the timer fires + % on a 1s cadence, we drive a synchronous tick via the test + % helper and verify the label updated (or at least still + % begins with the correct prefix and matches the HH:MM:SS + % pattern). + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + w.tickForTest(); + lbl = w.lastRefreshedLabelForTest(); + + testCase.verifyTrue(startsWith(lbl, 'Last refreshed:'), ... + sprintf(['testLastRefreshedLabelUpdatesAfterTick: label must ' ... + 'begin with ''Last refreshed:'', got ''%s'''], lbl)); + % Must match the regex "HH:MM:SS" (two digits each), proving + % a real timestamp got written by the tick. + tok = regexp(lbl, '\d{2}:\d{2}:\d{2}', 'match', 'once'); + testCase.verifyNotEmpty(tok, ... + sprintf(['testLastRefreshedLabelUpdatesAfterTick: label must ' ... + 'contain an HH:MM:SS timestamp after a tick, got ''%s'''], lbl)); + end + function testRefreshTimerStoppedAndDeletedOnClose(testCase) %TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer. registerTwoSensors_(); diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m index 1e6a02c3..0a25b84f 100644 --- a/tests/test_companion_tag_status_table.m +++ b/tests/test_companion_tag_status_table.m @@ -32,7 +32,11 @@ function test_companion_tag_status_table() @testActivityInactive_oldDatenumTimestamp, ... @testActivityInactive_emptyXY, ... @testActivityInactive_futureTimestamp, ... - @testFilterRows_subsetFixture }; + @testFilterRows_subsetFixture, ... + @testFilterRows_matchesUnitsField, ... + @testFilterRows_matchesLabelsField, ... + @testFilterRows_chipFiltersAndSemantics, ... + @testFilterRows_emptyChipGroupExcludesAll }; for i = 1:numel(tests) name = func2str(tests{i}); try @@ -299,6 +303,105 @@ function testFilterRows_subsetFixture() assertEqual_(keptNameOnly{1, 1}, 'state_machine', 'matched via Name field'); end +% ===================== Broader search + chip filters (260519-bs4-04 patch) ===================== + +function testFilterRows_matchesUnitsField() + % Search now matches the Units column (column 5). Mirrors the user's + % "search for °C" workflow: a row with Units='°C' must match. + rows = { ... + 'temp_a', 'Temp A', 'Sensor', 'medium', char([176, 67]), '', '', '', '', '', ''; ... + 'press_a', 'Pressure A', 'Sensor', 'medium', 'bar', '', '', '', '', '', '' }; + + keptDegree = TagStatusTableWindow.filterRows_(rows, char([176, 67])); + assertEqual_(size(keptDegree, 1), 1, 'filter on Units degC keeps the temperature row'); + assertEqual_(keptDegree{1, 1}, 'temp_a', 'matched row key'); + + keptBar = TagStatusTableWindow.filterRows_(rows, 'bar'); + assertEqual_(size(keptBar, 1), 1, 'filter ''bar'' on Units keeps press_a'); + assertEqual_(keptBar{1, 1}, 'press_a', 'matched row key'); +end + +function testFilterRows_matchesLabelsField() + % Search must match the Labels column (column 11), which is a joined + % comma-separated string when there is more than one label. + rows = { ... + 'k1', 'Tag 1', 'Sensor', 'medium', '', '', '', '', '', '', 'plant, north'; ... + 'k2', 'Tag 2', 'Sensor', 'medium', '', '', '', '', '', '', 'plant, south'; ... + 'k3', 'Tag 3', 'Sensor', 'medium', '', '', '', '', '', '', '' }; + + keptNorth = TagStatusTableWindow.filterRows_(rows, 'north'); + assertEqual_(size(keptNorth, 1), 1, 'filter ''north'' on Labels keeps 1 row'); + assertEqual_(keptNorth{1, 1}, 'k1', 'matched row key'); + + keptPlant = TagStatusTableWindow.filterRows_(rows, 'plant'); + assertEqual_(size(keptPlant, 1), 2, 'filter ''plant'' on Labels keeps 2 rows'); +end + +function testFilterRows_chipFiltersAndSemantics() + % Verifies AND-across-groups, OR-within-group semantics for the chip + % filters. Build a small fixture with diverse Type/Crit/Activity cells. + rows = { ... + 'k1', 'Sensor Hi Live', 'Sensor', 'high', '', '', '', '', 'Live', '', ''; ... + 'k2', 'Sensor Med Live', 'Sensor', 'medium', '', '', '', '', 'Live', '', ''; ... + 'k3', 'Sensor Hi Inact', 'Sensor', 'high', '', '', '', '', 'Inactive', '', ''; ... + 'k4', 'Monitor Hi Live', 'Monitor', 'high', '', '', '', '', 'Live', '', ''; ... + 'k5', 'Compos Low Live', 'Composite', 'low', '', '', '', '', 'Live', '', ''; ... + 'k6', 'State Med Inact', 'State', 'medium', '', '', '', '', 'Inactive', '', '' }; + + % Type=Sensor only (OR within Type group), all crits, all activities. + kept = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor'}, {'low', 'medium', 'high', 'safety'}, {'live', 'inactive'}); + assertEqual_(size(kept, 1), 3, 'Type=Sensor keeps 3 sensor rows'); + + % Type=Sensor AND Crit=high -> only rows with both. + kept2 = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor'}, {'high'}, {'live', 'inactive'}); + assertEqual_(size(kept2, 1), 2, 'Sensor+high keeps k1 and k3'); + assertTrue_(any(strcmp(kept2(:, 1), 'k1')), 'kept includes k1'); + assertTrue_(any(strcmp(kept2(:, 1), 'k3')), 'kept includes k3'); + + % Type=Sensor AND Crit=high AND Activity=Live -> only k1. + kept3 = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor'}, {'high'}, {'live'}); + assertEqual_(size(kept3, 1), 1, 'Sensor+high+Live keeps only k1'); + assertEqual_(kept3{1, 1}, 'k1', 'kept row is k1'); + + % OR-within-group: Type=Sensor OR Monitor, all else permissive. + kept4 = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor', 'monitor'}, {'low', 'medium', 'high', 'safety'}, ... + {'live', 'inactive'}); + assertEqual_(size(kept4, 1), 4, 'Type=Sensor OR Monitor keeps 4 rows'); + + % Combined with search: "Hi" + Type=Sensor. + kept5 = TagStatusTableWindow.filterRows_(rows, 'Hi', ... + {'sensor'}, {'low', 'medium', 'high', 'safety'}, ... + {'live', 'inactive'}); + assertEqual_(size(kept5, 1), 2, 'search ''Hi'' + Type=Sensor keeps k1+k3'); +end + +function testFilterRows_emptyChipGroupExcludesAll() + % "Zero chips selected in any group" -> table shows nothing. + rows = { ... + 'k1', 'A', 'Sensor', 'high', '', '', '', '', 'Live', '', ''; ... + 'k2', 'B', 'Sensor', 'medium', '', '', '', '', 'Inactive', '', '' }; + + % Empty Type group. + kept = TagStatusTableWindow.filterRows_(rows, '', ... + {}, {'low', 'medium', 'high', 'safety'}, {'live', 'inactive'}); + assertEqual_(size(kept, 1), 0, 'empty Type chip group excludes all rows'); + + % Empty Criticality group. + kept2 = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor', 'monitor', 'composite', 'state', 'derived'}, {}, {'live', 'inactive'}); + assertEqual_(size(kept2, 1), 0, 'empty Criticality chip group excludes all rows'); + + % Empty Activity group. + kept3 = TagStatusTableWindow.filterRows_(rows, '', ... + {'sensor', 'monitor', 'composite', 'state', 'derived'}, ... + {'low', 'medium', 'high', 'safety'}, {}); + assertEqual_(size(kept3, 1), 0, 'empty Activity chip group excludes all rows'); +end + % ===================== Helpers ===================== function add_companion_path() From 50d464c1512801af1d530dea22701e2047bdfe38 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 09:23:50 +0200 Subject: [PATCH 5/9] feat(quick-260519-bs4-05): add polling pause/resume toggle button - New uicontrol pushbutton on the last-refreshed header row labelled "Pause polling"/"Resume polling" (classical figure -> uicontrol family) - Public method setPollingActive(tf) drives the flow; the button's callback delegates to it so click + programmatic paths are identical - Pause stops RefreshTimer_ without deleting it; resume re-starts the same handle (with startRefreshTimer_ fallback if it died) - Resume fires a synchronous one-shot onRefreshTick_ so the table is immediately fresh instead of waiting up to 1 s for the next tick - markTagsDirty is now a no-op while paused: the user's mental model is "polling off -> table is frozen". No coupling to FastSenseCompanion - Header label appends " (paused)" suffix in-place when paused; the preceding HH:MM:SS is preserved so the user sees WHEN polling stopped - 4 new TestTagStatusTableWindow cases cover Running state transitions, paused markTagsDirty no-op, and button-label toggling Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FastSenseCompanion/TagStatusTableWindow.m | 168 +++++++++++++++++- tests/suite/TestTagStatusTableWindow.m | 95 ++++++++++ 2 files changed, 258 insertions(+), 5 deletions(-) diff --git a/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m index c760ce4e..c83c1f2a 100644 --- a/libs/FastSenseCompanion/TagStatusTableWindow.m +++ b/libs/FastSenseCompanion/TagStatusTableWindow.m @@ -48,6 +48,8 @@ hSearchLbl_ = [] % "Search:" label hHeaderLbl_ = [] % "Tags" right-side header label hLastRefreshLbl_ = [] % "Last refreshed: HH:MM:SS" label (260519-bs4-04 patch) + hPauseBtn_ = [] % "Pause polling"/"Resume polling" uicontrol pushbutton (260519-bs4-05 patch) + PollingActive_ = true % true = RefreshTimer_ running + markTagsDirty live; false = frozen (260519-bs4-05 patch) hChipsType_ = [] % 1x5 array of uicontrol pushbuttons (Sensor/Monitor/Composite/State/Derived) hChipsCrit_ = [] % 1x4 array of uicontrol pushbuttons (Low/Medium/High/Safety) hChipsActivity_ = [] % 1x2 array of uicontrol pushbuttons (Live/Inactive) @@ -140,10 +142,12 @@ function openWith(obj, registry, theme, companion) % --- Last-refreshed label (top-left, small muted text). --- % Style mirrors EventsLogPane.setLastUpdated convention: % small font, Menlo monospace, PlaceholderTextColor. + % Width reduced to leave room for the Pause/Resume polling + % button on the right edge of the same row (260519-bs4-05). obj.hLastRefreshLbl_ = uicontrol(obj.hFig_, ... 'Style', 'text', ... 'Units', 'normalized', ... - 'Position', [0.01 0.945 0.98 0.04], ... + 'Position', [0.01 0.945 0.85 0.04], ... 'String', 'Last refreshed: --:--:--', ... 'HorizontalAlignment', 'left', ... 'BackgroundColor', t.WidgetBackground, ... @@ -151,6 +155,21 @@ function openWith(obj, registry, theme, companion) 'FontName', 'Menlo', ... 'FontSize', 10); + % --- Pause/Resume polling button (right edge, same row). --- + % Same widget family as the other controls (uicontrol pushbutton) + % so the classical-figure window stays consistent. Initial label + % matches the default PollingActive_=true state ("Pause polling"). + % 260519-bs4-05 patch. + obj.hPauseBtn_ = uicontrol(obj.hFig_, ... + 'Style', 'pushbutton', ... + 'Units', 'normalized', ... + 'Position', [0.87 0.945 0.12 0.04], ... + 'String', 'Pause polling', ... + 'BackgroundColor', t.WidgetBackground, ... + 'ForegroundColor', t.ForegroundColor, ... + 'FontSize', 10, ... + 'Callback', @(~,~) obj.setPollingActive(~obj.PollingActive_)); + % --- Search strip --- obj.hSearchLbl_ = uicontrol(obj.hFig_, ... 'Style', 'text', ... @@ -244,9 +263,13 @@ function openWith(obj, registry, theme, companion) function markTagsDirty(obj, keys) %MARKTAGSDIRTY Refresh only rows for the listed tag keys. - % keys -- cellstr or single char. No-op when ~IsOpen. Whole body - % wrapped in try/catch so a live tick can never crash via this path. + % keys -- cellstr or single char. No-op when ~IsOpen or when + % PollingActive_ is false (paused -> table is frozen, mirroring + % the user's "polling off = nothing moves" mental model; + % 260519-bs4-05 patch). Whole body wrapped in try/catch so a + % live tick can never crash via this path. if ~obj.IsOpen; return; end + if ~obj.PollingActive_; return; end if isempty(keys); return; end if ischar(keys); keys = {keys}; end if ~iscell(keys); return; end @@ -304,6 +327,10 @@ function applyTheme(obj, theme) obj.hLastRefreshLbl_.BackgroundColor = t.WidgetBackground; obj.hLastRefreshLbl_.ForegroundColor = t.PlaceholderTextColor; end + if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_) + obj.hPauseBtn_.BackgroundColor = t.WidgetBackground; + obj.hPauseBtn_.ForegroundColor = t.ForegroundColor; + end % Re-apply chip active/inactive styling -- pulls Accent % from the freshly-stored theme. obj.applyChipStyles_(); @@ -317,6 +344,74 @@ function applyTheme(obj, theme) end end + function setPollingActive(obj, tf) + %SETPOLLINGACTIVE Pause or resume the window's refresh polling. + % setPollingActive(true) -> starts RefreshTimer_ (if not running), + % fires one immediate synchronous + % onRefreshTick_ so the user sees fresh + % data right away, sets the button + % label to 'Pause polling' and drops + % the '(paused)' suffix from the + % header label. + % setPollingActive(false) -> stops RefreshTimer_ (without deleting + % it -- close() still cleans up via + % stopRefreshTimer_), sets the button + % label to 'Resume polling' and adds + % the '(paused)' suffix to the header + % label so the user sees WHEN the + % polling stopped. + % + % While paused, markTagsDirty() is a no-op: the table is frozen. + % No-op when ~IsOpen. Whole body wrapped in try/catch so a stray + % click cannot crash the window. 260519-bs4-05 patch. + if ~obj.IsOpen; return; end + if ~islogical(tf) || ~isscalar(tf) + error('FastSenseCompanion:tagStatusTableInvalidPollingFlag', ... + 'setPollingActive requires a scalar logical argument.'); + end + try + obj.PollingActive_ = tf; + if tf + % Resume: restart timer (if it died, recreate via + % startRefreshTimer_; if it just stopped, start() it). + if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_) + try + if strcmp(get(obj.RefreshTimer_, 'Running'), 'off') + start(obj.RefreshTimer_); + end + catch + % If start() fails (e.g. timer in a weird state), + % rebuild it cleanly. + obj.startRefreshTimer_(); + end + else + obj.startRefreshTimer_(); + end + % Immediate one-shot refresh on resume so the user sees + % freshness right away rather than waiting up to + % RefreshPeriod_ seconds for the timer tick. + obj.onRefreshTick_(); + else + % Pause: stop the timer but DO NOT delete it -- we want + % to be able to re-start the same timer on resume. + % Close-path teardown still runs stopRefreshTimer_ + % regardless of paused state. + if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_) + try + if strcmp(get(obj.RefreshTimer_, 'Running'), 'on') + stop(obj.RefreshTimer_); + end + catch + % Best-effort; never throw out of a UI click. + end + end + end + obj.refreshPauseUi_(); + catch + % UI click handler must never throw. + end + end + function close(obj) %CLOSE Programmatic close; routes through onCloseRequest_ for parity. if obj.IsOpen && ~isempty(obj.hFig_) && isvalid(obj.hFig_) @@ -370,6 +465,26 @@ function tickForTest(obj) obj.onRefreshTick_(); end + function s = pauseBtnLabelForTest(obj) + %PAUSEBTNLABELFORTEST Test helper: read the Pause/Resume button text. + % Returns '' when the window is detached or the button is invalid. + % 260519-bs4-05 patch. + s = ''; + if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_) + s = obj.hPauseBtn_.String; + end + end + + function t = refreshTimerForTest(obj) + %REFRESHTIMERFORTEST Test helper: return the underlying RefreshTimer_. + % Returns [] when the window is detached or the timer is invalid. + % 260519-bs4-05 patch. + t = []; + if ~isempty(obj.RefreshTimer_) && isvalid(obj.RefreshTimer_) + t = obj.RefreshTimer_; + end + end + end methods (Access = private) @@ -410,6 +525,7 @@ function onCloseRequest_(obj) obj.hSearchLbl_ = []; obj.hHeaderLbl_ = []; obj.hLastRefreshLbl_ = []; + obj.hPauseBtn_ = []; obj.hChipsType_ = []; obj.hChipsCrit_ = []; obj.hChipsActivity_ = []; @@ -611,7 +727,12 @@ function onActivityChip_(obj, key) function setLastRefreshedNow_(obj) %SETLASTREFRESHEDNOW_ Update the "Last refreshed: HH:MM:SS" label to now. % 24h clock, second precision, local time. No-op when the label - % is invalid (window detached). 260519-bs4-04 patch. + % is invalid (window detached). When paused, appends " (paused)" + % suffix so the user sees the freshness state -- but the timer + % does not tick while paused, so this branch is only reached + % from the synchronous resume path (where the suffix is dropped + % right after by refreshPauseUi_) and from defensive callers. + % 260519-bs4-04 patch; paused-suffix added in 260519-bs4-05. if isempty(obj.hLastRefreshLbl_) || ~isvalid(obj.hLastRefreshLbl_) return; end @@ -621,7 +742,44 @@ function setLastRefreshedNow_(obj) % Octave / stripped MATLAB fallback. ts = datestr(now, 'HH:MM:SS'); %#ok end - obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s', ts); + if obj.PollingActive_ + obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s', ts); + else + obj.hLastRefreshLbl_.String = sprintf('Last refreshed: %s (paused)', ts); + end + end + + function refreshPauseUi_(obj) + %REFRESHPAUSEUI_ Sync the Pause/Resume button label and header suffix. + % Called from setPollingActive after PollingActive_ flips. Does + % NOT update the "Last refreshed" timestamp -- it only rewrites + % the suffix in-place so the previous HH:MM:SS is preserved + % (the user can see WHEN the polling stopped, per the spec). + % 260519-bs4-05 patch. + % --- Button label --- + if ~isempty(obj.hPauseBtn_) && isvalid(obj.hPauseBtn_) + if obj.PollingActive_ + obj.hPauseBtn_.String = 'Pause polling'; + else + obj.hPauseBtn_.String = 'Resume polling'; + end + end + % --- Header label "(paused)" suffix maintenance --- + if isempty(obj.hLastRefreshLbl_) || ~isvalid(obj.hLastRefreshLbl_) + return; + end + cur = obj.hLastRefreshLbl_.String; + if ~ischar(cur) + return; + end + hasSuffix = ~isempty(regexp(cur, '\(paused\)\s*$', 'once')); + if obj.PollingActive_ && hasSuffix + % Drop the trailing " (paused)". + obj.hLastRefreshLbl_.String = regexprep(cur, '\s*\(paused\)\s*$', ''); + elseif ~obj.PollingActive_ && ~hasSuffix + % Add the " (paused)" suffix to the existing timestamp. + obj.hLastRefreshLbl_.String = [strtrim(cur), ' (paused)']; + end end function startRefreshTimer_(obj) diff --git a/tests/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m index c24b2e02..0cf44112 100644 --- a/tests/suite/TestTagStatusTableWindow.m +++ b/tests/suite/TestTagStatusTableWindow.m @@ -257,6 +257,101 @@ function testLastRefreshedLabelUpdatesAfterTick(testCase) 'contain an HH:MM:SS timestamp after a tick, got ''%s'''], lbl)); end + function testSetPollingActive_falseStopsTimer(testCase) + %TESTSETPOLLINGACTIVE_FALSESTOPSTIMER setPollingActive(false) stops RefreshTimer_. + % The timer must move from Running='on' to Running='off' but + % must NOT be deleted (close() still cleans it up). 260519-bs4-05. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + tBefore = w.refreshTimerForTest(); + testCase.verifyNotEmpty(tBefore, ... + 'testSetPollingActive_falseStopsTimer: timer must exist after open'); + testCase.verifyEqual(get(tBefore, 'Running'), 'on', ... + 'testSetPollingActive_falseStopsTimer: timer must be running pre-pause'); + + w.setPollingActive(false); + + tAfter = w.refreshTimerForTest(); + testCase.verifyNotEmpty(tAfter, ... + 'testSetPollingActive_falseStopsTimer: timer must still exist (not deleted) after pause'); + testCase.verifyEqual(get(tAfter, 'Running'), 'off', ... + 'testSetPollingActive_falseStopsTimer: timer state must be off after pause'); + end + + function testSetPollingActive_trueRestartsTimer(testCase) + %TESTSETPOLLINGACTIVE_TRUERESTARTSTIMER setPollingActive(true) re-starts the timer. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + w.setPollingActive(false); + t = w.refreshTimerForTest(); + testCase.verifyEqual(get(t, 'Running'), 'off', ... + 'precondition: timer must be off after pause'); + + w.setPollingActive(true); + + tAfter = w.refreshTimerForTest(); + testCase.verifyNotEmpty(tAfter, ... + 'testSetPollingActive_trueRestartsTimer: timer must exist after resume'); + testCase.verifyEqual(get(tAfter, 'Running'), 'on', ... + 'testSetPollingActive_trueRestartsTimer: timer state must be on after resume'); + end + + function testMarkTagsDirty_noOpWhilePaused(testCase) + %TESTMARKTAGSDIRTY_NOOPWHILEPAUSED markTagsDirty must not mutate Data while paused. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + % Snapshot the table.Data BEFORE pausing. + hFig = w.getFigForTest(); + hTbl = findall(hFig, 'Type', 'uitable'); + testCase.verifyNotEmpty(hTbl, ... + 'precondition: uitable handle must be discoverable'); + dataBefore = hTbl.Data; + + w.setPollingActive(false); + + % While paused, mutate one of the underlying tags and call + % markTagsDirty -- it MUST be inert. + tA = TagRegistry.get('press_a'); + tA.updateData([1 2 3 4 5 6], [10 11 12 13 14 99]); + w.markTagsDirty({'press_a'}); + + dataAfter = hTbl.Data; + testCase.verifyEqual(dataAfter, dataBefore, ... + 'testMarkTagsDirty_noOpWhilePaused: table.Data must be unchanged while paused'); + end + + function testPauseBtnLabelFlips(testCase) + %TESTPAUSEBTNLABELFLIPS Button text toggles via setPollingActive. + registerTwoSensors_(); + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + testCase.addTeardown(@() TagRegistry.clear()); + + w = app.openTagStatusTable(); + testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Pause polling', ... + 'testPauseBtnLabelFlips: initial label must be ''Pause polling'''); + + w.setPollingActive(false); + testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Resume polling', ... + 'testPauseBtnLabelFlips: label must read ''Resume polling'' when paused'); + + w.setPollingActive(true); + testCase.verifyEqual(w.pauseBtnLabelForTest(), 'Pause polling', ... + 'testPauseBtnLabelFlips: label must revert to ''Pause polling'' after resume'); + end + function testRefreshTimerStoppedAndDeletedOnClose(testCase) %TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer. registerTwoSensors_(); From 10df7408cb45738ed16da96eca8926a79d9eb34f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 09:41:38 +0200 Subject: [PATCH 6/9] feat(quick-260519-bs4-06): add Events count column Surface a per-tag event count in the Tag Status Table so users can see at a glance which tags have produced violations / annotations and which haven't. The count refreshes with the same 1 s polling cadence as the existing Samples column. - Insert "Events" as column 10 (between Activity and Samples); Samples shifts to col 11, Labels to col 12. RowBuffer_ widened to 12 columns. - Add static helper countEventsForTag_(tag): pure function returning a non-negative integer; defers to Tag.eventsAttached() (which itself wraps EventStore.getEventsForTag, the same path CompanionEventViewer uses). Never throws; returns 0 for missing/empty EventStore. - Add private precomputeEventCounts_(keys): single call site that buckets event counts for the listed keys; threaded through rebuildAll_, markTagsDirty, and onRefreshTick_ so each tick does ONE consolidated walk per dirty key set. - Extend buildRow_ with optional 3rd arg eventCountsByKey: when present and the key is a hit, the count reads from the precomputed map; otherwise falls back to countEventsForTag_(tag). Backward compatible with 2-arg callers. - Shave column widths so all 12 columns fit in the default 1100 px window without horizontal scroll; window stays resizable. - Shift rowMatchesSearch_'s Labels column index from 11 to 12. - Update existing tests for the new 12-col row shape; add 4 pure-logic tests (countEventsForTag_ with/without EventStore, buildRow_ Events column placement, bucketed-map precedence) and 1 UI test (testEventsCountColumnPopulatedFromRegistry: tag with 3 stubbed events -> Events='3'; tag with none -> Events='0'). No touch to FastSenseCompanion.m. All errors stay under the existing FastSenseCompanion:tagStatusTable* namespace. mh_style/mh_lint/mh_metric clean; 24/24 pure-logic + 16/16 UI + 64/64 TestFastSenseCompanion pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FastSenseCompanion/TagStatusTableWindow.m | 173 +++++++++++++--- tests/suite/TestTagStatusTableWindow.m | 56 ++++- tests/test_companion_tag_status_table.m | 194 +++++++++++++----- 3 files changed, 345 insertions(+), 78 deletions(-) diff --git a/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m index c83c1f2a..e93a258b 100644 --- a/libs/FastSenseCompanion/TagStatusTableWindow.m +++ b/libs/FastSenseCompanion/TagStatusTableWindow.m @@ -59,7 +59,7 @@ Registry_ = [] % TagRegistry handle (or class name placeholder) Theme_ = [] % resolved CompanionTheme struct Companion_ = [] % FastSenseCompanion handle (uialert parent + detach) - RowBuffer_ = cell(0, 11) + RowBuffer_ = cell(0, 12) KeyToRow_ = [] % containers.Map(key -> row index into RowBuffer_) Listeners_ = {} % addlistener handles; deleted in close() RefreshTimer_ = [] % timer driving periodic re-query (window-owned; 260519-bs4 patch) @@ -82,7 +82,7 @@ methods (Access = public) function obj = TagStatusTableWindow() - obj.RowBuffer_ = cell(0, 11); + obj.RowBuffer_ = cell(0, 12); obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); % Default chip state: every chip ACTIVE (first-open shows % everything). 260519-bs4-04 patch. @@ -210,21 +210,23 @@ function openWith(obj, registry, theme, companion) stripePair = obj.stripePairFromTheme_(t); % --- Center uitable. --- - % 11 columns: Activity is column 9 (between Last updated and Samples). + % 12 columns: Activity is col 9, Events is col 10, Samples col 11. + % Events column (260519-bs4-06 patch) shows the integer count of + % events attached to each tag via EventStore.getEventsForTag. obj.hTable_ = uitable(obj.hFig_, ... 'Units', 'normalized', ... 'Position', [0.01 0.055 0.98 0.78], ... 'ColumnName', {'Key', 'Name', 'Type', 'Criticality', 'Units', ... 'Latest', 'Status', 'Last updated', 'Activity', ... - 'Samples', 'Labels'}, ... - 'ColumnWidth', {130, 200, 75, 80, 60, 90, 80, 140, 70, 70, 'auto'}, ... - 'ColumnEditable', false(1, 11), ... + 'Events', 'Samples', 'Labels'}, ... + 'ColumnWidth', {120, 180, 70, 75, 55, 85, 75, 130, 65, 55, 65, 'auto'}, ... + 'ColumnEditable', false(1, 12), ... 'RowName', {}, ... 'FontName', 'Menlo', ... 'FontSize', 10, ... 'BackgroundColor', stripePair, ... 'ForegroundColor', t.ForegroundColor, ... - 'Data', cell(0, 11)); + 'Data', cell(0, 12)); % --- Footer "N tags" label. --- obj.hStatusLbl_ = uicontrol(obj.hFig_, ... @@ -275,13 +277,17 @@ function markTagsDirty(obj, keys) if ~iscell(keys); return; end try nowSec = TagStatusTableWindow.nowSeconds_(); + % Build a small precomputed event-count map for ONLY the + % dirty keys -- O(M events * H stores) once, then O(1) per + % row in the loop below (260519-bs4-06 patch). + eventCountsByKey = obj.precomputeEventCounts_(keys); changed = false; for k = 1:numel(keys) key = char(keys{k}); if isempty(key); continue; end tag = obj.resolveTag_(key); if isempty(tag); continue; end - row = TagStatusTableWindow.buildRow_(tag, nowSec); + row = TagStatusTableWindow.buildRow_(tag, nowSec, eventCountsByKey); if obj.KeyToRow_.isKey(key) idx = obj.KeyToRow_(key); obj.RowBuffer_(idx, :) = row; @@ -534,7 +540,7 @@ function onCloseRequest_(obj) function rebuildAll_(obj) %REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted). - obj.RowBuffer_ = cell(0, 11); + obj.RowBuffer_ = cell(0, 12); obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double'); try tags = TagRegistry.find(@(t) true); @@ -558,10 +564,15 @@ function rebuildAll_(obj) tags = tags(ord); % Preallocate the buffer up front. nTags = numel(tags); - obj.RowBuffer_ = cell(nTags, 11); + obj.RowBuffer_ = cell(nTags, 12); nowSec = TagStatusTableWindow.nowSeconds_(); + % Bucket events by tag key ONCE for the whole rebuild + % (260519-bs4-06 patch). O(M events) instead of O(N tags * + % M events) when each call to getEventsForTag walks the store. + eventCountsByKey = obj.precomputeEventCounts_(keysSorted); for k = 1:nTags - obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_(tags{k}, nowSec); + obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_( ... + tags{k}, nowSec, eventCountsByKey); obj.KeyToRow_(keysSorted{k}) = k; end end @@ -593,6 +604,45 @@ function applyFilter_(obj) end end + function counts = precomputeEventCounts_(obj, keys) + %PRECOMPUTEEVENTCOUNTS_ Bucket EventStore events by tag key in one pass. + % Walks every distinct EventStore reachable through the listed + % tag keys, calls obj.EventStore.getEventsForTag(key) ONCE per + % key, and totals into a containers.Map. The savings come from + % the fact that we resolve each tag at most once per tick and + % only count keys we actually need (the keys passed in). + % + % When EventStore.getEventsForTag is O(N events) (current + % implementation walks all events), this collapses N tag-row + % builds * N events to N keys * N events, which is the same + % cost order but ensures the work happens at a single, + % debug-friendly call site rather than scattered through + % buildRow_. + % + % Returns a containers.Map(char -> double); empty when keys is + % empty or when no tag has an EventStore. Wrapped in try/catch; + % failure returns an empty map and buildRow_ falls back to the + % per-tag query path (still O(M events) but at least correct). + % 260519-bs4-06 patch. + counts = containers.Map('KeyType', 'char', 'ValueType', 'double'); + if isempty(keys); return; end + if ischar(keys); keys = {keys}; end + if ~iscell(keys); return; end + try + for k = 1:numel(keys) + key = char(keys{k}); + if isempty(key); continue; end + tag = obj.resolveTag_(key); + if isempty(tag); continue; end + n = TagStatusTableWindow.countEventsForTag_(tag); + counts(key) = n; + end + catch + % Best-effort -- a failure here should not abort the tick. + % buildRow_ will fall back to per-row queries below. + end + end + function pair = stripePairFromTheme_(~, t) %STRIPEPAIRFROMTHEME_ 2x3 stripe pair derived from theme brightness. isDark = mean(t.DashboardBackground) < 0.5; @@ -843,13 +893,17 @@ function onRefreshTick_(obj) nowSec = TagStatusTableWindow.nowSeconds_(); changed = false; keys = obj.KeyToRow_.keys(); + % Bucket events by tag key ONCE per tick rather than + % querying the store N times (one per row). Cheap when + % store is empty / not bound. 260519-bs4-06 patch. + eventCountsByKey = obj.precomputeEventCounts_(keys); for k = 1:numel(keys) key = keys{k}; if ~obj.KeyToRow_.isKey(key); continue; end idx = obj.KeyToRow_(key); tag = obj.resolveTag_(key); if isempty(tag); continue; end - newRow = TagStatusTableWindow.buildRow_(tag, nowSec); + newRow = TagStatusTableWindow.buildRow_(tag, nowSec, eventCountsByKey); oldRow = obj.RowBuffer_(idx, :); if ~isequal(newRow, oldRow) obj.RowBuffer_(idx, :) = newRow; @@ -882,29 +936,45 @@ function onRefreshTick_(obj) methods (Static, Access = public) - function row = buildRow_(tag, nowSeconds) - %BUILDROW_ Return a 1x11 cell row describing tag's current status. + function row = buildRow_(tag, nowSeconds, eventCountsByKey) + %BUILDROW_ Return a 1x12 cell row describing tag's current status. % Columns: Key, Name, Type, Criticality, Units, Latest, Status, - % Last updated, Activity, Samples, Labels. + % Last updated, Activity, Events, Samples, Labels. % % Inputs: - % tag -- Tag handle (any subclass; tolerant of throws) - % nowSeconds -- (optional) current wall-clock time as posix - % seconds, used for the Activity column. When - % omitted, TagStatusTableWindow.nowSeconds_() is - % queried (slightly more expensive). Tests pass - % an explicit value for determinism. 260519-bs4 patch. + % tag -- Tag handle (any subclass; tolerant of throws) + % nowSeconds -- (optional) current wall-clock time as posix + % seconds, used for the Activity column. When + % omitted, TagStatusTableWindow.nowSeconds_() + % is queried. Tests pass an explicit value for + % determinism. 260519-bs4 patch. + % eventCountsByKey -- (optional) containers.Map(char -> double) + % giving precomputed per-tag event counts. + % When the tag's Key is present in the map, + % the Events column reads from the map. + % Otherwise falls back to + % countEventsForTag_(tag) which walks the + % tag's bound EventStore. Pass [] or omit + % to force the per-tag query. 260519-bs4-06. % % The Activity column is "Live" when X(end) is within % InactiveThresholdSeconds_ (5 minutes) of nowSeconds in the same % time base, else "Inactive". Empty / unconvertible / future X % defensively renders "Inactive". % + % The Events column shows an integer count of events attached to + % the tag (via EventStore.getEventsForTag). Tags with no + % EventStore -- or any throw during the count -- render "0". + % 260519-bs4-06 patch. + % % Never throws -- a tag whose getXY/valueAt fails renders em-dash % placeholders for the dynamic columns AND "Inactive" for Activity. if nargin < 2 || isempty(nowSeconds) nowSeconds = TagStatusTableWindow.nowSeconds_(); end + if nargin < 3 + eventCountsByKey = []; + end em = char(8212); key = ''; name = ''; @@ -982,9 +1052,59 @@ function onRefreshTick_(obj) % Leave placeholders; never throw. end + % --- Events count (260519-bs4-06) --- + % Bucketed-by-key precomputed map preferred; falls back to a + % per-tag query when missing. countEventsForTag_ never throws. + useBucket = ~isempty(eventCountsByKey) && ... + isa(eventCountsByKey, 'containers.Map') && ... + ~isempty(key) && eventCountsByKey.isKey(key); + if useBucket + try + nEvents = double(eventCountsByKey(key)); + catch + nEvents = 0; + end + else + nEvents = TagStatusTableWindow.countEventsForTag_(tag); + end + eventsTxt = sprintf('%d', nEvents); + row = {key, name, typeLabel, crit, units, ... latestTxt, statusTxt, lastUpdatedTxt, activityTxt, ... - samplesTxt, labelStr}; + eventsTxt, samplesTxt, labelStr}; + end + + function n = countEventsForTag_(tag) + %COUNTEVENTSFORTAG_ Integer count of events attached to a Tag. + % Returns 0 for tags with no EventStore, [] EventStore, or any + % exception raised while querying. Delegates to tag.eventsAttached + % when available (the Tag base class API, which itself wraps + % EventStore.getEventsForTag), so tag subclasses that override + % the lookup (e.g. MonitorTag binding to a shared store) all + % route through the same query path. Pure function -- safe to + % call from the refresh loop without side effects. + % 260519-bs4-06 patch. + n = 0; + if isempty(tag); return; end + try + % Skip cheap-fail-fast cases without touching the store. + if ~isprop(tag, 'EventStore') || isempty(tag.EventStore) + return; + end + if ismethod(tag, 'eventsAttached') + events = tag.eventsAttached(); + else + events = tag.EventStore.getEventsForTag(char(tag.Key)); + end + if isempty(events) + n = 0; + else + n = numel(events); + end + catch + % EventStore not bound / throws / etc. -> 0 + n = 0; + end end function s = nowSeconds_() @@ -1013,7 +1133,7 @@ function onRefreshTick_(obj) % applies all four dimensions. % % Inputs: - % rows -- cell(N, 11) buffer (TagStatusTableWindow.RowBuffer_). + % rows -- cell(N, 12) buffer (TagStatusTableWindow.RowBuffer_). % query -- char/string; empty / whitespace = no search filter. % activeTypes -- cellstr subset of {sensor, monitor, % composite, state, derived}. Omitted = all kept. @@ -1025,7 +1145,7 @@ function onRefreshTick_(obj) % Semantics: % -- search: substring match (case-insensitive) on Key, Name, % Units, OR Labels (Labels are stored as a comma-joined string - % in column 11). + % in column 12; was column 11 pre-260519-bs4-06). % -- chip groups: AND across groups (a row passes only if it % matches the active set of every group), OR within a group % (a row matches if its value is in the active set). @@ -1205,12 +1325,13 @@ function onRefreshTick_(obj) function tf = rowMatchesSearch_(row, qLow) %ROWMATCHESSEARCH_ Case-insensitive substring match on Key+Name+Units+Labels. -% row -- 1x11 cell. Columns 1 (Key), 2 (Name), 5 (Units), 11 (Labels). +% row -- 1x12 cell. Columns 1 (Key), 2 (Name), 5 (Units), 12 (Labels). +% (Labels column moved 11 -> 12 in 260519-bs4-06 when Events was inserted.) % qLow -- already-lowercased query. % Tolerates rows with missing / non-char columns (defensive try/catch). tf = false; try - for c = [1, 2, 5, 11] + for c = [1, 2, 5, 12] val = row{c}; if ~ischar(val); continue; end if ~isempty(strfind(lower(val), qLow)) %#ok diff --git a/tests/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m index 0cf44112..330b67d7 100644 --- a/tests/suite/TestTagStatusTableWindow.m +++ b/tests/suite/TestTagStatusTableWindow.m @@ -109,14 +109,17 @@ function testMarkTagsDirty_updatesRow(testCase) app.scanLiveTagUpdatesForTest_(); - % Find tag_a row in the buffer; samples column (now index 10 after - % Activity column was inserted at 9) should read '5' and latest - % (index 6) should reflect 55. + % Find tag_a row in the buffer; samples column (now index 11 + % after Events column was inserted at 10 in 260519-bs4-06) should + % read '5' and latest (index 6) should reflect 55. The Events + % column at index 10 must be '0' since no EventStore is bound. rowA = findRowByKey_(w, 'tag_a'); testCase.verifyNotEmpty(rowA, ... 'testMarkTagsDirty_updatesRow: tag_a row must exist in buffer'); - testCase.verifyEqual(rowA{10}, '5', ... + testCase.verifyEqual(rowA{11}, '5', ... 'testMarkTagsDirty_updatesRow: Samples must reflect new count after tick'); + testCase.verifyEqual(rowA{10}, '0', ... + 'testMarkTagsDirty_updatesRow: Events count is 0 with no EventStore'); testCase.verifyTrue(any(strcmp(rowA{6}, {'55.00', '55', '55.000'})), ... sprintf(['testMarkTagsDirty_updatesRow: Latest must reflect new ' ... 'value 55, got ''%s'''], rowA{6})); @@ -352,6 +355,51 @@ function testPauseBtnLabelFlips(testCase) 'testPauseBtnLabelFlips: label must revert to ''Pause polling'' after resume'); end + function testEventsCountColumnPopulatedFromRegistry(testCase) + %TESTEVENTSCOUNTCOLUMNPOPULATEDFROMREGISTRY Tag with events emits real Events count in table.Data. + % Build a fixture where ONE tag has 3 bound events and the + % other has none; open a window; verify the table column 10 + % carries the right per-row count. 260519-bs4-06 patch. + TagRegistry.clear(); + EventBinding.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + testCase.addTeardown(@() EventBinding.clear()); + + % First tag: no EventStore -> Events count must be 0. + tA = SensorTag('no_events', 'Name', 'No Events'); + tA.updateData([1 2 3], [1 2 3]); + TagRegistry.register('no_events', tA); + + % Second tag: bind an EventStore with 3 events. + store = EventStore(''); + tB = SensorTag('has_events', 'Name', 'Has Events'); + tB.updateData([1 2 3], [10 20 30]); + tB.EventStore = store; + for i = 1:3 + ev = Event(i, i + 0.5, 'has_events', 'thr', NaN, 'upper'); + store.append(ev); + ev.TagKeys = {'has_events'}; + EventBinding.attach(ev.Id, 'has_events'); + end + TagRegistry.register('has_events', tB); + + app = FastSenseCompanion(); + testCase.addTeardown(@() safeClose_(app)); + w = app.openTagStatusTable(); + testCase.verifyEqual(w.bufferSize(), 2, ... + 'precondition: buffer must hold both rows'); + + rowNoEvents = findRowByKey_(w, 'no_events'); + rowHasEvents = findRowByKey_(w, 'has_events'); + + testCase.verifyNotEmpty(rowNoEvents, 'no_events row missing'); + testCase.verifyNotEmpty(rowHasEvents, 'has_events row missing'); + testCase.verifyEqual(rowNoEvents{10}, '0', ... + 'tag with no EventStore must show Events=0'); + testCase.verifyEqual(rowHasEvents{10}, '3', ... + 'tag with 3 bound events must show Events=3'); + end + function testRefreshTimerStoppedAndDeletedOnClose(testCase) %TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer. registerTwoSensors_(); diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m index 0a25b84f..3200e190 100644 --- a/tests/test_companion_tag_status_table.m +++ b/tests/test_companion_tag_status_table.m @@ -1,9 +1,10 @@ function test_companion_tag_status_table() %TEST_COMPANION_TAG_STATUS_TABLE Pure-logic unit tests for TagStatusTableWindow. -% Function-style tests (Octave-compatible) for the two static helper methods +% Function-style tests (Octave-compatible) for the three static helper methods % on TagStatusTableWindow: -% - buildRow_(tag) : 1x10 cell row, handles every Tag subclass + throwing tags. +% - buildRow_(tag) : 1x12 cell row, handles every Tag subclass + throwing tags. % - filterRows_(rows, query) : case-insensitive substring filter on Key+Name. +% - countEventsForTag_(tag) : O(1)-when-empty event count. % % NO UI is built; tests do not require a uifigure or graphical environment. % @@ -36,7 +37,11 @@ function test_companion_tag_status_table() @testFilterRows_matchesUnitsField, ... @testFilterRows_matchesLabelsField, ... @testFilterRows_chipFiltersAndSemantics, ... - @testFilterRows_emptyChipGroupExcludesAll }; + @testFilterRows_emptyChipGroupExcludesAll, ... + @testCountEventsForTag_noEventStore, ... + @testCountEventsForTag_withStubbedEvents, ... + @testBuildRow_includesEventsCountAtCol10, ... + @testBuildRow_eventCountsByKeyBucketedMapWins }; for i = 1:numel(tests) name = func2str(tests{i}); try @@ -64,7 +69,7 @@ function testBuildRowForSensorTag_basic() tag.updateData([1 2 3], [10 20 30]); row = TagStatusTableWindow.buildRow_(tag); - assertSize_(row, [1 11]); + assertSize_(row, [1 12]); em = char(8212); assertEqual_(row{1}, 'k', 'Key'); assertEqual_(row{2}, 'SensorName', 'Name'); @@ -81,9 +86,10 @@ function testBuildRowForSensorTag_basic() % Activity is "Inactive" because X(end)=3 is below 7e5 (the % datenum-or-posix anchor threshold in computeActivity_), so we cannot % map it to a wall-clock time and defensively render "Inactive". - assertEqual_(row{9}, 'Inactive', 'Activity (unanchored X)'); - assertEqual_(row{10}, '3', 'Samples'); - assertEqual_(row{11}, '', 'Labels'); + assertEqual_(row{9}, 'Inactive', 'Activity (unanchored X)'); + assertEqual_(row{10}, '0', 'Events (no EventStore bound)'); + assertEqual_(row{11}, '3', 'Samples'); + assertEqual_(row{12}, '', 'Labels'); end function testBuildRowForSensorTag_emptyData() @@ -92,12 +98,13 @@ function testBuildRowForSensorTag_emptyData() row = TagStatusTableWindow.buildRow_(tag); em = char(8212); - assertEqual_(row{1}, 'k_empty', 'Key'); - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, 'Inactive', 'Activity (empty XY)'); - assertEqual_(row{10}, '0', 'Samples'); + assertEqual_(row{1}, 'k_empty', 'Key'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (empty XY)'); + assertEqual_(row{10}, '0', 'Events (no EventStore bound)'); + assertEqual_(row{11}, '0', 'Samples'); end function testBuildRowForMonitorTag_alarm() @@ -148,11 +155,12 @@ function testBuildRowForStateTag_emptyValueAt() row = TagStatusTableWindow.buildRow_(st); em = char(8212); - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, 'Inactive', 'Activity (empty state)'); - assertEqual_(row{10}, '0', 'Samples'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (empty state)'); + assertEqual_(row{10}, '0', 'Events (no EventStore bound)'); + assertEqual_(row{11}, '0', 'Samples'); end function testBuildRowForCompositeTag() @@ -190,19 +198,21 @@ function testBuildRow_getXYThrows() row = TagStatusTableWindow.buildRow_(stub); em = char(8212); - assertEqual_(row{1}, 'throw_tag', 'Key'); + assertEqual_(row{1}, 'throw_tag', 'Key'); % Type/Crit/Units/Labels are from the stub's properties — still readable. - assertEqual_(row{6}, em, 'Latest'); - assertEqual_(row{7}, em, 'Status'); - assertEqual_(row{8}, em, 'Last updated'); - assertEqual_(row{9}, 'Inactive', 'Activity (throwing getXY)'); - assertEqual_(row{10}, '0', 'Samples'); + assertEqual_(row{6}, em, 'Latest'); + assertEqual_(row{7}, em, 'Status'); + assertEqual_(row{8}, em, 'Last updated'); + assertEqual_(row{9}, 'Inactive', 'Activity (throwing getXY)'); + assertEqual_(row{10}, '0', 'Events (no EventStore bound)'); + assertEqual_(row{11}, '0', 'Samples'); end function testFilterRows_caseInsensitive() + % 12-column fixture (Events col inserted at idx 10; Labels now at idx 12). rows = { ... - 'press_a', 'Pressure A', 'Sensor', 'medium', '', '', '', '', '', '', ''; ... - 'temp_b', 'Temp B', 'Sensor', 'medium', '', '', '', '', '', '', '' }; + 'press_a', 'Pressure A', 'Sensor', 'medium', '', '', '', '', '', '', '', ''; ... + 'temp_b', 'Temp B', 'Sensor', 'medium', '', '', '', '', '', '', '', '' }; kept = TagStatusTableWindow.filterRows_(rows, 'PRESS'); assertEqual_(size(kept, 1), 1, 'filter PRESS keeps 1 row'); @@ -216,7 +226,7 @@ function testFilterRows_caseInsensitive() end function testFilterRows_matchesKeyOrName() - rows = {'tag_x', 'Foo', 'Sensor', '', '', '', '', '', '', '', ''}; + rows = {'tag_x', 'Foo', 'Sensor', '', '', '', '', '', '', '', '', ''}; keptName = TagStatusTableWindow.filterRows_(rows, 'foo'); assertEqual_(size(keptName, 1), 1, 'filter ''foo'' matches Name'); @@ -264,7 +274,7 @@ function testActivityInactive_emptyXY() row = TagStatusTableWindow.buildRow_(tag, 1.7e9); assertEqual_(row{9}, 'Inactive', 'Activity should be Inactive when XY is empty'); - assertEqual_(row{10}, '0', 'Samples should be 0'); + assertEqual_(row{11}, '0', 'Samples should be 0'); end function testActivityInactive_futureTimestamp() @@ -283,11 +293,11 @@ function testFilterRows_subsetFixture() % Regression guard for the search field: filter on a multi-row fixture % and confirm we get exactly the expected subset. rows = { ... - 'pressure_inlet', 'Pressure Inlet', 'Sensor', '', '', '', '', '', '', '', ''; ... - 'pressure_outlet', 'Pressure Outlet', 'Sensor', '', '', '', '', '', '', '', ''; ... - 'temp_a', 'Temperature A', 'Sensor', '', '', '', '', '', '', '', ''; ... - 'state_machine', 'State Machine', 'State', '', '', '', '', '', '', '', ''; ... - 'alarm_high', 'Alarm High', 'Monitor', '', '', '', '', '', '', '', '' }; + 'pressure_inlet', 'Pressure Inlet', 'Sensor', '', '', '', '', '', '', '', '', ''; ... + 'pressure_outlet', 'Pressure Outlet', 'Sensor', '', '', '', '', '', '', '', '', ''; ... + 'temp_a', 'Temperature A', 'Sensor', '', '', '', '', '', '', '', '', ''; ... + 'state_machine', 'State Machine', 'State', '', '', '', '', '', '', '', '', ''; ... + 'alarm_high', 'Alarm High', 'Monitor', '', '', '', '', '', '', '', '', '' }; kept = TagStatusTableWindow.filterRows_(rows, 'pressure'); assertEqual_(size(kept, 1), 2, 'filter ''pressure'' keeps 2 rows'); @@ -309,8 +319,8 @@ function testFilterRows_matchesUnitsField() % Search now matches the Units column (column 5). Mirrors the user's % "search for °C" workflow: a row with Units='°C' must match. rows = { ... - 'temp_a', 'Temp A', 'Sensor', 'medium', char([176, 67]), '', '', '', '', '', ''; ... - 'press_a', 'Pressure A', 'Sensor', 'medium', 'bar', '', '', '', '', '', '' }; + 'temp_a', 'Temp A', 'Sensor', 'medium', char([176, 67]), '', '', '', '', '', '', ''; ... + 'press_a', 'Pressure A', 'Sensor', 'medium', 'bar', '', '', '', '', '', '', '' }; keptDegree = TagStatusTableWindow.filterRows_(rows, char([176, 67])); assertEqual_(size(keptDegree, 1), 1, 'filter on Units degC keeps the temperature row'); @@ -322,12 +332,13 @@ function testFilterRows_matchesUnitsField() end function testFilterRows_matchesLabelsField() - % Search must match the Labels column (column 11), which is a joined - % comma-separated string when there is more than one label. + % Search must match the Labels column (column 12 since 260519-bs4-06), + % which is a joined comma-separated string when there is more than one + % label. rows = { ... - 'k1', 'Tag 1', 'Sensor', 'medium', '', '', '', '', '', '', 'plant, north'; ... - 'k2', 'Tag 2', 'Sensor', 'medium', '', '', '', '', '', '', 'plant, south'; ... - 'k3', 'Tag 3', 'Sensor', 'medium', '', '', '', '', '', '', '' }; + 'k1', 'Tag 1', 'Sensor', 'medium', '', '', '', '', '', '', '', 'plant, north'; ... + 'k2', 'Tag 2', 'Sensor', 'medium', '', '', '', '', '', '', '', 'plant, south'; ... + 'k3', 'Tag 3', 'Sensor', 'medium', '', '', '', '', '', '', '', '' }; keptNorth = TagStatusTableWindow.filterRows_(rows, 'north'); assertEqual_(size(keptNorth, 1), 1, 'filter ''north'' on Labels keeps 1 row'); @@ -340,13 +351,14 @@ function testFilterRows_matchesLabelsField() function testFilterRows_chipFiltersAndSemantics() % Verifies AND-across-groups, OR-within-group semantics for the chip % filters. Build a small fixture with diverse Type/Crit/Activity cells. + % 12-column shape: ..., Activity(9), Events(10), Samples(11), Labels(12). rows = { ... - 'k1', 'Sensor Hi Live', 'Sensor', 'high', '', '', '', '', 'Live', '', ''; ... - 'k2', 'Sensor Med Live', 'Sensor', 'medium', '', '', '', '', 'Live', '', ''; ... - 'k3', 'Sensor Hi Inact', 'Sensor', 'high', '', '', '', '', 'Inactive', '', ''; ... - 'k4', 'Monitor Hi Live', 'Monitor', 'high', '', '', '', '', 'Live', '', ''; ... - 'k5', 'Compos Low Live', 'Composite', 'low', '', '', '', '', 'Live', '', ''; ... - 'k6', 'State Med Inact', 'State', 'medium', '', '', '', '', 'Inactive', '', '' }; + 'k1', 'Sensor Hi Live', 'Sensor', 'high', '', '', '', '', 'Live', '', '', ''; ... + 'k2', 'Sensor Med Live', 'Sensor', 'medium', '', '', '', '', 'Live', '', '', ''; ... + 'k3', 'Sensor Hi Inact', 'Sensor', 'high', '', '', '', '', 'Inactive', '', '', ''; ... + 'k4', 'Monitor Hi Live', 'Monitor', 'high', '', '', '', '', 'Live', '', '', ''; ... + 'k5', 'Compos Low Live', 'Composite', 'low', '', '', '', '', 'Live', '', '', ''; ... + 'k6', 'State Med Inact', 'State', 'medium', '', '', '', '', 'Inactive', '', '', '' }; % Type=Sensor only (OR within Type group), all crits, all activities. kept = TagStatusTableWindow.filterRows_(rows, '', ... @@ -382,8 +394,8 @@ function testFilterRows_chipFiltersAndSemantics() function testFilterRows_emptyChipGroupExcludesAll() % "Zero chips selected in any group" -> table shows nothing. rows = { ... - 'k1', 'A', 'Sensor', 'high', '', '', '', '', 'Live', '', ''; ... - 'k2', 'B', 'Sensor', 'medium', '', '', '', '', 'Inactive', '', '' }; + 'k1', 'A', 'Sensor', 'high', '', '', '', '', 'Live', '', '', ''; ... + 'k2', 'B', 'Sensor', 'medium', '', '', '', '', 'Inactive', '', '', '' }; % Empty Type group. kept = TagStatusTableWindow.filterRows_(rows, '', ... @@ -402,6 +414,92 @@ function testFilterRows_emptyChipGroupExcludesAll() assertEqual_(size(kept3, 1), 0, 'empty Activity chip group excludes all rows'); end +% ===================== Events column (260519-bs4-06 patch) ===================== + +function testCountEventsForTag_noEventStore() + % A vanilla SensorTag has no EventStore bound -> count is 0. + resetRegistry_(); + tag = SensorTag('plain_sensor', 'Name', 'Plain'); + tag.updateData([1 2 3], [10 20 30]); + + n = TagStatusTableWindow.countEventsForTag_(tag); + assertEqual_(n, 0, 'countEventsForTag_ must return 0 for tag with no EventStore'); +end + +function testCountEventsForTag_withStubbedEvents() + % Bind an EventStore with N events tagged to tag.Key. countEventsForTag_ + % must return N. Uses the same EventBinding.attach path that MonitorTag + % uses in production, so this exercises the real query path end to end. + resetRegistry_(); + EventBinding.clear(); + store = EventStore(''); + tag = SensorTag('binds_events', 'Name', 'Has Events'); + tag.updateData([1 2 3], [10 20 30]); + tag.EventStore = store; + + nExpected = 4; + for i = 1:nExpected + ev = Event(i * 10, i * 10 + 1, 'binds_events', 'thr_label', NaN, 'upper'); + store.append(ev); + ev.TagKeys = {'binds_events'}; + EventBinding.attach(ev.Id, 'binds_events'); + end + + n = TagStatusTableWindow.countEventsForTag_(tag); + assertEqual_(n, nExpected, ... + sprintf('countEventsForTag_ must return %d for a tag with %d stubbed events', ... + nExpected, nExpected)); +end + +function testBuildRow_includesEventsCountAtCol10() + % buildRow_ must put the integer event count at column 10 of the row. + resetRegistry_(); + EventBinding.clear(); + store = EventStore(''); + tag = SensorTag('row_events', 'Name', 'Row Events'); + tag.updateData([1 2 3], [10 20 30]); + tag.EventStore = store; + + % Append 2 events bound to the tag. + for i = 1:2 + ev = Event(i, i + 0.5, 'row_events', 'thr', NaN, 'upper'); + store.append(ev); + ev.TagKeys = {'row_events'}; + EventBinding.attach(ev.Id, 'row_events'); + end + + row = TagStatusTableWindow.buildRow_(tag); + assertSize_(row, [1 12]); + assertEqual_(row{10}, '2', 'Events count must appear at column 10'); +end + +function testBuildRow_eventCountsByKeyBucketedMapWins() + % When the precomputed eventCountsByKey map carries a value for the + % tag's Key, buildRow_ must read from the map and NOT call the per-tag + % query path. We verify this by passing a value that DIFFERS from + % what the per-tag query would compute (here: 42 vs the real 0, + % since the tag has no EventStore bound). + resetRegistry_(); + tag = SensorTag('cache_hit', 'Name', 'Cache Hit'); + tag.updateData([1 2 3], [10 20 30]); + + bucket = containers.Map('KeyType', 'char', 'ValueType', 'double'); + bucket('cache_hit') = 42; + + row = TagStatusTableWindow.buildRow_(tag, [], bucket); + assertEqual_(row{10}, '42', ... + 'buildRow_ must read Events count from eventCountsByKey when present'); + + % And when the key is missing from the bucket, buildRow_ must fall + % back to per-tag query (which returns 0 for a tag without an EventStore). + bucketMissing = containers.Map('KeyType', 'char', 'ValueType', 'double'); + bucketMissing('other_key') = 99; + + row2 = TagStatusTableWindow.buildRow_(tag, [], bucketMissing); + assertEqual_(row2{10}, '0', ... + 'buildRow_ must fall back to per-tag query when bucket lacks the key'); +end + % ===================== Helpers ===================== function add_companion_path() From 52fd9971a9717d9c7676cfae2d1daaf7c5045ff6 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 10:23:10 +0200 Subject: [PATCH 7/9] docs(state): record quick task 260519-bs4 row + post-merge metadata Adds the 260519-bs4 row to the Quick Tasks Completed table (Verified, 6 feature commits + 1 merge commit) and refreshes the Last activity lines after merging main into the branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/STATE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 96f9863f..d8be7985 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ milestone: v3.0 milestone_name: FastSense Companion status: shipped last_updated: "2026-05-12T09:20:00.000Z" -last_activity: 2026-05-14 -- Quick task 260513-s0y shipped (PR #143): Tile + Close all buttons on FastSenseCompanion top toolbar with 3 tracking fixes (sync from Engines_, public trackOpenedFigure hook, de-maximize + Units=pixels coercion). Verified live on industrial-plant demo. 9/9 sub-tests + 64/64 regression PASS. +last_activity: 2026-05-19 - Shipped quick task 260519-bs4 as PR #149: Tag Status Table window in FastSenseCompanion (12-col uitable, window-owned 1s polling, Activity column with 5-min inactive threshold, Type/Criticality/Activity chip filters, Pause/Resume polling toggle, Events count column). Verified on live industrial-plant demo. Filed backlog 999.1 for unified in-app help/wiki system. https://github.com/HanSur94/FastSense/pull/149 progress: total_phases: 6 completed_phases: 2 @@ -20,7 +20,9 @@ Phase: 1028 Plan: Not started Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30 Status: Awaiting next milestone (run `/gsd:new-milestone` to scope v3.x or v4.0) -Last activity: 2026-05-14 - Quick task 260513-s0y shipped as PR #143 (commits 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88). FastSenseCompanion: Tile + Close all toolbar buttons. Three tracking fixes uncovered by live verification: (1) syncOpenedFigures_ walks Engines_ — DashboardListPane fires OpenDashboardRequested BEFORE engine.render(), and run_demo's pre-rendered demo dashboard never goes through that event flow; (2) public trackOpenedFigure hook on the companion called from InspectorPane.onOpenDetail_ + CompanionEventViewer.openEventDashboard_ which spawn figures directly bypassing the event flow; (3) de-maximize + Units=pixels coercion before set(Position) — DashboardEngine.render defaults to Units=normalized so pixel rectangles got treated as screen fractions, pushing figures off-canvas (root cause of "Tile button does nothing" report). 9/9 sub-tests + 64/64 regression PASS. Verified on live industrial-plant demo. +Last activity: 2026-05-19 - Shipped quick task 260519-bs4 as PR #149 (commits b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1). FastSenseCompanion: Tag Status Table window — 12-col uitable, window-owned 1s polling (works regardless of companion.IsLive), Activity column with 5-min inactive threshold, Type/Criticality/Activity chip filters, broadened search (Key+Name+Units+Labels), Pause/Resume polling toggle, Events count column, "Last refreshed" header heartbeat. PR conflicted with main #143 (Tile+Close all) on the inner toolbar grid; resolved by combining to 1×7 grid (Events|Live|Tags|Tile|Close all|spacer|gear). Verified end-to-end (104/104 tests post-merge + live industrial-plant demo). Backlog 999.1 filed for unified in-app help/wiki system (deferred from a user-requested info button — escalated to milestone scope). + +Previous activity: 2026-05-14 - Quick task 260513-s0y shipped as PR #143. FastSenseCompanion: Tile + Close all toolbar buttons. https://github.com/HanSur94/FastSense/pull/143 ### Quick Tasks Completed @@ -66,6 +68,7 @@ Last activity: 2026-05-14 - Quick task 260513-s0y shipped as PR #143 (commits 18 | 260513-q7w | Debounced post-resize refresh + ZOMBIE-PANEL fix that stops widgets going white during drag-resize and tab switching — TWO parallel timers on every figure resize event (300 ms cheap two-pass refresh + 1.2 s unconditional rerenderWidgets backstop). switchPage cancels both timers AND waits up to 3 s for in-flight rerenderWidgets to complete before mutating state. `IsRerendering_` flag prevents rerender-cascade scheduling. Re-entrancy guard aborts instead of self-rescheduling. **Root-cause fix**: rerenderWidgets now deletes the OUTER cell panel (via hCellPanel, falling back to hPanel for pre-realization widgets) — previous code deleted only `hPanel` which after realization points to the INNER content panel, leaving the outer cell + its WidgetButtonBar chrome alive on the canvas as "zombies" that stacked up over multiple rerenders and painted over freshly switched-to pages. test_dashboard_range_selector_integration 2/2, test_dashboard_time_sync_all_pages 5/5; canvas-children-count canary verifies zero zombie accumulation across 4 rerenders + resize + tab switch (constant 29) | 2026-05-13 | 577bf95, 99c8808, 4eda604, bc305dc, 54d5aa0, 20bcd4c | — | [260513-q7w-during-dashboard-figure-resize-fastsense](./quick/260513-q7w-during-dashboard-figure-resize-fastsense/) | | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | | 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | +| 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) | ## Progress Bar From 318ce5d6a951a3c24c3a2ee6bdd99c568b835ce8 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 10:25:46 +0200 Subject: [PATCH 8/9] style: fix 5 mh_style operator_after_continuation violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI MATLAB Lint flagged 5 continuations where && started the new line instead of ending the previous line. Pure formatting — no semantic change. Affected: - libs/FastSenseCompanion/FastSenseCompanion.m lines 905, 1425, 1442, 1443 - tests/test_companion_tag_status_table.m line 543 24/24 pure-logic tests still PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 11 ++++++++++- .planning/ROADMAP.md | 12 +++++++++++- libs/FastSenseCompanion/FastSenseCompanion.m | 14 +++++++------- tests/test_companion_tag_status_table.m | 4 ++-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5a7e51e9..ba40abb0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,16 @@ "Skill(gsd-debug)", "Skill(gsd-debug:*)", "mcp__matlab__evaluate_matlab_code", - "mcp__matlab__run_matlab_test_file" + "mcp__matlab__run_matlab_test_file", + "Skill(superpowers:brainstorming)", + "Skill(superpowers:brainstorming:*)", + "Skill(gsd-do)", + "Skill(gsd-do:*)", + "Skill(gsd-quick)", + "Skill(gsd-quick:*)", + "mcp__matlab__run_matlab_file", + "Skill(gsd-add-backlog)", + "Skill(gsd-add-backlog:*)" ] } } diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ca3c2f01..b8654e3f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -191,4 +191,14 @@ Plans: ## Backlog -(empty — last 5 items promoted to phases 1024-1028 on 2026-05-08) +### Phase 999.1: Unified in-app help / user-manual / wiki system (BACKLOG) + +**Goal:** [Captured for future planning] Build a project-wide help system so every pane / widget / window can expose an Info button that opens a `uifigure` modal rendering markdown from `docs/help//.md`. Reuses `libs/Dashboard/MarkdownRenderer.m` and the existing Dashboard Info modal (260508-n8h). Scope includes: directory layout `docs/help/{companion,dashboard,webbridge,fastsense,sensor-threshold,event-detection}/`, index page with navigation, theme-aware rendering, search-across-docs, optional cross-link resolution, optional hooks into `scripts/generate_wiki.py`. + +**Source:** Quick task 260519-bs4 (Tag Status Table) — user requested an info button + markdown; on reflection we agreed a one-off button would be premature; the proper solution is its own milestone-sized piece of work. +**Decisions to nail later:** repo location of help files (`docs/help/` vs. co-located under `libs/`); whether to ship a build-time wiki bundle or render at runtime; theming contract; how/whether to auto-generate from API docs (`scripts/generate_wiki.py` already exists). +**Requirements:** TBD +**Plans:** 0 plans + +Plans: +- [ ] TBD (promote with /gsd:review-backlog when ready) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 84c10280..36e15000 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -901,8 +901,8 @@ function openSettings(obj) %OPENTAGSTATUSTABLE Open or focus the singleton TagStatusTableWindow. % Returns the handle so tests and external callers can drive it. % Quick task 260519-bs4. - if ~isempty(obj.TagStatusTableWindow_) && isvalid(obj.TagStatusTableWindow_) ... - && obj.TagStatusTableWindow_.IsOpen + if ~isempty(obj.TagStatusTableWindow_) && isvalid(obj.TagStatusTableWindow_) && ... + obj.TagStatusTableWindow_.IsOpen w = obj.TagStatusTableWindow_; % Bring the existing classical figure to the front. try @@ -1421,8 +1421,8 @@ function scanLiveTagUpdates_(obj) end % Live log emission is intentionally scoped to Sensor / % State (the existing audience for the live log). - if last > 0 && (isa(tg, 'SensorTag') || isa(tg, 'StateTag')) ... - && ~isempty(obj.LiveLogPane_) && isvalid(obj.LiveLogPane_) + if last > 0 && (isa(tg, 'SensorTag') || isa(tg, 'StateTag')) && ... + ~isempty(obj.LiveLogPane_) && isvalid(obj.LiveLogPane_) obj.LiveLogPane_.addLiveLogEntry(key, delta, latestY); end obj.LiveSampleCount_(key) = n; @@ -1438,9 +1438,9 @@ function scanLiveTagUpdates_(obj) function tf = shouldScanForStatusTable_(obj) %SHOULDSCANFORSTATUSTABLE_ True when a TagStatusTableWindow is attached + open. - tf = ~isempty(obj.TagStatusTableWindow_) ... - && isvalid(obj.TagStatusTableWindow_) ... - && obj.TagStatusTableWindow_.IsOpen; + tf = ~isempty(obj.TagStatusTableWindow_) && ... + isvalid(obj.TagStatusTableWindow_) && ... + obj.TagStatusTableWindow_.IsOpen; end function attachStatusTable_(obj, w) diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m index 3200e190..879f7046 100644 --- a/tests/test_companion_tag_status_table.m +++ b/tests/test_companion_tag_status_table.m @@ -539,8 +539,8 @@ function assertEqual_(actual, expected, label) if ischar(actual) && ischar(expected) && strcmp(actual, expected) return; end - if isnumeric(actual) && isscalar(actual) && isnumeric(expected) && isscalar(expected) ... - && actual == expected + if isnumeric(actual) && isscalar(actual) && isnumeric(expected) && isscalar(expected) && ... + actual == expected return; end if isequal(actual, expected) From e80349dc9836210a06eb75d96aa56d176b7c4006 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 19 May 2026 10:37:03 +0200 Subject: [PATCH 9/9] test(quick-260519-bs4): skip tag status table test on Octave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI Octave run failed because test_companion_tag_status_table tried to load TagStatusTableWindow.m to reach the static helpers, but the class file uses MATLAB-only syntax (uifigure / uitable / classdef properties blocks) that Octave cannot parse — fails with "syntax error near line 40, column 22" on every test case. Adds the standard Octave skip guard at the top of the test (same pattern as test_companion_apply_theme_walker, test_companion_open_ad_hoc_plot, test_companion_inspector_resolve_state). Companion is MATLAB-only by design (Phase 1018 cross-cutting rule). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_companion_tag_status_table.m | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_companion_tag_status_table.m b/tests/test_companion_tag_status_table.m index 879f7046..8b1e1428 100644 --- a/tests/test_companion_tag_status_table.m +++ b/tests/test_companion_tag_status_table.m @@ -1,15 +1,26 @@ function test_companion_tag_status_table() %TEST_COMPANION_TAG_STATUS_TABLE Pure-logic unit tests for TagStatusTableWindow. -% Function-style tests (Octave-compatible) for the three static helper methods -% on TagStatusTableWindow: +% Function-style tests for the three static helper methods on +% TagStatusTableWindow: % - buildRow_(tag) : 1x12 cell row, handles every Tag subclass + throwing tags. % - filterRows_(rows, query) : case-insensitive substring filter on Key+Name. % - countEventsForTag_(tag) : O(1)-when-empty event count. % % NO UI is built; tests do not require a uifigure or graphical environment. % +% MATLAB-only -- skipped on Octave. The helpers under test are static +% methods on TagStatusTableWindow.m, whose class definition uses +% MATLAB-only syntax (uifigure / uitable / properties block) that +% Octave cannot parse. Loading the file to reach the static helpers +% triggers a parse error, so the test is gated to MATLAB. +% % See also TagStatusTableWindow, test_companion_filter_tags. + if exist('OCTAVE_VERSION', 'builtin') ~= 0 + fprintf(' Skipping test_companion_tag_status_table on Octave (TagStatusTableWindow targets MATLAB R2020b+).\n'); + return; + end + add_companion_path(); % Cache + restore TagRegistry across the whole run so we don't pollute the