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/.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
diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m
index acc2af04..36e15000 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)
% S0Y-01/02 -- companion-opened figure tracking.
OpenedFigures_ = [] % column vector of figure handles the companion opened
% (dashboards via onOpenDashboardRequested_,
@@ -231,15 +234,16 @@
obj.hToolbarPanel_.Layout.Column = [1 3];
obj.hToolbarPanel_.BorderType = 'none';
obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground;
- % Inner 1x6 grid:
- % col 1 = Events viewer button (Task 13) (110)
- % col 2 = Live: ON/OFF button (110)
- % col 3 = Tile windows (S0Y-01) ( 70)
- % col 4 = Close all (S0Y-02) ( 90)
- % col 5 = flex spacer ('1x')
- % col 6 = Settings gear ( 36)
- hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 6]);
- hToolbarGrid.ColumnWidth = {110, 110, 70, 90, '1x', 36};
+ % Inner 1x7 grid:
+ % col 1 = Events viewer button (Task 13) (110)
+ % col 2 = Live: ON/OFF button (110)
+ % col 3 = Tags table launch (quick task 260519-bs4) (110)
+ % col 4 = Tile windows (S0Y-01) ( 70)
+ % col 5 = Close all (S0Y-02) ( 90)
+ % col 6 = flex spacer ('1x')
+ % col 7 = Settings gear ( 36)
+ hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 7]);
+ hToolbarGrid.ColumnWidth = {110, 110, 110, 70, 90, '1x', 36};
hToolbarGrid.RowHeight = {'1x'};
hToolbarGrid.Padding = [4 0 4 0];
hToolbarGrid.ColumnSpacing = 8;
@@ -270,10 +274,21 @@
obj.hLiveBtn_.Tooltip = 'Toggle live refresh of the inspector';
obj.hLiveBtn_.ButtonPushedFcn = @(~,~) obj.toggleLiveMode();
- % Col 3 — Tile windows (S0Y-01).
+ % 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 4 — Tile windows (S0Y-01).
obj.hTileBtn_ = uibutton(hToolbarGrid, 'push');
obj.hTileBtn_.Layout.Row = 1;
- obj.hTileBtn_.Layout.Column = 3;
+ obj.hTileBtn_.Layout.Column = 4;
obj.hTileBtn_.Text = 'Tile';
obj.hTileBtn_.FontSize = 11;
obj.hTileBtn_.FontWeight = 'bold';
@@ -282,10 +297,10 @@
obj.hTileBtn_.FontColor = obj.Theme_.ForegroundColor;
obj.hTileBtn_.ButtonPushedFcn = @(~,~) obj.tileOpenedWindows();
- % Col 4 — Close all (S0Y-02). Uses Accent color to signal destructive action.
+ % Col 5 — Close all (S0Y-02). Uses Accent color to signal destructive action.
obj.hCloseAllBtn_ = uibutton(hToolbarGrid, 'push');
obj.hCloseAllBtn_.Layout.Row = 1;
- obj.hCloseAllBtn_.Layout.Column = 4;
+ obj.hCloseAllBtn_.Layout.Column = 5;
obj.hCloseAllBtn_.Text = 'Close all';
obj.hCloseAllBtn_.FontSize = 11;
obj.hCloseAllBtn_.FontWeight = 'bold';
@@ -294,10 +309,10 @@
obj.hCloseAllBtn_.FontColor = obj.Theme_.ForegroundColor;
obj.hCloseAllBtn_.ButtonPushedFcn = @(~,~) obj.closeAllOpenedWindows();
- % Col 6 — Settings gear.
+ % Col 7 — Settings gear.
obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push');
obj.hSettingsBtn_.Layout.Row = 1;
- obj.hSettingsBtn_.Layout.Column = 6;
+ obj.hSettingsBtn_.Layout.Column = 7;
obj.hSettingsBtn_.Text = char(9881); % gear glyph
obj.hSettingsBtn_.FontSize = 14;
obj.hSettingsBtn_.Tooltip = 'Companion settings';
@@ -444,6 +459,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_)
@@ -569,6 +596,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();
@@ -859,6 +897,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
@@ -1084,6 +1153,22 @@ 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
+
+ % --- S0Y-01/02 test seams (Hidden, do not call from production) ---
+
function figs = getOpenedFiguresForTest_(obj)
%GETOPENEDFIGURESFORTEST_ Test helper: return the OpenedFigures_ tracking list.
% Used by test_companion_tile_close_buttons. Returns the raw column
@@ -1291,7 +1376,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
@@ -1300,12 +1385,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
@@ -1324,14 +1419,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/libs/FastSenseCompanion/TagStatusTableWindow.m b/libs/FastSenseCompanion/TagStatusTableWindow.m
new file mode 100644
index 00000000..e93a258b
--- /dev/null
+++ b/libs/FastSenseCompanion/TagStatusTableWindow.m
@@ -0,0 +1,1372 @@
+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 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, 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; stops timer; 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
+ 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)
+ 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)
+ 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)
+ 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"
+ % 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)
+
+ function obj = TagStatusTableWindow()
+ 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.
+ obj.ActiveTypeChips_ = obj.TypeChipKeys_;
+ obj.ActiveCritChips_ = obj.CritChipKeys_;
+ obj.ActiveActivityChips_ = obj.ActivityChipKeys_;
+ 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). ---
+ % 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 580], ...
+ 'CloseRequestFcn', @(~,~) obj.onCloseRequest_());
+ movegui(obj.hFig_, 'center');
+
+ % --- 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.
+ % 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.85 0.04], ...
+ 'String', 'Last refreshed: --:--:--', ...
+ 'HorizontalAlignment', 'left', ...
+ 'BackgroundColor', t.WidgetBackground, ...
+ 'ForegroundColor', t.PlaceholderTextColor, ...
+ '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', ...
+ 'Units', 'normalized', ...
+ 'Position', [0.01 0.89 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.89 0.43 0.05], ...
+ '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.89 0.44 0.05], ...
+ 'String', 'Tags', ...
+ 'HorizontalAlignment', 'right', ...
+ 'BackgroundColor', t.WidgetBackground, ...
+ 'ForegroundColor', t.ForegroundColor, ...
+ 'FontSize', 10, ...
+ 'FontWeight', 'bold');
+
+ % --- Chip strip: Type / Criticality / Activity ---
+ obj.buildChipStrip_(t);
+
+ % --- Striped pair derived from theme (mirrors LiveLogPane). ---
+ stripePair = obj.stripePairFromTheme_(t);
+
+ % --- Center uitable. ---
+ % 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', ...
+ '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, 12));
+
+ % --- 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);
+
+ % --- 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. ---
+ % 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)
+ %MARKTAGSDIRTY Refresh only rows for the listed tag keys.
+ % 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
+ 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, eventCountsByKey);
+ 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.hLastRefreshLbl_) && isvalid(obj.hLastRefreshLbl_)
+ 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_();
+ 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 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_)
+ 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
+
+ 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
+
+ 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)
+
+ function onCloseRequest_(obj)
+ %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
+ 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.hLastRefreshLbl_ = [];
+ obj.hPauseBtn_ = [];
+ obj.hChipsType_ = [];
+ obj.hChipsCrit_ = [];
+ obj.hChipsActivity_ = [];
+ obj.IsOpen = false;
+ end
+
+ function rebuildAll_(obj)
+ %REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted).
+ obj.RowBuffer_ = cell(0, 12);
+ 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, 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, eventCountsByKey);
+ obj.KeyToRow_(keysSorted{k}) = k;
+ end
+ end
+
+ 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, ...
+ obj.ActiveTypeChips_, obj.ActiveCritChips_, obj.ActiveActivityChips_);
+ 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 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;
+ 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
+
+ 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). 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
+ try
+ ts = char(datetime('now', 'Format', 'HH:mm:ss'));
+ catch
+ % Octave / stripped MATLAB fallback.
+ ts = datestr(now, 'HH:MM:SS'); %#ok
+ end
+ 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)
+ %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();
+ % 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, eventCountsByKey);
+ oldRow = obj.RowBuffer_(idx, :);
+ if ~isequal(newRow, oldRow)
+ obj.RowBuffer_(idx, :) = newRow;
+ changed = true;
+ end
+ end
+ 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;
+ 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, nowSeconds, eventCountsByKey)
+ %BUILDROW_ Return a 1x12 cell row describing tag's current status.
+ % Columns: Key, Name, Type, Criticality, Units, Latest, Status,
+ % 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. 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 = '';
+ 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;
+ activityTxt = 'Inactive';
+ 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 + Activity ---
+ if isnumeric(X) && isfinite(X(end))
+ lastUpdatedTxt = formatLastUpdated_(X(end));
+ activityTxt = computeActivity_(X(end), nowSeconds, ...
+ TagStatusTableWindow.InactiveThresholdSeconds_);
+ 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
+
+ % --- 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, ...
+ 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_()
+ %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, 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, 12) 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 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).
+ % -- 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
+ 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
+ keep = true(size(rows, 1), 1);
+ for i = 1:size(rows, 1)
+ if haveQuery && ~rowMatchesSearch_(rows(i, :), qLow)
+ keep(i) = false;
+ continue;
+ end
+ if applyTypes && ~rowMatchesType_(rows(i, :), activeTypes)
+ keep(i) = false;
+ continue;
+ end
+ 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, :);
+ 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
+
+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
+
+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 -- 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, 12]
+ 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/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/suite/TestTagStatusTableWindow.m b/tests/suite/TestTagStatusTableWindow.m
new file mode 100644
index 00000000..330b67d7
--- /dev/null
+++ b/tests/suite/TestTagStatusTableWindow.m
@@ -0,0 +1,505 @@
+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 (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{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}));
+ 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
+
+ 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 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 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 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_();
+ 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
+
+% ===================== 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
+
+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
new file mode 100644
index 00000000..8b1e1428
--- /dev/null
+++ b/tests/test_companion_tag_status_table.m
@@ -0,0 +1,584 @@
+function test_companion_tag_status_table()
+%TEST_COMPANION_TAG_STATUS_TABLE Pure-logic unit tests for 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
+ % 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, ...
+ @testActivityLive_recentPosixTimestamp, ...
+ @testActivityInactive_oldDatenumTimestamp, ...
+ @testActivityInactive_emptyXY, ...
+ @testActivityInactive_futureTimestamp, ...
+ @testFilterRows_subsetFixture, ...
+ @testFilterRows_matchesUnitsField, ...
+ @testFilterRows_matchesLabelsField, ...
+ @testFilterRows_chipFiltersAndSemantics, ...
+ @testFilterRows_emptyChipGroupExcludesAll, ...
+ @testCountEventsForTag_noEventStore, ...
+ @testCountEventsForTag_withStubbedEvents, ...
+ @testBuildRow_includesEventsCountAtCol10, ...
+ @testBuildRow_eventCountsByKeyBucketedMapWins };
+ 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 12]);
+ 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)');
+ % 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}, '0', 'Events (no EventStore bound)');
+ assertEqual_(row{11}, '3', 'Samples');
+ assertEqual_(row{12}, '', '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}, 'Inactive', 'Activity (empty XY)');
+ assertEqual_(row{10}, '0', 'Events (no EventStore bound)');
+ assertEqual_(row{11}, '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}, 'Inactive', 'Activity (empty state)');
+ assertEqual_(row{10}, '0', 'Events (no EventStore bound)');
+ assertEqual_(row{11}, '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}, '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', '', '', '', '', '', '', '', '' };
+
+ 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
+
+% ===================== 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{11}, '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
+
+% ===================== 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 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', '', '', '', '', '', '', '', '' };
+
+ 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.
+ % 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', '', '', '' };
+
+ % 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
+
+% ===================== 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()
+ 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