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