From 78628eb4dfb1dc0ab3dfafc338a7fdba1efd49a5 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 18:17:55 +0200 Subject: [PATCH 01/26] feat(companion): add EventStore auto-discovery helper Walk TagRegistry for the first MonitorTag carrying a non-empty EventStore. Private helper companionDiscoverEventStore lives in libs/FastSenseCompanion/private/; runDiscoverEventStoreTests bridges the private-directory access gap for the class-based test suite. Co-Authored-By: Claude Sonnet 4.6 --- .../private/companionDiscoverEventStore.m | 26 +++++ .../runDiscoverEventStoreTests.m | 100 ++++++++++++++++++ tests/suite/TestFastSenseCompanion.m | 32 ++++++ 3 files changed, 158 insertions(+) create mode 100644 libs/FastSenseCompanion/private/companionDiscoverEventStore.m create mode 100644 libs/FastSenseCompanion/runDiscoverEventStoreTests.m diff --git a/libs/FastSenseCompanion/private/companionDiscoverEventStore.m b/libs/FastSenseCompanion/private/companionDiscoverEventStore.m new file mode 100644 index 00000000..f0e452ee --- /dev/null +++ b/libs/FastSenseCompanion/private/companionDiscoverEventStore.m @@ -0,0 +1,26 @@ +function store = companionDiscoverEventStore() +%COMPANIONDISCOVEREVENTSTORE Walk TagRegistry for the first MonitorTag with a non-empty EventStore. +% store = companionDiscoverEventStore() returns the EventStore handle of +% the first MonitorTag in the global TagRegistry whose EventStore +% property is non-empty. Returns [] if the registry is empty or no such +% MonitorTag exists. +% +% This is the auto-discovery path for FastSenseCompanion's EventStore +% wiring. Explicit 'EventStore' constructor option always wins over +% discovery; this helper is invoked only when no override is supplied. +% +% Iteration order matches TagRegistry.find() — for the industrial plant +% demo this is the registration order, which means the first registered +% MonitorTag wins (all share ctx.store, so any of them is correct). + + store = []; + allTags = TagRegistry.find(@(t) true); + if isempty(allTags); return; end + for i = 1:numel(allTags) + t = allTags{i}; + if isa(t, 'MonitorTag') && ~isempty(t.EventStore) + store = t.EventStore; + return; + end + end +end diff --git a/libs/FastSenseCompanion/runDiscoverEventStoreTests.m b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m new file mode 100644 index 00000000..f720e704 --- /dev/null +++ b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m @@ -0,0 +1,100 @@ +function results = runDiscoverEventStoreTests() +%RUNDISCOVEREVENTRESTORETESTS Execute unit tests for companionDiscoverEventStore. +% results = runDiscoverEventStoreTests() exercises the private helper +% companionDiscoverEventStore and returns a struct array, one element per +% test, with fields: +% name — char, test name +% passed — logical, true if the assertion held +% msg — char, failure message (empty when passed) +% +% This runner lives in libs/FastSenseCompanion/ (same directory as the +% private/ sub-folder) so that MATLAB's private-directory mechanism makes +% companionDiscoverEventStore visible. Tests in TestFastSenseCompanion +% delegate here via a single call. +% +% See also companionDiscoverEventStore, TestFastSenseCompanion. + + results = runTest1_emptyRegistry_(); + results(end+1) = runTest2_findsFirstStore_(); + results(end+1) = runTest3_skipsTagsWithoutStore_(); +end + +% --------------------------------------------------------------------------- + +function r = runTest1_emptyRegistry_() +%RUNTEST1_EMPTYREGISTRY_ Empty registry -> [] returned. + r.name = 'testDiscoverEventStoreReturnsEmptyOnEmptyRegistry'; + r.passed = false; + r.msg = ''; + TagRegistry.clear(); + try + store = companionDiscoverEventStore(); + if isempty(store) + r.passed = true; + else + r.msg = 'companionDiscoverEventStore: empty registry must return [].'; + end + catch e + r.msg = e.message; + end + TagRegistry.clear(); +end + +function r = runTest2_findsFirstStore_() +%RUNTEST2_FINDSFIRSTSTORE_ MonitorTag with EventStore -> handle returned. + r.name = 'testDiscoverEventStoreFindsFirstMonitorTagStore'; + r.passed = false; + r.msg = ''; + TagRegistry.clear(); + try + parent = SensorTag('p', 'Name', 'P', 'Units', 'u', ... + 'X', [0 1 2], 'Y', [1 2 3]); + TagRegistry.register('p', parent); + + storePath = [tempname() '.mat']; + es = EventStore(storePath); + + mon = MonitorTag('m', parent, @(x,y) y > 100, ... + 'EventStore', es); + TagRegistry.register('m', mon); + + found = companionDiscoverEventStore(); + if ~isempty(found) && found == es + r.passed = true; + else + r.msg = 'companionDiscoverEventStore: must return the MonitorTag''s EventStore.'; + end + catch e + r.msg = e.message; + end + TagRegistry.clear(); + if exist(storePath, 'file') == 2 + delete(storePath); + end +end + +function r = runTest3_skipsTagsWithoutStore_() +%RUNTEST3_SKIPSTAGSWITHOUSTORE_ MonitorTag without EventStore -> [] returned. + r.name = 'testDiscoverEventStoreSkipsTagsWithoutStore'; + r.passed = false; + r.msg = ''; + TagRegistry.clear(); + try + parent = SensorTag('p', 'Name', 'P', 'Units', 'u', ... + 'X', [0 1 2], 'Y', [1 2 3]); + TagRegistry.register('p', parent); + + mon = MonitorTag('m', parent, @(x,y) y > 100); % no EventStore + TagRegistry.register('m', mon); + + found = companionDiscoverEventStore(); + if isempty(found) + r.passed = true; + else + r.msg = 'companionDiscoverEventStore: must return [] when no monitor has a store.'; + end + catch e + r.msg = e.message; + end + TagRegistry.clear(); +end diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index 46f338b8..48623646 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1006,6 +1006,38 @@ function testCloseEventsDetachedWindowOnlyAffectsEvents(testCase) 'Live detached uifigure must NOT be torn down by the events close'); end + % ---- Task 1: Auto-discover EventStore from registry ---- + % These three tests delegate to runDiscoverEventStoreTests(), which + % lives in libs/FastSenseCompanion/ and has MATLAB private-directory + % access to companionDiscoverEventStore. + + function testDiscoverEventStoreReturnsEmptyOnEmptyRegistry(testCase) + %TESTDISCOVEREVENTSTORERETURNSEMPTYONEMPTYREGISTRY + % With no MonitorTags carrying an EventStore, helper returns []. + testCase.addTeardown(@() TagRegistry.clear()); + r = runDiscoverEventStoreTests(); + idx = strcmp({r.name}, 'testDiscoverEventStoreReturnsEmptyOnEmptyRegistry'); + testCase.verifyTrue(r(idx).passed, r(idx).msg); + end + + function testDiscoverEventStoreFindsFirstMonitorTagStore(testCase) + %TESTDISCOVEREVENTSTOREFINDSFIRSTMONITORTAGSTORE + % Registry with one MonitorTag whose EventStore is set returns it. + testCase.addTeardown(@() TagRegistry.clear()); + r = runDiscoverEventStoreTests(); + idx = strcmp({r.name}, 'testDiscoverEventStoreFindsFirstMonitorTagStore'); + testCase.verifyTrue(r(idx).passed, r(idx).msg); + end + + function testDiscoverEventStoreSkipsTagsWithoutStore(testCase) + %TESTDISCOVEREVENTSTORESKIPSTAGSWITHOUTSTORE + % Registry with MonitorTags whose EventStore is [] returns []. + testCase.addTeardown(@() TagRegistry.clear()); + r = runDiscoverEventStoreTests(); + idx = strcmp({r.name}, 'testDiscoverEventStoreSkipsTagsWithoutStore'); + testCase.verifyTrue(r(idx).passed, r(idx).msg); + end + end methods (Access = private) From 23ff5128cecddcb2f0a4779899f59ab580c91c5f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 18:33:09 +0200 Subject: [PATCH 02/26] fix(companion): align discover-event-store runner with sibling pattern + flat test Rewrote runDiscoverEventStoreTests to use the void/assert-based pattern matching all sibling runners (runFilterTagsTests, etc.), fixed H1 typos (RUNDISCOVEREVENTRESTORETESTS -> RUNDISCOVEREVENTSTORETESTS, SKIPSTAGSWITHOUSTORE_ -> SKIPSTAGSWITHOUTSTORE_), initialized storePath before the try block in test 2, added the flat Octave-compatible test file tests/test_companion_discover_event_store.m, and replaced the three struct-return delegate methods in TestFastSenseCompanion with a single testDiscoverEventStoreSuite that wraps the runner in verifyWarningFree. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runDiscoverEventStoreTests.m | 103 ++++++------------ tests/suite/TestFastSenseCompanion.m | 34 ++---- tests/test_companion_discover_event_store.m | 18 +++ 3 files changed, 60 insertions(+), 95 deletions(-) create mode 100644 tests/test_companion_discover_event_store.m diff --git a/libs/FastSenseCompanion/runDiscoverEventStoreTests.m b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m index f720e704..c06d157c 100644 --- a/libs/FastSenseCompanion/runDiscoverEventStoreTests.m +++ b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m @@ -1,50 +1,24 @@ -function results = runDiscoverEventStoreTests() -%RUNDISCOVEREVENTRESTORETESTS Execute unit tests for companionDiscoverEventStore. -% results = runDiscoverEventStoreTests() exercises the private helper -% companionDiscoverEventStore and returns a struct array, one element per -% test, with fields: -% name — char, test name -% passed — logical, true if the assertion held -% msg — char, failure message (empty when passed) -% -% This runner lives in libs/FastSenseCompanion/ (same directory as the -% private/ sub-folder) so that MATLAB's private-directory mechanism makes -% companionDiscoverEventStore visible. Tests in TestFastSenseCompanion -% delegate here via a single call. +function runDiscoverEventStoreTests() +%RUNDISCOVEREVENTSTORETESTS Execute unit tests for companionDiscoverEventStore. +% Called by tests/test_companion_discover_event_store.m. Lives here +% (inside libs/FastSenseCompanion) so that MATLAB's private-directory +% mechanism makes companionDiscoverEventStore visible (private functions +% are accessible to callers in the same folder). % % See also companionDiscoverEventStore, TestFastSenseCompanion. - results = runTest1_emptyRegistry_(); - results(end+1) = runTest2_findsFirstStore_(); - results(end+1) = runTest3_skipsTagsWithoutStore_(); -end - -% --------------------------------------------------------------------------- + nPassed = 0; -function r = runTest1_emptyRegistry_() -%RUNTEST1_EMPTYREGISTRY_ Empty registry -> [] returned. - r.name = 'testDiscoverEventStoreReturnsEmptyOnEmptyRegistry'; - r.passed = false; - r.msg = ''; + % --- Test 1: empty registry -> [] returned --- TagRegistry.clear(); - try - store = companionDiscoverEventStore(); - if isempty(store) - r.passed = true; - else - r.msg = 'companionDiscoverEventStore: empty registry must return [].'; - end - catch e - r.msg = e.message; - end + store = companionDiscoverEventStore(); + assert(isempty(store), ... + 'Test 1: companionDiscoverEventStore must return [] for an empty registry.'); TagRegistry.clear(); -end + nPassed = nPassed + 1; -function r = runTest2_findsFirstStore_() -%RUNTEST2_FINDSFIRSTSTORE_ MonitorTag with EventStore -> handle returned. - r.name = 'testDiscoverEventStoreFindsFirstMonitorTagStore'; - r.passed = false; - r.msg = ''; + % --- Test 2: MonitorTag with EventStore -> handle returned --- + storePath = ''; TagRegistry.clear(); try parent = SensorTag('p', 'Name', 'P', 'Units', 'u', ... @@ -54,47 +28,40 @@ storePath = [tempname() '.mat']; es = EventStore(storePath); - mon = MonitorTag('m', parent, @(x,y) y > 100, ... + mon = MonitorTag('m', parent, @(x, y) y > 100, ... 'EventStore', es); TagRegistry.register('m', mon); found = companionDiscoverEventStore(); - if ~isempty(found) && found == es - r.passed = true; - else - r.msg = 'companionDiscoverEventStore: must return the MonitorTag''s EventStore.'; - end + assert(~isempty(found) && found == es, ... + 'Test 2: companionDiscoverEventStore must return the MonitorTag''s EventStore.'); catch e - r.msg = e.message; + TagRegistry.clear(); + if exist(storePath, 'file') == 2 + delete(storePath); + end + rethrow(e); end TagRegistry.clear(); if exist(storePath, 'file') == 2 delete(storePath); end -end + nPassed = nPassed + 1; -function r = runTest3_skipsTagsWithoutStore_() -%RUNTEST3_SKIPSTAGSWITHOUSTORE_ MonitorTag without EventStore -> [] returned. - r.name = 'testDiscoverEventStoreSkipsTagsWithoutStore'; - r.passed = false; - r.msg = ''; + % --- Test 3: MonitorTag without EventStore -> [] returned --- TagRegistry.clear(); - try - parent = SensorTag('p', 'Name', 'P', 'Units', 'u', ... - 'X', [0 1 2], 'Y', [1 2 3]); - TagRegistry.register('p', parent); + parent = SensorTag('p', 'Name', 'P', 'Units', 'u', ... + 'X', [0 1 2], 'Y', [1 2 3]); + TagRegistry.register('p', parent); - mon = MonitorTag('m', parent, @(x,y) y > 100); % no EventStore - TagRegistry.register('m', mon); + mon = MonitorTag('m', parent, @(x, y) y > 100); % no EventStore + TagRegistry.register('m', mon); - found = companionDiscoverEventStore(); - if isempty(found) - r.passed = true; - else - r.msg = 'companionDiscoverEventStore: must return [] when no monitor has a store.'; - end - catch e - r.msg = e.message; - end + found = companionDiscoverEventStore(); + assert(isempty(found), ... + 'Test 3: companionDiscoverEventStore must return [] when no monitor has a store.'); TagRegistry.clear(); + nPassed = nPassed + 1; + + fprintf(' All %d tests passed.\n', nPassed); end diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index 48623646..59e5a11e 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1007,35 +1007,15 @@ function testCloseEventsDetachedWindowOnlyAffectsEvents(testCase) end % ---- Task 1: Auto-discover EventStore from registry ---- - % These three tests delegate to runDiscoverEventStoreTests(), which - % lives in libs/FastSenseCompanion/ and has MATLAB private-directory - % access to companionDiscoverEventStore. - function testDiscoverEventStoreReturnsEmptyOnEmptyRegistry(testCase) - %TESTDISCOVEREVENTSTORERETURNSEMPTYONEMPTYREGISTRY - % With no MonitorTags carrying an EventStore, helper returns []. - testCase.addTeardown(@() TagRegistry.clear()); - r = runDiscoverEventStoreTests(); - idx = strcmp({r.name}, 'testDiscoverEventStoreReturnsEmptyOnEmptyRegistry'); - testCase.verifyTrue(r(idx).passed, r(idx).msg); - end - - function testDiscoverEventStoreFindsFirstMonitorTagStore(testCase) - %TESTDISCOVEREVENTSTOREFINDSFIRSTMONITORTAGSTORE - % Registry with one MonitorTag whose EventStore is set returns it. - testCase.addTeardown(@() TagRegistry.clear()); - r = runDiscoverEventStoreTests(); - idx = strcmp({r.name}, 'testDiscoverEventStoreFindsFirstMonitorTagStore'); - testCase.verifyTrue(r(idx).passed, r(idx).msg); - end - - function testDiscoverEventStoreSkipsTagsWithoutStore(testCase) - %TESTDISCOVEREVENTSTORESKIPSTAGSWITHOUTSTORE - % Registry with MonitorTags whose EventStore is [] returns []. + function testDiscoverEventStoreSuite(testCase) + %TESTDISCOVEREVENTSTORESUITE Run the flat-file test suite for the helper. + % Wraps the assert-based runner so its stdout output is captured and + % any assertion failure is surfaced as an xunit-style test failure. + TagRegistry.clear(); testCase.addTeardown(@() TagRegistry.clear()); - r = runDiscoverEventStoreTests(); - idx = strcmp({r.name}, 'testDiscoverEventStoreSkipsTagsWithoutStore'); - testCase.verifyTrue(r(idx).passed, r(idx).msg); + testCase.verifyWarningFree(@() evalc('runDiscoverEventStoreTests()'), ... + 'runDiscoverEventStoreTests must complete without errors.'); end end diff --git a/tests/test_companion_discover_event_store.m b/tests/test_companion_discover_event_store.m new file mode 100644 index 00000000..b906a904 --- /dev/null +++ b/tests/test_companion_discover_event_store.m @@ -0,0 +1,18 @@ +function test_companion_discover_event_store() +%TEST_COMPANION_DISCOVER_EVENT_STORE Octave-compatible flat test for companionDiscoverEventStore. +% Delegates to runDiscoverEventStoreTests which lives inside +% libs/FastSenseCompanion so that MATLAB's private-directory mechanism +% makes companionDiscoverEventStore accessible (private functions are +% visible to callers in the same folder). +% +% See also companionDiscoverEventStore, runDiscoverEventStoreTests. + + add_companion_path_(); + runDiscoverEventStoreTests(); +end + +function add_companion_path_() +%ADD_COMPANION_PATH_ Add libs to path. + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); + install(); +end From fb77912f605f625694f5a8353822d224e3f9f393 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 18:40:42 +0200 Subject: [PATCH 03/26] feat(companion): EventStore constructor option with auto-discovery fallback Adds 'EventStore' name-value constructor option to FastSenseCompanion. When supplied, the explicit handle is stored verbatim; when absent or [], the private companionDiscoverEventStore() helper is called to walk the TagRegistry and return the first MonitorTag's non-empty EventStore. Adds getEventStore() public accessor and 5 new passing tests. Co-Authored-By: Claude Sonnet 4.6 --- libs/FastSenseCompanion/FastSenseCompanion.m | 28 ++++++- tests/suite/TestFastSenseCompanion.m | 79 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 60dca88a..bf719697 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -85,6 +85,8 @@ hEventsLogPanel_ = [] % sub-panel (LogPaneRoot-tagged) for events pane hLiveLogPanel_ = [] % sub-panel (LogPaneRoot-tagged) for live pane 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) end methods (Access = public) @@ -105,6 +107,7 @@ userName = 'FastSense Companion'; userTheme = 'dark'; userLivePeriod = 1.0; + userEventStore = []; % Step 2b — Override with stored prefdir values (if present and well-formed). % Priority: built-in default < prefdir < explicit Name-Value (Step 3). @@ -145,10 +148,17 @@ 'LivePeriod must be a positive finite scalar (seconds).'); end userLivePeriod = double(v); + case 'EventStore' + v = varargin{k+1}; + if ~isempty(v) && ~isa(v, 'EventStore') + error('FastSenseCompanion:invalidEventStore', ... + 'EventStore must be an EventStore handle or [] (got %s).', class(v)); + end + userEventStore = v; otherwise error('FastSenseCompanion:unknownOption', ... ['Unknown option ''%s''. Valid options: ', ... - 'Dashboards, Registry, Name, Theme, LivePeriod.'], key); + 'Dashboards, Registry, Name, Theme, LivePeriod, EventStore.'], key); end end @@ -178,6 +188,14 @@ obj.LivePeriod_ = userLivePeriod; obj.LivePeriod = userLivePeriod; + % Step 6b — Resolve EventStore: explicit override wins; otherwise + % auto-discover from the first MonitorTag with a non-empty EventStore. + if ~isempty(userEventStore) + obj.EventStore_ = userEventStore; + else + obj.EventStore_ = companionDiscoverEventStore(); + end + % Step 7 — Build uifigure (Visible='off' while building) obj.hFig_ = uifigure( ... 'Name', userName, ... @@ -895,6 +913,14 @@ function applyLogState(obj, which, newState) end end + function s = getEventStore(obj) + %GETEVENTSTORE Return the resolved EventStore handle (or [] if none). + % Returns whatever was passed via the 'EventStore' constructor + % option, OR the auto-discovered store from the registry, OR [] + % if neither resolved. + s = obj.EventStore_; + end + end methods (Access = private) diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index 59e5a11e..e0d05c4e 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1018,6 +1018,85 @@ function testDiscoverEventStoreSuite(testCase) 'runDiscoverEventStoreTests must complete without errors.'); end + % ---- Task 2: EventStore constructor option with auto-discovery ---- + + function testEventStoreOptionAcceptsHandle(testCase) + %TESTEVENTSTOREOPTIONACCEPTSHANDLE + % Explicit 'EventStore' option is stored on the object. + storePath = [tempname() '.mat']; + es = EventStore(storePath); + testCase.addTeardown(@() delete(storePath)); + + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + + testCase.verifySameHandle(app.getEventStore(), es, ... + 'EventStore option must be stored verbatim.'); + end + + function testEventStoreOptionInvalidThrows(testCase) + %TESTEVENTSTOREOPTIONINVALIDTHROWS + % Non-EventStore values raise FastSenseCompanion:invalidEventStore. + testCase.verifyError(@() FastSenseCompanion('EventStore', 42), ... + 'FastSenseCompanion:invalidEventStore'); + end + + function testEventStoreEmptyOptionAllowed(testCase) + %TESTEVENTSTOREEMPTYOPTIONALLOWED + % Empty value is accepted (means "no override; auto-discover"). + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + app = FastSenseCompanion('EventStore', []); + testCase.addTeardown(@() app.close()); + testCase.verifyEmpty(app.getEventStore()); + end + + function testEventStoreAutoDiscoveryUsedWhenNoOverride(testCase) + %TESTEVENTSTOREAUTODISCOVERYUSEDWHENNOOVERRIDE + % Without explicit option, the helper-discovered store is used. + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + + parent = SensorTag('p2', 'Name', 'P', 'Units', 'u', ... + 'X', [0 1 2], 'Y', [1 2 3]); + TagRegistry.register('p2', parent); + + storePath = [tempname() '.mat']; + es = EventStore(storePath); + testCase.addTeardown(@() delete(storePath)); + + mon = MonitorTag('m2', parent, @(x,y) y > 100, 'EventStore', es); + TagRegistry.register('m2', mon); + + app = FastSenseCompanion(); + testCase.addTeardown(@() app.close()); + testCase.verifySameHandle(app.getEventStore(), es); + end + + function testEventStoreOverrideBeatsAutoDiscovery(testCase) + %TESTEVENTSTOREOVERRIDEBEATSAUTODISCOVERY + % Explicit 'EventStore' wins over auto-discovery. + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + + parent = SensorTag('p3', 'Name', 'P', 'Units', 'u', ... + 'X', [0 1 2], 'Y', [1 2 3]); + TagRegistry.register('p3', parent); + + pathA = [tempname() '.mat']; + pathB = [tempname() '.mat']; + esA = EventStore(pathA); esB = EventStore(pathB); + testCase.addTeardown(@() delete(pathA)); + testCase.addTeardown(@() delete(pathB)); + + mon = MonitorTag('m3', parent, @(x,y) y > 100, 'EventStore', esA); + TagRegistry.register('m3', mon); + + app = FastSenseCompanion('EventStore', esB); + testCase.addTeardown(@() app.close()); + testCase.verifySameHandle(app.getEventStore(), esB); + end + end methods (Access = private) From 6afb89ba25602e8a8ea3a6c6764aea8cd9190feb Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 18:49:59 +0200 Subject: [PATCH 04/26] docs(companion): list EventStore + LivePeriod in class header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review polish on top of fb77912 — surface the new EventStore option and the long-standing LivePeriod option in the classdef help block + inline constructor comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index bf719697..24e7efd8 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -15,12 +15,15 @@ % Registry — TagRegistry instance (default: TagRegistry singleton) % Name — window title string (default: 'FastSense Companion') % Theme — 'dark' | 'light' (default: 'dark') +% LivePeriod — seconds between live refreshes (default: 1.0) +% EventStore — EventStore handle or [] (default: auto-discover from registry) % % Public methods: % setProject(dashboards, registry) — rebuild against new project % addDashboard(d) - append a DashboardEngine; refresh browser % removeDashboard(key) - remove by Name; reset inspector if it was selected % refreshCatalog() — re-snapshot tags and rebuild catalog +% getEventStore() — resolved EventStore handle or [] % close() — idempotent teardown % % Events fired: @@ -93,7 +96,7 @@ function obj = FastSenseCompanion(varargin) %FASTSENSECOMPANION Constructor. Opens a themed three-pane uifigure immediately. - % Name-value pairs: 'Dashboards', 'Registry', 'Name', 'Theme'. + % Name-value pairs: 'Dashboards', 'Registry', 'Name', 'Theme', 'LivePeriod', 'EventStore'. % Step 1 — Octave guard (FIRST, before any other work) if exist('OCTAVE_VERSION', 'builtin') ~= 0 From 4b7feb2448d4da54acbe06e1e59912e59f7939c4 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 18:57:58 +0200 Subject: [PATCH 05/26] feat(companion): fire LiveModeChanged on start/stopLiveMode Declare LiveModeChanged in the events block, notify after IsLive is mutated in startLiveMode and stopLiveMode, and add the helper class LiveModeCapture plus the testLiveModeChangedFiresOnStartAndStop test. Co-Authored-By: Claude Sonnet 4.6 --- libs/FastSenseCompanion/FastSenseCompanion.m | 4 +++ tests/suite/LiveModeCapture.m | 11 ++++++++ tests/suite/TestFastSenseCompanion.m | 27 ++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 tests/suite/LiveModeCapture.m diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 24e7efd8..e8f6d9ad 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -29,12 +29,14 @@ % Events fired: % InspectorStateChanged payload: InspectorStateEventData(state, payload) % OpenAdHocPlotRequested payload: AdHocPlotEventData(tagKeys, mode) — fired by InspectorPane +% LiveModeChanged no payload — fires on startLiveMode/stopLiveMode after IsLive is updated % % See also DashboardEngine, TagRegistry, CompanionTheme. events InspectorStateChanged OpenAdHocPlotRequested + LiveModeChanged end properties (Access = public) @@ -689,6 +691,7 @@ function startLiveMode(obj) obj.IsLive = true; obj.updateLiveButton_(); obj.addLogEntry('info', sprintf('Live mode ON (period %gs)', obj.LivePeriod_)); + notify(obj, 'LiveModeChanged'); catch err obj.addLogEntry('error', sprintf('Live start failed: %s', err.message)); end @@ -707,6 +710,7 @@ function stopLiveMode(obj) obj.IsLive = false; obj.updateLiveButton_(); obj.addLogEntry('info', 'Live mode OFF'); + notify(obj, 'LiveModeChanged'); end function toggleLiveMode(obj) diff --git a/tests/suite/LiveModeCapture.m b/tests/suite/LiveModeCapture.m new file mode 100644 index 00000000..848a5c82 --- /dev/null +++ b/tests/suite/LiveModeCapture.m @@ -0,0 +1,11 @@ +classdef LiveModeCapture < handle +%LIVEMODECAPTURE Tiny test helper — accumulates booleans into Vals. + properties + Vals = logical([]) + end + methods + function push(obj, v) + obj.Vals(end+1) = logical(v); + end + end +end diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index e0d05c4e..d021b9d4 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1097,6 +1097,33 @@ function testEventStoreOverrideBeatsAutoDiscovery(testCase) testCase.verifySameHandle(app.getEventStore(), esB); end + % ---- Task 3: LiveModeChanged event ---- + + function testLiveModeChangedFiresOnStartAndStop(testCase) + %TESTLIVEMODECHANGEDFIRESONSTARTANDSTOP + % Toggling live mode fires LiveModeChanged each time, and listeners + % observe the new IsLive value via the source object. + app = FastSenseCompanion(); + testCase.addTeardown(@() app.close()); + + % Companion launches with live mode ON; stop it for a clean baseline. + app.stopLiveMode(); + + captured = LiveModeCapture(); + L = addlistener(app, 'LiveModeChanged', @(s, ~) captured.push(s.IsLive)); + testCase.addTeardown(@() delete(L)); + + app.startLiveMode(); + app.stopLiveMode(); + + testCase.verifyTrue(numel(captured.Vals) >= 2, ... + 'LiveModeChanged must fire at least twice (start + stop).'); + testCase.verifyTrue(captured.Vals(end-1), ... + 'Penultimate fire must observe IsLive=true after startLiveMode.'); + testCase.verifyFalse(captured.Vals(end), ... + 'Last fire must observe IsLive=false after stopLiveMode.'); + end + end methods (Access = private) From f18a1763d299ac0be872c7f58c2b87abf615d423 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:03:54 +0200 Subject: [PATCH 06/26] feat(companion): EventGanttCanvas static helpers (rows, colors, open-event end) Co-Authored-By: Claude Sonnet 4.6 --- libs/FastSenseCompanion/EventGanttCanvas.m | 90 ++++++++++++++++++++++ tests/suite/TestEventGanttCanvas.m | 86 +++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 libs/FastSenseCompanion/EventGanttCanvas.m create mode 100644 tests/suite/TestEventGanttCanvas.m diff --git a/libs/FastSenseCompanion/EventGanttCanvas.m b/libs/FastSenseCompanion/EventGanttCanvas.m new file mode 100644 index 00000000..2e5755a0 --- /dev/null +++ b/libs/FastSenseCompanion/EventGanttCanvas.m @@ -0,0 +1,90 @@ +classdef EventGanttCanvas < handle +%EVENTGANTTCANVAS Gantt drawing + hit-testing helper for CompanionEventViewer. +% +% Constructor: canvas = EventGanttCanvas(hAxes, theme) +% Public: +% canvas.draw(events, theme) — redraw all bars (Task 5) +% canvas.OnSingleClick / OnDoubleClick — function handles (Task 5) +% Static: +% [map, keys] = EventGanttCanvas.computeRows(events) +% rgb = EventGanttCanvas.severityColor(sev) +% x = EventGanttCanvas.eventEndOrNow(ev, nowRef) +% +% See also CompanionEventViewer. + + properties (SetAccess = private) + hAxes % axes handle + Theme % CompanionTheme struct + BarHandles % rectangle/patch handles, Nx1 + BarEvents % Event objects mirrored to handles, Nx1 + end + + properties + OnSingleClick = [] + OnDoubleClick = [] + end + + methods + function obj = EventGanttCanvas(hAxes, theme) + %EVENTGANTTCANVAS Construct with a target axes and a CompanionTheme. + obj.hAxes = hAxes; + obj.Theme = theme; + obj.BarHandles = []; + obj.BarEvents = Event.empty; + end + end + + methods (Static) + function [map, keys] = computeRows(events) + %COMPUTEROWS Build row-index map from an array of Event objects. + % [map, keys] = EventGanttCanvas.computeRows(events) + % map - containers.Map: key (char) -> row index (double) + % keys - sorted column cellstr of unique row keys + map = containers.Map('KeyType', 'char', 'ValueType', 'double'); + if isempty(events) + keys = cell(0, 1); + return; + end + allKeys = {}; + for i = 1:numel(events) + ev = events(i); + if ~isempty(ev.TagKeys) + allKeys = [allKeys; ev.TagKeys(:)]; %#ok + else + allKeys = [allKeys; {ev.SensorName}]; %#ok + end + end + keys = unique(allKeys); % returns sorted column cellstr + for i = 1:numel(keys) + map(keys{i}) = i; + end + end + + function rgb = severityColor(sev) + %SEVERITYCOLOR Return an RGB triple for the given severity level. + % rgb = EventGanttCanvas.severityColor(sev) + % sev = 1 -> green (info/ok) + % sev = 2 -> orange (warn) + % sev = 3 -> red (alarm) + % otherwise -> grey fallback + switch double(sev) + case 1, rgb = [0.20 0.70 0.30]; % green (info/ok) + case 2, rgb = [0.95 0.60 0.10]; % orange (warn) + case 3, rgb = [0.85 0.20 0.20]; % red (alarm) + otherwise, rgb = [0.50 0.50 0.50]; % grey fallback + end + end + + function x = eventEndOrNow(ev, nowRef) + %EVENTENDORNOW Return the display end time for an event. + % x = EventGanttCanvas.eventEndOrNow(ev, nowRef) + % For closed events returns ev.EndTime; for open or NaN-ended + % events returns nowRef so the bar extends to the current time. + if ev.IsOpen || isnan(ev.EndTime) + x = nowRef; + else + x = ev.EndTime; + end + end + end +end diff --git a/tests/suite/TestEventGanttCanvas.m b/tests/suite/TestEventGanttCanvas.m new file mode 100644 index 00000000..b67075f0 --- /dev/null +++ b/tests/suite/TestEventGanttCanvas.m @@ -0,0 +1,86 @@ +classdef TestEventGanttCanvas < matlab.unittest.TestCase +%TESTEVENTGANTTCANVAS Unit tests for EventGanttCanvas pure helpers. + + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (TestMethodSetup) + function skipOnOctave(testCase) + testCase.assumeFalse( ... + exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestEventGanttCanvas: skipped on Octave (companion is MATLAB-only).'); + end + end + + methods (Test) + + function testComputeRowsEmpty(testCase) + [map, keys] = EventGanttCanvas.computeRows(Event.empty); + testCase.verifyEqual(map.Count, uint64(0)); + testCase.verifyEmpty(keys); + end + + function testComputeRowsAssignsRowsInSortedTagOrder(testCase) + ev1 = makeEvent_('b.tag', 1, 0, 1, 1); + ev2 = makeEvent_('a.tag', 1, 2, 3, 2); + ev3 = makeEvent_('b.tag', 1, 4, 5, 1); + [map, keys] = EventGanttCanvas.computeRows([ev1 ev2 ev3]); + testCase.verifyEqual(keys, {'a.tag'; 'b.tag'}); + testCase.verifyEqual(map('a.tag'), 1); + testCase.verifyEqual(map('b.tag'), 2); + end + + function testComputeRowsFallsBackToSensorNameWhenNoTagKeys(testCase) + ev = Event(0, 1, 'sensor.foo', 'lbl', 1, 'upper'); + ev.TagKeys = {}; + [map, keys] = EventGanttCanvas.computeRows(ev); + testCase.verifyEqual(keys, {'sensor.foo'}); + testCase.verifyEqual(map('sensor.foo'), 1); + end + + function testSeverityColorMapping(testCase) + % Severity 1 (info) -> green, 2 (warn) -> orange, 3 (alarm) -> red + c1 = EventGanttCanvas.severityColor(1); + c2 = EventGanttCanvas.severityColor(2); + c3 = EventGanttCanvas.severityColor(3); + testCase.verifyEqual(numel(c1), 3); + testCase.verifyTrue(c1(2) > c1(1) && c1(2) > c1(3), ... + 'sev=1 (info) must be green-dominant.'); + testCase.verifyTrue(c2(1) > 0.7 && c2(2) > 0.4 && c2(3) < 0.3, ... + 'sev=2 (warn) must be orange.'); + testCase.verifyTrue(c3(1) > c3(2) && c3(1) > c3(3), ... + 'sev=3 (alarm) must be red-dominant.'); + end + + function testSeverityColorClampsOutOfRange(testCase) + c = EventGanttCanvas.severityColor(99); + testCase.verifyEqual(numel(c), 3); + c2 = EventGanttCanvas.severityColor(0); + testCase.verifyEqual(numel(c2), 3); + end + + function testEventEndOrNowClosedReturnsEndTime(testCase) + ev = makeEvent_('t', 1, 5, 7, 2); + ev.IsOpen = false; + testCase.verifyEqual(EventGanttCanvas.eventEndOrNow(ev, 1000), 7); + end + + function testEventEndOrNowOpenReturnsNowReference(testCase) + ev = Event(5, NaN, 'sensor', 'lbl', 1, 'upper'); + ev.IsOpen = true; + testCase.verifyEqual(EventGanttCanvas.eventEndOrNow(ev, 1000), 1000); + end + end +end + +function ev = makeEvent_(tagKey, severity, startT, endT, sensorIdx) + sensorName = sprintf('sensor_%d', sensorIdx); + ev = Event(startT, endT, sensorName, 'lbl', 1, 'upper'); + ev.TagKeys = {tagKey}; + ev.Severity = severity; + ev.IsOpen = false; +end From eb13f2568e8edd4275e65729aad07c3ef0d96463 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:09:03 +0200 Subject: [PATCH 07/26] feat(companion): EventGanttCanvas.draw renders bars + open-event dashed edges Co-Authored-By: Claude Sonnet 4.6 --- libs/FastSenseCompanion/EventGanttCanvas.m | 106 +++++++++++++++++++++ tests/suite/TestEventGanttCanvas.m | 58 +++++++++++ 2 files changed, 164 insertions(+) diff --git a/libs/FastSenseCompanion/EventGanttCanvas.m b/libs/FastSenseCompanion/EventGanttCanvas.m index 2e5755a0..6dba0705 100644 --- a/libs/FastSenseCompanion/EventGanttCanvas.m +++ b/libs/FastSenseCompanion/EventGanttCanvas.m @@ -32,6 +32,112 @@ obj.BarHandles = []; obj.BarEvents = Event.empty; end + + function draw(obj, events, theme) + %DRAW Repaint the axes from scratch with the given event list + theme. + % Open events render with a dashed right edge extending to "now". + if nargin >= 3 && ~isempty(theme); obj.Theme = theme; end + + % Clear prior handles. + for i = 1:numel(obj.BarHandles) + if isgraphics(obj.BarHandles(i)); delete(obj.BarHandles(i)); end + end + obj.BarHandles = []; + obj.BarEvents = Event.empty; + + cla(obj.hAxes); + set(obj.hAxes, ... + 'Color', obj.Theme.WidgetBackground, ... + 'XColor', obj.Theme.ForegroundColor, ... + 'YColor', obj.Theme.ForegroundColor, ... + 'GridColor', obj.Theme.WidgetBorderColor); + hold(obj.hAxes, 'on'); + + if isempty(events) + set(obj.hAxes, 'YTick', [], 'YTickLabel', {}); + hold(obj.hAxes, 'off'); + return; + end + + [rowMap, keys] = EventGanttCanvas.computeRows(events); + barH = 0.6; + nowRef = now; % wall-clock reference for open events + + for i = 1:numel(events) + ev = events(i); + if ~isempty(ev.TagKeys) + rowKey = ev.TagKeys{1}; + else + rowKey = ev.SensorName; + end + if ~isKey(rowMap, rowKey); continue; end + y = rowMap(rowKey); + x0 = ev.StartTime; + x1 = EventGanttCanvas.eventEndOrNow(ev, nowRef); + rgb = EventGanttCanvas.severityColor(ev.Severity); + hRect = patch(obj.hAxes, ... + [x0 x1 x1 x0], [y-barH/2 y-barH/2 y+barH/2 y+barH/2], ... + rgb, ... + 'EdgeColor', 'none', ... + 'FaceAlpha', 0.85, ... + 'Tag', 'GanttBar', ... + 'UserData', i); + + if ev.IsOpen || isnan(ev.EndTime) + line(obj.hAxes, [x1 x1], [y-barH/2 y+barH/2], ... + 'Color', rgb, ... + 'LineStyle', '--', ... + 'LineWidth', 1.5, ... + 'Tag', 'OpenEdge', ... + 'UserData', i); + end + obj.BarHandles(end+1) = hRect; %#ok + obj.BarEvents(end+1) = ev; %#ok + end + + set(obj.hAxes, ... + 'YDir', 'reverse', ... + 'YTick', 1:numel(keys), ... + 'YTickLabel', keys, ... + 'YLim', [0.5, numel(keys) + 0.5]); + + hold(obj.hAxes, 'off'); + + % Wire bar click handler — single + double click distinguished + % via figure SelectionType in the callback. + for i = 1:numel(obj.BarHandles) + set(obj.BarHandles(i), 'ButtonDownFcn', @(src, ~) obj.onBarButtonDown_(src)); + end + end + + function delete(obj) + %DELETE Tear down handles. Theme/axes lifecycle owned by parent. + for i = 1:numel(obj.BarHandles) + if isgraphics(obj.BarHandles(i)); delete(obj.BarHandles(i)); end + end + obj.BarHandles = []; + obj.BarEvents = Event.empty; + end + end + + methods (Access = private) + function onBarButtonDown_(obj, src) + try + idx = get(src, 'UserData'); + if ~isnumeric(idx) || idx < 1 || idx > numel(obj.BarEvents); return; end + ev = obj.BarEvents(idx); + fig = ancestor(obj.hAxes, 'figure'); + selType = ''; + if isgraphics(fig); selType = get(fig, 'SelectionType'); end + if strcmp(selType, 'open') + if ~isempty(obj.OnDoubleClick); obj.OnDoubleClick(ev); end + else + if ~isempty(obj.OnSingleClick); obj.OnSingleClick(ev); end + end + catch + % Click handlers must never crash drawing. + end + end end methods (Static) diff --git a/tests/suite/TestEventGanttCanvas.m b/tests/suite/TestEventGanttCanvas.m index b67075f0..a7d3f2e2 100644 --- a/tests/suite/TestEventGanttCanvas.m +++ b/tests/suite/TestEventGanttCanvas.m @@ -74,6 +74,56 @@ function testEventEndOrNowOpenReturnsNowReference(testCase) ev.IsOpen = true; testCase.verifyEqual(EventGanttCanvas.eventEndOrNow(ev, 1000), 1000); end + + function testDrawCreatesOneRectanglePerEvent(testCase) + %TESTDRAWCREATESONERECTANGLEPEREVENT + % Each event becomes one rectangle handle. + f = figure('Visible', 'off'); + testCase.addTeardown(@() close(f, 'force')); + ax = axes('Parent', f); + canvas = EventGanttCanvas(ax, defaultTheme_()); + + ev1 = Event(0, 1, 'sA', 'lbl', 1, 'upper'); ev1.TagKeys = {'tA'}; ev1.Severity = 1; + ev2 = Event(2, 3, 'sB', 'lbl', 1, 'upper'); ev2.TagKeys = {'tB'}; ev2.Severity = 2; + canvas.draw([ev1 ev2], canvas.Theme); + + testCase.verifyEqual(numel(canvas.BarHandles), 2); + testCase.verifyEqual(numel(canvas.BarEvents), 2); + end + + function testDrawClearsPriorRenderOnSecondCall(testCase) + %TESTDRAWCLEARSPRIORRENDERONSECONDCALL + % Calling draw() twice doesn't accumulate handles — old ones deleted. + f = figure('Visible', 'off'); + testCase.addTeardown(@() close(f, 'force')); + ax = axes('Parent', f); + canvas = EventGanttCanvas(ax, defaultTheme_()); + + ev1 = Event(0, 1, 'sA', 'lbl', 1, 'upper'); ev1.TagKeys = {'tA'}; + ev2 = Event(2, 3, 'sA', 'lbl', 1, 'upper'); ev2.TagKeys = {'tA'}; + canvas.draw([ev1 ev2], canvas.Theme); + canvas.draw(ev1, canvas.Theme); + + testCase.verifyEqual(numel(canvas.BarHandles), 1, ... + 'Second draw must not accumulate handles.'); + end + + function testDrawDashedRightEdgeForOpenEvent(testCase) + %TESTDRAWDASHEDRIGHTEDGEFOROPENEVENT + % Open events draw an extra dashed line; verify >0 child line + % handles tagged 'OpenEdge'. + f = figure('Visible', 'off'); + testCase.addTeardown(@() close(f, 'force')); + ax = axes('Parent', f); + canvas = EventGanttCanvas(ax, defaultTheme_()); + + ev = Event(0, NaN, 'sA', 'lbl', 1, 'upper'); ev.TagKeys = {'tA'}; ev.IsOpen = true; + canvas.draw(ev, canvas.Theme); + + edges = findobj(ax, 'Tag', 'OpenEdge'); + testCase.verifyTrue(numel(edges) >= 1, ... + 'Open events must render at least one dashed edge handle.'); + end end end @@ -84,3 +134,11 @@ function testEventEndOrNowOpenReturnsNowReference(testCase) ev.Severity = severity; ev.IsOpen = false; end + +function t = defaultTheme_() + t = struct( ... + 'DashboardBackground', [1 1 1], ... + 'WidgetBackground', [1 1 1], ... + 'ForegroundColor', [0 0 0], ... + 'WidgetBorderColor', [0.7 0.7 0.7]); +end From 750b3a93776fc9d69ebb580a01465eb467a9b16f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:14:53 +0200 Subject: [PATCH 08/26] feat(companion): CompanionEventViewer figure shell + close lifecycle Creates the CompanionEventViewer classic-figure shell with three uipanels (filter 15%, axes 65%, slider 20%), EventGanttCanvas-hosted axes, idempotent close/bringToFront, and constructor validation for store/registry/companion. Adds 7 unit tests in TestCompanionEventViewer. Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 153 ++++++++++++++++++ tests/suite/TestCompanionEventViewer.m | 97 +++++++++++ 2 files changed, 250 insertions(+) create mode 100644 libs/FastSenseCompanion/CompanionEventViewer.m create mode 100644 tests/suite/TestCompanionEventViewer.m diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m new file mode 100644 index 00000000..247adc57 --- /dev/null +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -0,0 +1,153 @@ +classdef CompanionEventViewer < handle +%COMPANIONEVENTVIEWER Pop-out classic-figure viewer: tag-aware, time-filtered Gantt of EventStore events. +% +% v = CompanionEventViewer(store, registry, companion) +% store — EventStore handle (required) +% registry — TagRegistry handle/class (required, for tag-key search) +% companion — FastSenseCompanion handle (required, for theme + LiveModeChanged) +% +% Public: +% v.refresh() — pull from store, redraw Gantt (Task 9) +% v.setTimeRange(tStart, tEnd) — programmatic; sets mode to 'custom' (Task 8) +% v.setTagFilter(keysCell) — {} / '' means "all tags" (Task 8) +% v.bringToFront() — figure(hFigure) +% v.close() — idempotent teardown +% +% See also EventGanttCanvas, FastSenseCompanion. + + properties (SetAccess = private) + hFigure + SelectedTagKeys = {} + SeverityMask = [true true true] + OpenOnly = false + TimeRange = [0 1] + TimePresetMode = 'snapshot' % 'roll' | 'snapshot' | 'custom' + IsLive = false + end + + properties (Access = private) + Store_ = [] + Registry_ = [] + Companion_ = [] + Theme_ = [] + Canvas_ = [] + Selector_ = [] + FilterPanel_ = [] + AxesPanel_ = [] + SliderPanel_ = [] + AutoTimer_ = [] + AutoPeriod_ = 1.0 + AutoEnabled_ = true + Listeners_ = {} + end + + methods + function obj = CompanionEventViewer(store, registry, companion) + %COMPANIONEVENTVIEWER Construct the viewer window. + % store — EventStore handle (required) + % registry — TagRegistry handle/class (required) + % companion — FastSenseCompanion handle (required) + if isempty(store) || ~isa(store, 'EventStore') + error('CompanionEventViewer:invalidStore', ... + 'store must be an EventStore handle.'); + end + if isempty(registry) + error('CompanionEventViewer:invalidRegistry', ... + 'registry must be a TagRegistry handle.'); + end + if isempty(companion) || ~isa(companion, 'FastSenseCompanion') + error('CompanionEventViewer:invalidCompanion', ... + 'companion must be a FastSenseCompanion handle.'); + end + obj.Store_ = store; + obj.Registry_ = registry; + obj.Companion_ = companion; + obj.Theme_ = CompanionTheme.get(companion.Theme); + obj.IsLive = companion.IsLive; + obj.AutoPeriod_ = companion.LivePeriod; + + obj.buildFigure_(); + end + + function bringToFront(obj) + %BRINGTTOFRONT Raise the viewer figure. No-op if figure is gone. + if ~isempty(obj.hFigure) && isgraphics(obj.hFigure) + figure(obj.hFigure); + end + end + + function close(obj) + %CLOSE Idempotent teardown: timer, listeners, canvas, figure. + if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) + obj.hFigure = []; + return; + end + try + if ~isempty(obj.AutoTimer_) && isvalid(obj.AutoTimer_) + if strcmp(obj.AutoTimer_.Running, 'on'); stop(obj.AutoTimer_); end + delete(obj.AutoTimer_); + end + catch + end + obj.AutoTimer_ = []; + for i = 1:numel(obj.Listeners_) + try; delete(obj.Listeners_{i}); catch; end + end + obj.Listeners_ = {}; + try + if ~isempty(obj.Canvas_) && isvalid(obj.Canvas_) + delete(obj.Canvas_); + end + catch + end + obj.Canvas_ = []; + try + if ~isempty(obj.Selector_) && isvalid(obj.Selector_) + delete(obj.Selector_); + end + catch + end + obj.Selector_ = []; + try; delete(obj.hFigure); catch; end + obj.hFigure = []; + end + end + + methods (Access = private) + function buildFigure_(obj) + %BUILDFIGURE_ Create the classic figure with three uipanels + Gantt axes. + t = obj.Theme_; + obj.hFigure = figure( ... + 'Name', 'FastSense — Event Viewer', ... + 'NumberTitle', 'off', ... + 'Color', t.DashboardBackground, ... + 'Position', [120 120 1100 600], ... + 'CloseRequestFcn', @(~,~) obj.close(), ... + 'Visible', 'on'); + + obj.FilterPanel_ = uipanel('Parent', obj.hFigure, ... + 'Units', 'normalized', ... + 'Position', [0 0.85 1 0.15], ... + 'BackgroundColor', t.WidgetBackground, ... + 'BorderType', 'none'); + obj.AxesPanel_ = uipanel('Parent', obj.hFigure, ... + 'Units', 'normalized', ... + 'Position', [0 0.20 1 0.65], ... + 'BackgroundColor', t.WidgetBackground, ... + 'BorderType', 'none'); + obj.SliderPanel_ = uipanel('Parent', obj.hFigure, ... + 'Units', 'normalized', ... + 'Position', [0 0 1 0.20], ... + 'BackgroundColor', t.WidgetBackground, ... + 'BorderType', 'none'); + + ax = axes('Parent', obj.AxesPanel_, ... + 'Units', 'normalized', ... + 'Position', [0.10 0.10 0.85 0.85], ... + 'Color', t.WidgetBackground, ... + 'XColor', t.ForegroundColor, ... + 'YColor', t.ForegroundColor); + obj.Canvas_ = EventGanttCanvas(ax, t); + end + end +end diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m new file mode 100644 index 00000000..c83ec318 --- /dev/null +++ b/tests/suite/TestCompanionEventViewer.m @@ -0,0 +1,97 @@ +classdef TestCompanionEventViewer < matlab.unittest.TestCase +%TESTCOMPANIONEVENTVIEWER Class-based tests for CompanionEventViewer. +% See docs/superpowers/specs/2026-05-08-companion-event-viewer-design.md. + + methods (TestClassSetup) + function gateModernMatlab(testCase) + testCase.assumeTrue(~verLessThan('matlab', '9.10'), ... + 'Companion suite requires MATLAB R2021a+'); + end + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (TestMethodSetup) + function skipOnOctave(testCase) + testCase.assumeFalse( ... + exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestCompanionEventViewer: skipped on Octave (companion is MATLAB-only).'); + end + end + + methods (Test) + function testConstructorRequiresEventStore(testCase) + testCase.verifyError( ... + @() CompanionEventViewer([], TagRegistry, makeFakeCompanion_()), ... + 'CompanionEventViewer:invalidStore'); + end + + function testConstructorRequiresRegistry(testCase) + es = makeStore_(testCase); + testCase.verifyError( ... + @() CompanionEventViewer(es, [], makeFakeCompanion_()), ... + 'CompanionEventViewer:invalidRegistry'); + end + + function testConstructorRequiresCompanion(testCase) + es = makeStore_(testCase); + testCase.verifyError( ... + @() CompanionEventViewer(es, TagRegistry, []), ... + 'CompanionEventViewer:invalidCompanion'); + end + + function testConstructorOpensFigure(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + testCase.verifyTrue(isgraphics(v.hFigure)); + end + + function testCloseIsIdempotent(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + v.close(); + testCase.verifyWarningFree(@() v.close(), ... + 'close() must be idempotent.'); + end + + function testCloseDeletesFigure(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + f = v.hFigure; + v.close(); + testCase.verifyFalse(isgraphics(f), 'figure must be destroyed.'); + end + + function testBringToFrontIdempotent(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + testCase.verifyWarningFree(@() v.bringToFront()); + testCase.verifyTrue(isgraphics(v.hFigure)); + end + end +end + +% --- File-local helpers (after the classdef end) ---------------------- +function es = makeStore_(testCase) + storePath = [tempname() '.mat']; + es = EventStore(storePath); + testCase.addTeardown(@() delete(storePath)); +end + +function comp = makeFakeCompanion_() + % Minimal stub for typecheck — real companion needed for listener wiring tests later. + comp = struct('IsLive', false, 'LivePeriod', 1.0); +end + +function comp = makeRealCompanion_(testCase) + comp = FastSenseCompanion(); + testCase.addTeardown(@() comp.close()); +end From 88d84d092a263105dc26a2f18c3b3c5bfcd95c12 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:18:39 +0200 Subject: [PATCH 09/26] feat(companion): CompanionEventViewer.applyFilters pure filter pipeline Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 41 ++++++++++++++++ tests/suite/TestCompanionEventViewer.m | 49 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 247adc57..f504cdd1 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -113,6 +113,47 @@ function close(obj) end end + methods (Static) + function out = applyFilters(events, tagKeys, sevMask, openOnly, timeRange) + %APPLYFILTERS Pure filter pipeline. Inputs: + % events — Event row vector + % tagKeys — cellstr, {} means "all" + % sevMask — 1x3 logical [info warn alarm] + % openOnly — logical scalar + % timeRange — 1x2 [tStart tEnd]; tEnd=Inf is acceptable + % + % Open events (IsOpen=true) treat EndTime as Inf for overlap. + if isempty(events) + out = Event.empty; return; + end + keep = true(1, numel(events)); + nowRef = now; + for i = 1:numel(events) + ev = events(i); + if ~isempty(tagKeys) + if ~any(ismember(ev.TagKeys, tagKeys)) + keep(i) = false; continue; + end + end + sev = double(ev.Severity); + if sev < 1 || sev > numel(sevMask) || ~sevMask(sev) + keep(i) = false; continue; + end + if openOnly && ~ev.IsOpen + keep(i) = false; continue; + end + evEnd = ev.EndTime; + if isnan(evEnd) || ev.IsOpen + evEnd = max(nowRef, ev.StartTime); + end + if evEnd < timeRange(1) || ev.StartTime > timeRange(2) + keep(i) = false; continue; + end + end + out = events(keep); + end + end + methods (Access = private) function buildFigure_(obj) %BUILDFIGURE_ Create the classic figure with three uipanels + Gantt axes. diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index c83ec318..21780294 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -76,6 +76,47 @@ function testBringToFrontIdempotent(testCase) testCase.verifyWarningFree(@() v.bringToFront()); testCase.verifyTrue(isgraphics(v.hFigure)); end + + % --- Task 7: applyFilters tests --- + + function testFilterEmptyTagKeysMeansAll(testCase) + evs = makeEvents_(); + out = CompanionEventViewer.applyFilters(evs, {}, [true true true], false, [-Inf Inf]); + testCase.verifyEqual(numel(out), numel(evs)); + end + + function testFilterByTagKeys(testCase) + evs = makeEvents_(); + out = CompanionEventViewer.applyFilters(evs, {'tA'}, [true true true], false, [-Inf Inf]); + testCase.verifyTrue(all(arrayfun(@(e) any(strcmp(e.TagKeys, 'tA')), out))); + end + + function testFilterBySeverity(testCase) + evs = makeEvents_(); % evs has severities 1, 2, 3 + out = CompanionEventViewer.applyFilters(evs, {}, [false true false], false, [-Inf Inf]); + testCase.verifyTrue(all(arrayfun(@(e) e.Severity == 2, out))); + end + + function testFilterOpenOnly(testCase) + evs = makeEvents_(); % evs(end) has IsOpen=true + out = CompanionEventViewer.applyFilters(evs, {}, [true true true], true, [-Inf Inf]); + testCase.verifyTrue(all(arrayfun(@(e) e.IsOpen, out))); + testCase.verifyTrue(numel(out) >= 1); + end + + function testFilterByTimeRange(testCase) + evs = makeEvents_(); % evs(1)=[0,1], evs(2)=[10,11], evs(3)=[20,21], evs(4) open at [30,NaN] + out = CompanionEventViewer.applyFilters(evs, {}, [true true true], false, [9 12]); + testCase.verifyEqual(numel(out), 1, 'only the [10,11] event overlaps [9,12].'); + testCase.verifyEqual(out(1).StartTime, 10); + end + + function testFilterTimeRangeIncludesOpenEvents(testCase) + evs = makeEvents_(); + out = CompanionEventViewer.applyFilters(evs, {}, [true true true], false, [29 99]); + testCase.verifyTrue(any(arrayfun(@(e) e.IsOpen, out)), ... + 'Open event with EndTime=NaN must overlap any range that starts after its StartTime.'); + end end end @@ -95,3 +136,11 @@ function testBringToFrontIdempotent(testCase) comp = FastSenseCompanion(); testCase.addTeardown(@() comp.close()); end + +function evs = makeEvents_() + e1 = Event(0, 1, 'sA', 'lbl', 1, 'upper'); e1.TagKeys = {'tA'}; e1.Severity = 1; + e2 = Event(10, 11, 'sB', 'lbl', 1, 'upper'); e2.TagKeys = {'tB'}; e2.Severity = 2; + e3 = Event(20, 21, 'sC', 'lbl', 1, 'upper'); e3.TagKeys = {'tA'}; e3.Severity = 3; + e4 = Event(30, NaN, 'sD', 'lbl', 1, 'upper'); e4.TagKeys = {'tD'}; e4.IsOpen = true; e4.Severity = 2; + evs = [e1 e2 e3 e4]; +end From d5d8513e0cbe039f9d661978f699a4fe4dc416bb Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:20:25 +0200 Subject: [PATCH 10/26] feat(companion): viewer preset + setTimeRange logic with roll/snapshot/custom Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 70 +++++++++++++++++++ tests/suite/TestCompanionEventViewer.m | 43 ++++++++++++ 2 files changed, 113 insertions(+) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index f504cdd1..0cadb10e 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -76,6 +76,43 @@ function bringToFront(obj) end end + function setTimeRange(obj, tStart, tEnd) + %SETTIMERANGE Set an explicit time range; switches mode to 'custom'. + % setTimeRange(tStart, tEnd) — both numeric scalars, tEnd > tStart. + if ~isnumeric(tStart) || ~isnumeric(tEnd) || ~isscalar(tStart) || ~isscalar(tEnd) + error('CompanionEventViewer:invalidTimeRange', ... + 'setTimeRange requires two numeric scalars.'); + end + if ~(tEnd > tStart) + error('CompanionEventViewer:invalidTimeRange', ... + 'setTimeRange requires tEnd > tStart (got [%g %g]).', tStart, tEnd); + end + obj.TimeRange = [tStart tEnd]; + obj.TimePresetMode = 'custom'; + end + + function setTagFilter(obj, keysCell) + %SETTAGFILTER Set the tag key filter. {} / '' means "all tags". + if isempty(keysCell) + obj.SelectedTagKeys = {}; + return; + end + if ~iscellstr(keysCell) %#ok + if ischar(keysCell) + keysCell = {keysCell}; + else + error('CompanionEventViewer:invalidTagFilter', ... + 'setTagFilter requires cellstr or char.'); + end + end + obj.SelectedTagKeys = keysCell(:)'; + end + + function applyPreset_internalForTest(obj, name) + %APPLYPRESET_INTERNALFORTEST Test-only proxy for the preset handler. + obj.applyPreset_(name); + end + function close(obj) %CLOSE Idempotent teardown: timer, listeners, canvas, figure. if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) @@ -155,6 +192,39 @@ function close(obj) end methods (Access = private) + function applyPreset_(obj, name) + %APPLYPRESET_ Set TimeRange + TimePresetMode for a named preset. + % Presets: '1h', '24h', '7d', 'all'. + % 'all' resolves min-start to max-end across the store events. + switch name + case '1h', span = 1/24; + case '24h', span = 1; + case '7d', span = 7; + case 'all', span = []; % full extent — resolved below + otherwise + error('CompanionEventViewer:unknownPreset', ... + 'Unknown preset ''%s''.', name); + end + if isempty(span) + evs = obj.Store_.getEvents(); + if isempty(evs) + obj.TimeRange = [now-1, now]; + else + starts = arrayfun(@(e) e.StartTime, evs); + nowRef = now; + ends = arrayfun(@(e) EventGanttCanvas.eventEndOrNow(e, nowRef), evs); + obj.TimeRange = [min(starts), max(nowRef, max(ends))]; + end + else + obj.TimeRange = [now - span, now]; + end + if obj.IsLive + obj.TimePresetMode = 'roll'; + else + obj.TimePresetMode = 'snapshot'; + end + end + function buildFigure_(obj) %BUILDFIGURE_ Create the classic figure with three uipanels + Gantt axes. t = obj.Theme_; diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index 21780294..eae74404 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -117,6 +117,49 @@ function testFilterTimeRangeIncludesOpenEvents(testCase) testCase.verifyTrue(any(arrayfun(@(e) e.IsOpen, out)), ... 'Open event with EndTime=NaN must overlap any range that starts after its StartTime.'); end + + % --- Task 8: preset + setTimeRange tests --- + + function testApplyPresetSnapshotWhenNotLive(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + comp.stopLiveMode(); % ensure not live + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.applyPreset_internalForTest('1h'); + testCase.verifyEqual(v.TimePresetMode, 'snapshot'); + testCase.verifyEqual(v.TimeRange(2) - v.TimeRange(1), 1/24, 'AbsTol', 1e-6); + end + + function testApplyPresetRollWhenLive(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + if ~comp.IsLive; comp.startLiveMode(); end + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.applyPreset_internalForTest('24h'); + testCase.verifyEqual(v.TimePresetMode, 'roll'); + testCase.verifyEqual(v.TimeRange(2) - v.TimeRange(1), 1, 'AbsTol', 1e-6); + end + + function testSetTimeRangeSwitchesModeToCustom(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.setTimeRange(100, 200); + testCase.verifyEqual(v.TimePresetMode, 'custom'); + testCase.verifyEqual(v.TimeRange, [100 200]); + end + + function testSetTimeRangeRejectsInverted(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + testCase.verifyError(@() v.setTimeRange(5, 5), 'CompanionEventViewer:invalidTimeRange'); + testCase.verifyError(@() v.setTimeRange(10, 5), 'CompanionEventViewer:invalidTimeRange'); + end end end From f701c574b1f1b9d91eb85ce9db60926b5ab09a5c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:25:01 +0200 Subject: [PATCH 11/26] feat(companion): viewer refresh() pulls from store, applies filter, draws Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 15 ++++++++ tests/suite/TestCompanionEventViewer.m | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 0cadb10e..4cd9747a 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -113,6 +113,21 @@ function applyPreset_internalForTest(obj, name) obj.applyPreset_(name); end + function refresh(obj) + %REFRESH Pull from store, apply filters, redraw Gantt. No-op if figure gone. + if isempty(obj.hFigure) || ~isgraphics(obj.hFigure); return; end + evs = obj.Store_.getEvents(); + if isempty(evs); evs = Event.empty; end + filtered = CompanionEventViewer.applyFilters( ... + evs, obj.SelectedTagKeys, obj.SeverityMask, obj.OpenOnly, obj.TimeRange); + obj.Canvas_.draw(filtered, obj.Theme_); + end + + function c = getCanvasForTest_(obj) + %GETCANVASFORTEST_ Test-only accessor for the canvas helper. + c = obj.Canvas_; + end + function close(obj) %CLOSE Idempotent teardown: timer, listeners, canvas, figure. if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index eae74404..c29616ea 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -160,6 +160,40 @@ function testSetTimeRangeRejectsInverted(testCase) testCase.verifyError(@() v.setTimeRange(5, 5), 'CompanionEventViewer:invalidTimeRange'); testCase.verifyError(@() v.setTimeRange(10, 5), 'CompanionEventViewer:invalidTimeRange'); end + + % --- Task 9: refresh() tests --- + + function testRefreshDrawsBarsForStoreEvents(testCase) + es = makeStore_(testCase); + e1 = Event(0, 1, 'sA', 'lbl', 1, 'upper'); e1.TagKeys = {'tA'}; e1.Severity = 1; + e2 = Event(10, 11, 'sB', 'lbl', 1, 'upper'); e2.TagKeys = {'tB'}; e2.Severity = 2; + es.append([e1 e2]); + + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.setTimeRange(-1, 100); % wide window + v.refresh(); + + canvas = v.getCanvasForTest_(); + testCase.verifyEqual(numel(canvas.BarHandles), 2); + end + + function testRefreshPicksUpAppendedEvents(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.setTimeRange(-1, 100); + v.refresh(); + canvas = v.getCanvasForTest_(); + testCase.verifyEqual(numel(canvas.BarHandles), 0); + + e1 = Event(0, 1, 'sA', 'lbl', 1, 'upper'); e1.TagKeys = {'tA'}; + es.append(e1); + v.refresh(); + testCase.verifyEqual(numel(canvas.BarHandles), 1); + end end end From 11507ba0aaf810e1c7c55ca977b701329ceae79d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:33:42 +0200 Subject: [PATCH 12/26] feat(companion): viewer LiveModeChanged listener + auto-refresh timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire CompanionEventViewer to FastSenseCompanion's LiveModeChanged event: starts/stops an auto-refresh timer on live-mode transitions, advances the TimeRange window each tick when in 'roll' mode, and demotes 'roll' → 'snapshot' when live mode is turned off. AutoEnabled_ flag gates timer creation for future Task 11 checkbox control. Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 77 +++++++++++++++++++ tests/suite/TestCompanionEventViewer.m | 47 +++++++++++ 2 files changed, 124 insertions(+) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 4cd9747a..7847ca60 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -128,6 +128,17 @@ function refresh(obj) c = obj.Canvas_; end + function tf = isAutoTimerRunning_(obj) + %ISAUTOTIMERRUNNING_ Test accessor: true if the auto-refresh timer is running. + tf = ~isempty(obj.AutoTimer_) && isvalid(obj.AutoTimer_) && ... + strcmp(obj.AutoTimer_.Running, 'on'); + end + + function t = getAutoTimerForTest_(obj) + %GETAUTOTIMERFORTEST_ Test accessor: return AutoTimer_ handle (may be []). + t = obj.AutoTimer_; + end + function close(obj) %CLOSE Idempotent teardown: timer, listeners, canvas, figure. if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) @@ -274,6 +285,72 @@ function buildFigure_(obj) 'XColor', t.ForegroundColor, ... 'YColor', t.ForegroundColor); obj.Canvas_ = EventGanttCanvas(ax, t); + + % Live-mode coupling. + obj.Listeners_{end+1} = addlistener(obj.Companion_, 'LiveModeChanged', ... + @(s, ~) obj.onCompanionLiveChanged_(s.IsLive)); + obj.onCompanionLiveChanged_(obj.Companion_.IsLive); % initial sync + end + + function onCompanionLiveChanged_(obj, isLive) + %ONCOMPANIONLIVECHANGED_ React to companion LiveModeChanged event. + obj.IsLive = logical(isLive); + if obj.IsLive && obj.AutoEnabled_ + obj.startAutoTimer_(); + if strcmp(obj.TimePresetMode, 'snapshot') + obj.TimePresetMode = 'roll'; + end + else + obj.stopAutoTimer_(); + if strcmp(obj.TimePresetMode, 'roll') + obj.TimePresetMode = 'snapshot'; + end + end + end + + function startAutoTimer_(obj) + %STARTAUTOTIMER_ Create and start the auto-refresh timer if not already running. + try + if isempty(obj.AutoTimer_) || ~isvalid(obj.AutoTimer_) + obj.AutoTimer_ = timer( ... + 'ExecutionMode', 'fixedRate', ... + 'Period', obj.AutoPeriod_, ... + 'BusyMode', 'drop', ... + 'TimerFcn', @(~,~) obj.onAutoTick_(), ... + 'ErrorFcn', @(~,~) []); + end + if strcmp(obj.AutoTimer_.Running, 'off') + start(obj.AutoTimer_); + end + catch + % Auto-refresh failure must never crash the viewer. + end + end + + function stopAutoTimer_(obj) + %STOPAUTOTIMER_ Stop the auto-refresh timer if running. + try + if ~isempty(obj.AutoTimer_) && isvalid(obj.AutoTimer_) && ... + strcmp(obj.AutoTimer_.Running, 'on') + stop(obj.AutoTimer_); + end + catch + end + end + + function onAutoTick_(obj) + %ONAUTOTICK_ Timer callback: advance window if rolling, then refresh. + try + if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) + obj.stopAutoTimer_(); return; + end + if strcmp(obj.TimePresetMode, 'roll') + span = obj.TimeRange(2) - obj.TimeRange(1); + obj.TimeRange = [now - span, now]; + end + obj.refresh(); + catch + end end end end diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index c29616ea..40e44690 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -194,6 +194,53 @@ function testRefreshPicksUpAppendedEvents(testCase) v.refresh(); testCase.verifyEqual(numel(canvas.BarHandles), 1); end + + % --- Task 10: live-mode coupling + auto-refresh timer tests --- + + function testViewerStartsTimerWhenCompanionGoesLive(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + comp.stopLiveMode(); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + testCase.verifyFalse(v.isAutoTimerRunning_(), 'Initially: not running.'); + + comp.startLiveMode(); + testCase.verifyTrue(v.isAutoTimerRunning_(), 'Live ON must start timer.'); + + comp.stopLiveMode(); + testCase.verifyFalse(v.isAutoTimerRunning_(), 'Live OFF must stop timer.'); + end + + function testViewerSnapshotPresetWhenCompanionLiveOff(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + if ~comp.IsLive; comp.startLiveMode(); end + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.applyPreset_internalForTest('1h'); + testCase.verifyEqual(v.TimePresetMode, 'roll'); + + comp.stopLiveMode(); + testCase.verifyEqual(v.TimePresetMode, 'snapshot', ... + 'Companion live OFF must demote roll → snapshot.'); + end + + function testCloseRemovesLiveListenerAndTimer(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + if ~comp.IsLive; comp.startLiveMode(); end + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.verifyTrue(v.isAutoTimerRunning_()); + v.close(); + + % After close, toggling companion must not error or re-create state. + comp.stopLiveMode(); + comp.startLiveMode(); + t = v.getAutoTimerForTest_(); + testCase.verifyFalse(~isempty(t) && isvalid(t) && strcmp(t.Running, 'on'), ... + 'Closed viewer must not re-arm its timer.'); + end end end From 50c43c0c530cd3e329ac01872942f40d1f2fa5ac Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:42:20 +0200 Subject: [PATCH 13/26] feat(companion): viewer filter bar + TimeRangeSelector slider Implements Task 11: populates the FilterPanel_ with preset buttons (1h/24h/7d/All), From/To datetime edits, severity toggles, open-only checkbox, tag search, and refresh/auto controls; instantiates TimeRangeSelector in SliderPanel_; adds onSliderRangeChanged_ and other private callbacks; adds getSliderForTest_ and onSliderRangeChanged_internalForTest test accessors; applyPreset_ now calls refresh() at the end; applyPreset_ handles both 'all' and 'All' case variants. 26/26 viewer tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 175 +++++++++++++++++- tests/suite/TestCompanionEventViewer.m | 45 +++++ 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 7847ca60..f33aa60e 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -139,6 +139,16 @@ function refresh(obj) t = obj.AutoTimer_; end + function s = getSliderForTest_(obj) + %GETSLIDERFORTEST_ Test accessor: return Selector_ handle. + s = obj.Selector_; + end + + function onSliderRangeChanged_internalForTest(obj, t1, t2) + %ONSLIDERRANGECHANGED_INTERNALFORTEST Test-only proxy for the slider callback. + obj.onSliderRangeChanged_(t1, t2); + end + function close(obj) %CLOSE Idempotent teardown: timer, listeners, canvas, figure. if isempty(obj.hFigure) || ~isgraphics(obj.hFigure) @@ -223,10 +233,10 @@ function applyPreset_(obj, name) % Presets: '1h', '24h', '7d', 'all'. % 'all' resolves min-start to max-end across the store events. switch name - case '1h', span = 1/24; - case '24h', span = 1; - case '7d', span = 7; - case 'all', span = []; % full extent — resolved below + case '1h', span = 1/24; + case '24h', span = 1; + case '7d', span = 7; + case {'all', 'All'}, span = []; % full extent — resolved below otherwise error('CompanionEventViewer:unknownPreset', ... 'Unknown preset ''%s''.', name); @@ -249,6 +259,7 @@ function applyPreset_(obj, name) else obj.TimePresetMode = 'snapshot'; end + obj.refresh(); end function buildFigure_(obj) @@ -286,6 +297,85 @@ function buildFigure_(obj) 'YColor', t.ForegroundColor); obj.Canvas_ = EventGanttCanvas(ax, t); + % --- Filter bar contents ----------------------------------- + % Preset buttons row. + presets = {'1h', '24h', '7d', 'All'}; + for i = 1:numel(presets) + uicontrol('Parent', obj.FilterPanel_, ... + 'Style', 'pushbutton', 'String', presets{i}, ... + 'Tag', 'PresetBtn', ... + 'Units', 'normalized', ... + 'Position', [0.02 + (i-1)*0.05, 0.55, 0.045, 0.35], ... + 'BackgroundColor', t.WidgetBorderColor, ... + 'ForegroundColor', t.ForegroundColor, ... + 'Callback', @(src, ~) obj.applyPreset_(get(src, 'String'))); + end + + % From / To datetime edits. + uicontrol('Parent', obj.FilterPanel_, 'Style', 'text', 'String', 'From:', ... + 'Units', 'normalized', 'Position', [0.25 0.55 0.04 0.35], ... + 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'HorizontalAlignment', 'right'); + uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'FromEdit', ... + 'Units', 'normalized', 'Position', [0.30 0.55 0.10 0.35], ... + 'String', '', ... + 'Callback', @(src, ~) obj.onFromToEdited_()); + uicontrol('Parent', obj.FilterPanel_, 'Style', 'text', 'String', 'To:', ... + 'Units', 'normalized', 'Position', [0.40 0.55 0.03 0.35], ... + 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'HorizontalAlignment', 'right'); + uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'ToEdit', ... + 'Units', 'normalized', 'Position', [0.43 0.55 0.10 0.35], ... + 'String', '', ... + 'Callback', @(src, ~) obj.onFromToEdited_()); + + % Severity toggles. + sevLabels = {'I', 'W', 'A'}; + for i = 1:3 + uicontrol('Parent', obj.FilterPanel_, 'Style', 'togglebutton', ... + 'String', sevLabels{i}, 'Tag', sprintf('SevBtn%d', i), ... + 'Value', 1, ... + 'Units', 'normalized', ... + 'Position', [0.55 + (i-1)*0.03, 0.55, 0.025, 0.35], ... + 'BackgroundColor', t.WidgetBorderColor, ... + 'ForegroundColor', t.ForegroundColor, ... + 'Callback', @(src, ~) obj.onSevToggled_(i, get(src, 'Value'))); + end + + % Open-only checkbox. + uicontrol('Parent', obj.FilterPanel_, 'Style', 'checkbox', ... + 'String', 'Open only', 'Tag', 'OpenOnlyChk', ... + 'Value', 0, ... + 'Units', 'normalized', 'Position', [0.65 0.55 0.07 0.35], ... + 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'Callback', @(src, ~) obj.setOpenOnly_(get(src, 'Value') == 1)); + + % Tag search. + uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', ... + 'Tag', 'TagSearch', 'String', '', ... + 'Units', 'normalized', 'Position', [0.02 0.10 0.20 0.35], ... + 'TooltipString', 'Tag search…', ... + 'Callback', @(src, ~) obj.onTagSearchChanged_(get(src, 'String'))); + + % Refresh + Auto + interval. + uicontrol('Parent', obj.FilterPanel_, 'Style', 'pushbutton', 'String', 'Refresh', ... + 'Units', 'normalized', 'Position', [0.74 0.55 0.07 0.35], ... + 'Callback', @(~, ~) obj.refresh()); + uicontrol('Parent', obj.FilterPanel_, 'Style', 'checkbox', 'String', 'Auto', ... + 'Tag', 'AutoChk', 'Value', 1, ... + 'Units', 'normalized', 'Position', [0.82 0.55 0.05 0.35], ... + 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'Callback', @(src, ~) obj.setAutoEnabled_(get(src, 'Value') == 1)); + uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'IntervalEdit', ... + 'String', sprintf('%g', obj.AutoPeriod_), ... + 'Units', 'normalized', 'Position', [0.87 0.55 0.04 0.35], ... + 'Callback', @(src, ~) obj.onIntervalEdited_(get(src, 'String'))); + + % --- Slider in bottom panel -------------------------------- + obj.Selector_ = TimeRangeSelector(obj.SliderPanel_, ... + 'OnRangeChanged', @(t1, t2) obj.onSliderRangeChanged_(t1, t2), ... + 'Theme', t); + % Live-mode coupling. obj.Listeners_{end+1} = addlistener(obj.Companion_, 'LiveModeChanged', ... @(s, ~) obj.onCompanionLiveChanged_(s.IsLive)); @@ -352,5 +442,82 @@ function onAutoTick_(obj) catch end end + + function onSliderRangeChanged_(obj, t1, t2) + %ONSLIDERRANGECHANGED_ React to slider drag: set custom time range. + if t2 <= t1; return; end + obj.TimeRange = [t1 t2]; + obj.TimePresetMode = 'custom'; + obj.refresh(); + end + + function onFromToEdited_(obj) + %ONFROMTOEDITED_ Parse From/To edit fields and apply as custom range. + fromCtl = findall(obj.hFigure, 'Tag', 'FromEdit'); + toCtl = findall(obj.hFigure, 'Tag', 'ToEdit'); + sFrom = strtrim(get(fromCtl, 'String')); + sTo = strtrim(get(toCtl, 'String')); + if isempty(sFrom) || isempty(sTo); return; end + try + t1 = datenum(sFrom); + t2 = datenum(sTo); + obj.setTimeRange(t1, t2); + obj.refresh(); + catch + % Bad input — ignore silently; user can correct it. + end + end + + function onSevToggled_(obj, idx, val) + %ONSEVTOGGLED_ React to severity toggle button press. + obj.SeverityMask(idx) = (val == 1); + obj.refresh(); + end + + function setOpenOnly_(obj, tf) + %SETOPENONLY_ Set open-only filter flag and refresh. + obj.OpenOnly = logical(tf); + obj.refresh(); + end + + function onTagSearchChanged_(obj, txt) + %ONTAGSEARCHCHANGED_ Filter by tag keys matching search text. + txt = strtrim(txt); + if isempty(txt) + obj.SelectedTagKeys = {}; + else + allKeys = TagRegistry.keys(); + if isempty(allKeys) + obj.SelectedTagKeys = {}; + else + hit = allKeys(contains(allKeys, txt)); + obj.SelectedTagKeys = hit(:)'; + end + end + obj.refresh(); + end + + function setAutoEnabled_(obj, tf) + %SETAUTOENABLED_ Enable or disable the auto-refresh timer. + obj.AutoEnabled_ = logical(tf); + if obj.AutoEnabled_ && obj.IsLive + obj.startAutoTimer_(); + else + obj.stopAutoTimer_(); + end + end + + function onIntervalEdited_(obj, txt) + %ONINTERVALEDITED_ Update auto-refresh period from edit field. + v = str2double(strtrim(txt)); + if ~isfinite(v) || v <= 0; return; end + obj.AutoPeriod_ = v; + if ~isempty(obj.AutoTimer_) && isvalid(obj.AutoTimer_) + wasOn = strcmp(obj.AutoTimer_.Running, 'on'); + if wasOn; stop(obj.AutoTimer_); end + obj.AutoTimer_.Period = v; + if wasOn; start(obj.AutoTimer_); end + end + end end end diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index 40e44690..23bd8240 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -241,6 +241,51 @@ function testCloseRemovesLiveListenerAndTimer(testCase) testCase.verifyFalse(~isempty(t) && isvalid(t) && strcmp(t.Running, 'on'), ... 'Closed viewer must not re-arm its timer.'); end + + % --- Task 11: filter bar UI + TimeRangeSelector slider tests --- + + function testPresetButtonsExist(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + btns = findall(v.hFigure, 'Style', 'pushbutton', 'Tag', 'PresetBtn'); + presetTexts = arrayfun(@(b) get(b, 'String'), btns, 'UniformOutput', false); + testCase.verifyEqual(sort(presetTexts), {'1h'; '24h'; '7d'; 'All'}); + end + + function testFromToDateTimePickersExist(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + fromCtl = findall(v.hFigure, 'Tag', 'FromEdit'); + toCtl = findall(v.hFigure, 'Tag', 'ToEdit'); + testCase.verifyNotEmpty(fromCtl); + testCase.verifyNotEmpty(toCtl); + end + + function testSliderInstantiated(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + testCase.verifyClass(v.getSliderForTest_(), 'TimeRangeSelector'); + end + + function testSliderRangeChangedSetsCustomMode(testCase) + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + comp.stopLiveMode(); % ensure not live so preset yields 'snapshot' + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.applyPreset_internalForTest('1h'); + testCase.verifyEqual(v.TimePresetMode, 'snapshot'); + + % Simulate slider drag via the public callback path. + v.onSliderRangeChanged_internalForTest(now - 0.5, now); + testCase.verifyEqual(v.TimePresetMode, 'custom'); + end end end From 7ffcbff5c5b049cc5d48147b145df48c02322c8c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 19:49:28 +0200 Subject: [PATCH 14/26] feat(companion): viewer click handlers (details popup + SensorDetailPlot drill-down) Co-Authored-By: Claude Sonnet 4.6 --- .../FastSenseCompanion/CompanionEventViewer.m | 92 +++++++++++++++++++ tests/suite/TestCompanionEventViewer.m | 40 ++++++++ 2 files changed, 132 insertions(+) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index f33aa60e..1471cecc 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -128,6 +128,30 @@ function refresh(obj) c = obj.Canvas_; end + function setSingleClickHandlerForTest_(obj, fn) + %SETSINGLECLICKHANDLERFORTEST_ Override OnSingleClick for testing. + obj.Canvas_.OnSingleClick = fn; + end + + function setDoubleClickHandlerForTest_(obj, fn) + %SETDOUBLECLICKHANDLERFORTEST_ Override OnDoubleClick for testing. + obj.Canvas_.OnDoubleClick = fn; + end + + function fireBarClickForTest_(obj, idx, selType) + %FIREBARCLICKFORTEST_ Simulate a bar click without GUI. + ev = obj.Canvas_.BarEvents(idx); + if strcmp(selType, 'open') + if ~isempty(obj.Canvas_.OnDoubleClick) + obj.Canvas_.OnDoubleClick(ev); + end + else + if ~isempty(obj.Canvas_.OnSingleClick) + obj.Canvas_.OnSingleClick(ev); + end + end + end + function tf = isAutoTimerRunning_(obj) %ISAUTOTIMERRUNNING_ Test accessor: true if the auto-refresh timer is running. tf = ~isempty(obj.AutoTimer_) && isvalid(obj.AutoTimer_) && ... @@ -296,6 +320,8 @@ function buildFigure_(obj) 'XColor', t.ForegroundColor, ... 'YColor', t.ForegroundColor); obj.Canvas_ = EventGanttCanvas(ax, t); + obj.Canvas_.OnSingleClick = @(ev) obj.onEventSingleClick_(ev); + obj.Canvas_.OnDoubleClick = @(ev) obj.onEventDoubleClick_(ev); % --- Filter bar contents ----------------------------------- % Preset buttons row. @@ -519,5 +545,71 @@ function onIntervalEdited_(obj, txt) if wasOn; start(obj.AutoTimer_); end end end + + function onEventSingleClick_(obj, ev) + %ONEVENTSINGLECLICK_ Show a small details popup with editable Notes. + try + msg = sprintf( ... + ['Sensor: %s\nThreshold: %s (%s @ %g)\n', ... + 'Severity: %d\nStart: %s\nEnd: %s\n', ... + 'Duration: %g\nPeak: %g\nN points: %d'], ... + ev.SensorName, ev.ThresholdLabel, ev.Direction, ev.ThresholdValue, ... + ev.Severity, ... + obj.formatTime_(ev.StartTime), ... + obj.formatTime_(ev.EndTime), ... + obj.eventDuration_(ev), ... + obj.scalarOrNaN_(ev.PeakValue), obj.scalarOrNaN_(ev.NumPoints)); + + answer = inputdlg({sprintf('%s\n\nNotes:', msg)}, ... + sprintf('Event %s', ev.Id), [10 60], {ev.Notes}); + if ~isempty(answer) + ev.Notes = answer{1}; + try; obj.Store_.save(); catch; end + end + catch + % Popups must never crash the viewer. + end + end + + function onEventDoubleClick_(obj, ev) + %ONEVENTDOUBLECLICK_ Open a SensorDetailPlot zoomed to the event window. + try + tagKey = ''; + if ~isempty(ev.TagKeys); tagKey = ev.TagKeys{1}; end + if isempty(tagKey); tagKey = ev.SensorName; end + tag = []; + try; tag = TagRegistry.get(tagKey); catch; end + if isempty(tag) || ~isa(tag, 'Tag'); return; end + sdp = SensorDetailPlot(tag); + evEnd = EventGanttCanvas.eventEndOrNow(ev, now); + pad = 0.1 * max(evEnd - ev.StartTime, 1); + try + set(sdp.hMainAxes, 'XLim', [ev.StartTime - pad, evEnd + pad]); + catch + end + catch + end + end + + function s = formatTime_(~, t) + %FORMATTIME_ Format a datenum time as readable string; NaN => '(open)'. + if isnan(t); s = '(open)'; return; end + try + s = datestr(t, 'yyyy-mm-dd HH:MM:SS'); + catch + s = sprintf('%g', t); + end + end + + function d = eventDuration_(~, ev) + %EVENTDURATION_ Return EndTime-StartTime, or NaN for open events. + if isnan(ev.EndTime); d = NaN; return; end + d = ev.EndTime - ev.StartTime; + end + + function v = scalarOrNaN_(~, x) + %SCALARORNANNORM_ Return x(1) if numeric, else NaN. + if isempty(x) || ~isnumeric(x); v = NaN; else; v = x(1); end + end end end diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index 23bd8240..910783a8 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -286,6 +286,46 @@ function testSliderRangeChangedSetsCustomMode(testCase) v.onSliderRangeChanged_internalForTest(now - 0.5, now); testCase.verifyEqual(v.TimePresetMode, 'custom'); end + + % --- Task 12: single-click details popup + double-click drill-down tests --- + + function testSingleClickInvokesDetailsHandler(testCase) + es = makeStore_(testCase); + e = Event(0, 1, 'sA', 'lbl', 1, 'upper'); e.TagKeys = {'tA'}; e.Severity = 1; + es.append(e); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.setTimeRange(-1, 100); + v.refresh(); + + captured = LiveModeCapture(); + v.setSingleClickHandlerForTest_(@(ev) captured.push(true)); + v.fireBarClickForTest_(1, 'normal'); + testCase.verifyTrue(any(captured.Vals)); + end + + function testDoubleClickOpensSensorDetailPlot(testCase) + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + parent = SensorTag('sA', 'Name', 'A', 'Units', 'u', ... + 'X', 0:5, 'Y', [1 2 3 2 1 2]); + TagRegistry.register('sA', parent); + + es = makeStore_(testCase); + e = Event(0, 1, 'sA', 'lbl', 1, 'upper'); e.TagKeys = {'sA'}; e.Severity = 1; + es.append(e); + + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.setTimeRange(-1, 100); v.refresh(); + + captured = LiveModeCapture(); + v.setDoubleClickHandlerForTest_(@(ev) captured.push(true)); + v.fireBarClickForTest_(1, 'open'); + testCase.verifyTrue(any(captured.Vals)); + end end end From 3aebd16fc63b074845be1b4440ffc4d03d8bf26c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 20:05:15 +0200 Subject: [PATCH 15/26] feat(companion): toolbar Events button + single-instance viewer wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fixed-width col-1 [Events ↗] button to the companion toolbar that opens a CompanionEventViewer (idempotent: second click brings it to front). Wire ObjectBeingDestroyed listener to clear the handle on viewer close. Companion close() tears down the viewer first, before the live timer and listeners, preventing stale callbacks into a half-deleted companion. Co-Authored-By: Claude Sonnet 4.6 --- libs/FastSenseCompanion/FastSenseCompanion.m | 71 +++++++++++++++++++- tests/suite/TestFastSenseCompanion.m | 60 +++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index e8f6d9ad..848317ce 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -61,6 +61,7 @@ hLayout_ = [] % root uigridlayout handle hToolbarPanel_ = [] % top toolbar uipanel (row 1, spans cols [1 3]) hSettingsBtn_ = [] % gear button inside hToolbarPanel_ (right-aligned) + hEventsBtn_ = [] % toolbar uibutton: Events viewer launch hLeftPanel_ = [] % left pane uipanel hMidPanel_ = [] % middle pane uipanel hRightPanel_ = [] % right pane uipanel @@ -225,17 +226,32 @@ obj.hToolbarPanel_.Layout.Column = [1 3]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; - % Inner 1x5 grid — col 1 reserved for future toolbar items; + % Inner 1x5 grid — col 1 = Events viewer button (Task 13); % col 2 = Live: ON/OFF button; col 3 = Events log dropdown % (Phase 1027.1); col 4 = Live log dropdown (Phase 1027.1); % col 5 = gear button. hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 5]); - hToolbarGrid.ColumnWidth = {'1x', 110, 150, 150, 36}; + hToolbarGrid.ColumnWidth = {110, 110, 150, 150, 36}; hToolbarGrid.RowHeight = {'1x'}; hToolbarGrid.Padding = [4 0 4 0]; hToolbarGrid.ColumnSpacing = 8; hToolbarGrid.BackgroundColor = obj.Theme_.WidgetBackground; + % Col 1 — Events viewer launch (Task 13). + obj.hEventsBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hEventsBtn_.Layout.Row = 1; + obj.hEventsBtn_.Layout.Column = 1; + obj.hEventsBtn_.Text = ['Events ', char(8599)]; % ↗ + obj.hEventsBtn_.FontSize = 11; + obj.hEventsBtn_.FontWeight = 'bold'; + obj.hEventsBtn_.Tag = 'CompanionEventsBtn'; + obj.hEventsBtn_.Tooltip = 'Open the event viewer'; + obj.hEventsBtn_.ButtonPushedFcn = @(~,~) obj.openEventViewer_(); + if isempty(obj.EventStore_) + obj.hEventsBtn_.Enable = 'off'; + obj.hEventsBtn_.Tooltip = 'No EventStore registered'; + end + % Col 2 — Live: ON/OFF button (Phase 1027: moved from log header). obj.hLiveBtn_ = uibutton(hToolbarGrid, 'push'); obj.hLiveBtn_.Layout.Row = 1; @@ -427,6 +443,17 @@ function close(obj) end % Diagnostic — confirms the X click reached close(). fprintf('[FastSenseCompanion] close() invoked, tearing down...\n'); + % Tear down the event viewer first so its listener doesn't fire + % into a half-deleted companion. Independent try/catch — viewer + % failure must not block the rest of teardown. + try + if ~isempty(obj.EventViewer_) && isvalid(obj.EventViewer_) + obj.EventViewer_.close(); + end + catch err + fprintf(2, '[FastSenseCompanion] EventViewer cleanup failed: %s\n', err.message); + end + obj.EventViewer_ = []; % Stop and delete live timer first so no tick fires mid-teardown. try if ~isempty(obj.LiveTimer_) && isvalid(obj.LiveTimer_) @@ -928,6 +955,26 @@ function applyLogState(obj, which, newState) s = obj.EventStore_; end + function openEventViewer(obj) + %OPENEVENTVIEWER Public alias for the toolbar callback (used by tests / scripting). + obj.openEventViewer_(); + end + + function openEventViewer_internalForTest(obj) + %OPENEVENTVIEWER_INTERNALFORTEST Test shim: call openEventViewer_ directly. + obj.openEventViewer_(); + end + + function v = getEventViewerForTest_(obj) + %GETEVENTVIEWERFORTEST_ Test helper: return the EventViewer_ handle or []. + v = obj.EventViewer_; + end + + function f = getFigForTest_(obj) + %GETFIGFORTEST_ Test helper: return the companion uifigure handle. + f = obj.hFig_; + end + end methods (Access = private) @@ -1299,6 +1346,26 @@ function resolveInspectorState_(obj) end end + function openEventViewer_(obj) + %OPENEVENTVIEWER_ Open or bring-to-front the singleton CompanionEventViewer. + % Idempotent: second call focuses the existing viewer window. + % No-op when EventStore_ is empty. + if isempty(obj.EventStore_); return; end + if ~isempty(obj.EventViewer_) && isvalid(obj.EventViewer_) && ... + ~isempty(obj.EventViewer_.hFigure) && isgraphics(obj.EventViewer_.hFigure) + obj.EventViewer_.bringToFront(); + return; + end + obj.EventViewer_ = CompanionEventViewer(obj.EventStore_, obj.Registry_, obj); + obj.Listeners_{end+1} = addlistener(obj.EventViewer_, 'ObjectBeingDestroyed', ... + @(~,~) obj.clearEventViewerHandle_()); + end + + function clearEventViewerHandle_(obj) + %CLEAREVENTVIEWERHANDLE_ ObjectBeingDestroyed callback: clear the stale handle. + obj.EventViewer_ = []; + end + function onOpenAdHocPlotRequested_(obj, ~, evt) %ONOPENADHOCPLOTREQUESTED_ Listener for OpenAdHocPlotRequested event. % Resolves AdHocPlotEventData.TagKeys to Tag handles via Registry_, diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index d021b9d4..eaeb4dad 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1124,6 +1124,66 @@ function testLiveModeChangedFiresOnStartAndStop(testCase) 'Last fire must observe IsLive=false after stopLiveMode.'); end + % ---- Task 13: toolbar Events button + single-instance viewer wiring ---- + + function testEventsButtonExistsInToolbar(testCase) + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + btn = findall(app.getFigForTest_(), 'Tag', 'CompanionEventsBtn'); + testCase.verifyNotEmpty(btn); + testCase.verifyEqual(char(btn.Enable), 'on'); + end + + function testEventsButtonDisabledWhenNoStore(testCase) + TagRegistry.clear(); + testCase.addTeardown(@() TagRegistry.clear()); + app = FastSenseCompanion(); + testCase.addTeardown(@() app.close()); + btn = findall(app.getFigForTest_(), 'Tag', 'CompanionEventsBtn'); + testCase.verifyNotEmpty(btn); + testCase.verifyEqual(char(btn.Enable), 'off'); + testCase.verifyEqual(btn.Tooltip, 'No EventStore registered'); + end + + function testEventsButtonOpensViewerSingleInstance(testCase) + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.openEventViewer_internalForTest(); + v1 = app.getEventViewerForTest_(); + testCase.verifyClass(v1, 'CompanionEventViewer'); + app.openEventViewer_internalForTest(); + v2 = app.getEventViewerForTest_(); + testCase.verifySameHandle(v1, v2, 'Second click must reuse the existing viewer.'); + end + + function testCompanionCloseClosesViewer(testCase) + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + app.openEventViewer_internalForTest(); + v = app.getEventViewerForTest_(); + f = v.hFigure; + app.close(); + testCase.verifyFalse(isgraphics(f), 'Companion close must close viewer figure.'); + end + + function testViewerObjectBeingDestroyedClearsHandle(testCase) + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.openEventViewer_internalForTest(); + v = app.getEventViewerForTest_(); + delete(v); + drawnow; + testCase.verifyEmpty(app.getEventViewerForTest_(), ... + 'ObjectBeingDestroyed listener must clear EventViewer_.'); + end + end methods (Access = private) From 915117350df0f6ee8414998b337996f1aae84b7e Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 20:13:52 +0200 Subject: [PATCH 16/26] feat(demo): wire EventStore explicitly + smoke-test viewer launch Co-Authored-By: Claude Opus 4.7 (1M context) --- .../industrial_plant/private/buildCompanion.m | 3 +- .../suite/TestIndustrialPlantDemoCompanion.m | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/demo/industrial_plant/private/buildCompanion.m b/demo/industrial_plant/private/buildCompanion.m index 08b69500..6cd8d152 100644 --- a/demo/industrial_plant/private/buildCompanion.m +++ b/demo/industrial_plant/private/buildCompanion.m @@ -23,5 +23,6 @@ companion = FastSenseCompanion( ... 'Dashboards', {ctx.engine}, ... - 'Theme', 'light'); + 'Theme', 'light', ... + 'EventStore', ctx.store); end diff --git a/tests/suite/TestIndustrialPlantDemoCompanion.m b/tests/suite/TestIndustrialPlantDemoCompanion.m index bd468873..cbf78d3e 100644 --- a/tests/suite/TestIndustrialPlantDemoCompanion.m +++ b/tests/suite/TestIndustrialPlantDemoCompanion.m @@ -152,6 +152,48 @@ function testCOMPDEMO04_teardownClosesCompanionAndNoOrphanTimers(testCase) 'COMPDEMO-04: teardownDemo must leave no NEW timers in timerfindall (no orphans)'); end + function testDemoCompanionExposesEventStore(testCase) + %TESTDEMOCOMPANIONEXPOSESEVENTSTORE + % After run_demo, companion's resolved EventStore is non-empty. + testCase.assumeFalse(exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestIndustrialPlantDemoCompanion is MATLAB-only.'); + TagRegistry.clear(); + ctx = run_demo(); + testCase.addTeardown(@() teardownDemo(ctx)); + testCase.addTeardown(@() TagRegistry.clear()); + testCase.assertNotEmpty(ctx.companion); + testCase.verifyNotEmpty(ctx.companion.getEventStore()); + end + + function testDemoEventsButtonEnabled(testCase) + %TESTDEMOEVENTSBUTTONENABLED + % After run_demo, the toolbar Events button is enabled. + testCase.assumeFalse(exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestIndustrialPlantDemoCompanion is MATLAB-only.'); + TagRegistry.clear(); + ctx = run_demo(); + testCase.addTeardown(@() teardownDemo(ctx)); + testCase.addTeardown(@() TagRegistry.clear()); + btn = findall(ctx.companion.getFigForTest_(), 'Tag', 'CompanionEventsBtn'); + testCase.verifyNotEmpty(btn); + testCase.verifyEqual(char(get(btn, 'Enable')), 'on'); + end + + function testDemoEventViewerOpensWithoutErrors(testCase) + %TESTDEMOEVENTVIEWEROPENSWITHOUTERRORS + % Programmatically open the viewer; verify it constructs successfully. + testCase.assumeFalse(exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestIndustrialPlantDemoCompanion is MATLAB-only.'); + TagRegistry.clear(); + ctx = run_demo(); + testCase.addTeardown(@() teardownDemo(ctx)); + testCase.addTeardown(@() TagRegistry.clear()); + ctx.companion.openEventViewer(); + v = ctx.companion.getEventViewerForTest_(); + testCase.verifyClass(v, 'CompanionEventViewer'); + testCase.verifyTrue(isgraphics(v.hFigure)); + end + end end From 1ee5ae85d4f9327bfdda653f52df3f8548e30b2f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 21:46:23 +0200 Subject: [PATCH 17/26] feat(companion): event viewer polish (tooltips, datetime axis, slider preview, layout) Five fixes after first round of live testing: * Tooltips on every filter-bar control (presets, From/To, severity I/W/A, open-only, search, Refresh, Auto, interval). * Datetime tick labels on the Gantt X-axis via datetick(ax, 'x', 'keeplimits'). Replaces the raw datenum scaffold (7.401e5...) with HH:MM:SS-style labels MATLAB picks based on the visible range. * Event-marker dots on the TimeRangeSelector slider, severity-colored. Feeds via Selector_.setEventMarkers(times, colors) from a new private updateSliderPreview_(allEvents) called on every refresh. The slider now shows the full unfiltered event distribution while the Gantt above shows the filtered slice. * Slider panel reduced 20% -> 10% of figure height; Gantt area grew 65% -> 75%. Filter bar unchanged. * Axes left margin widened 0.10 -> 0.18 inside its panel so long tag keys (e.g. feedline.pressure.high) render fully instead of clipping to "edline.pressure". Programmatic Selector_.setSelection calls suppress OnRangeChanged via a save/clear/restore guard to break a recursion loop discovered in testing: refresh -> setSelection -> OnRangeChanged -> onSliderRangeChanged_ -> refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FastSenseCompanion/CompanionEventViewer.m | 78 +++++++++++++++++-- libs/FastSenseCompanion/EventGanttCanvas.m | 6 ++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 1471cecc..0224baf7 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -114,13 +114,14 @@ function applyPreset_internalForTest(obj, name) end function refresh(obj) - %REFRESH Pull from store, apply filters, redraw Gantt. No-op if figure gone. + %REFRESH Pull from store, apply filters, redraw Gantt + slider. No-op if figure gone. if isempty(obj.hFigure) || ~isgraphics(obj.hFigure); return; end evs = obj.Store_.getEvents(); if isempty(evs); evs = Event.empty; end filtered = CompanionEventViewer.applyFilters( ... evs, obj.SelectedTagKeys, obj.SeverityMask, obj.OpenOnly, obj.TimeRange); obj.Canvas_.draw(filtered, obj.Theme_); + obj.updateSliderPreview_(evs); end function c = getCanvasForTest_(obj) @@ -297,6 +298,7 @@ function buildFigure_(obj) 'CloseRequestFcn', @(~,~) obj.close(), ... 'Visible', 'on'); + % Layout: filter bar 15% top, Gantt 75% middle, slider 10% bottom. obj.FilterPanel_ = uipanel('Parent', obj.hFigure, ... 'Units', 'normalized', ... 'Position', [0 0.85 1 0.15], ... @@ -304,18 +306,19 @@ function buildFigure_(obj) 'BorderType', 'none'); obj.AxesPanel_ = uipanel('Parent', obj.hFigure, ... 'Units', 'normalized', ... - 'Position', [0 0.20 1 0.65], ... + 'Position', [0 0.10 1 0.75], ... 'BackgroundColor', t.WidgetBackground, ... 'BorderType', 'none'); obj.SliderPanel_ = uipanel('Parent', obj.hFigure, ... 'Units', 'normalized', ... - 'Position', [0 0 1 0.20], ... + 'Position', [0 0 1 0.10], ... 'BackgroundColor', t.WidgetBackground, ... 'BorderType', 'none'); + % Wider left margin so long tag keys (e.g. feedline.pressure.high) fit. ax = axes('Parent', obj.AxesPanel_, ... 'Units', 'normalized', ... - 'Position', [0.10 0.10 0.85 0.85], ... + 'Position', [0.18 0.10 0.78 0.85], ... 'Color', t.WidgetBackground, ... 'XColor', t.ForegroundColor, ... 'YColor', t.ForegroundColor); @@ -325,7 +328,12 @@ function buildFigure_(obj) % --- Filter bar contents ----------------------------------- % Preset buttons row. - presets = {'1h', '24h', '7d', 'All'}; + presets = {'1h', '24h', '7d', 'All'}; + presetTooltips = { ... + 'Show events from the last hour', ... + 'Show events from the last 24 hours', ... + 'Show events from the last 7 days', ... + 'Show all events on record'}; for i = 1:numel(presets) uicontrol('Parent', obj.FilterPanel_, ... 'Style', 'pushbutton', 'String', presets{i}, ... @@ -334,6 +342,7 @@ function buildFigure_(obj) 'Position', [0.02 + (i-1)*0.05, 0.55, 0.045, 0.35], ... 'BackgroundColor', t.WidgetBorderColor, ... 'ForegroundColor', t.ForegroundColor, ... + 'TooltipString', presetTooltips{i}, ... 'Callback', @(src, ~) obj.applyPreset_(get(src, 'String'))); end @@ -345,6 +354,7 @@ function buildFigure_(obj) uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'FromEdit', ... 'Units', 'normalized', 'Position', [0.30 0.55 0.10 0.35], ... 'String', '', ... + 'TooltipString', 'Custom start time (e.g. 2026-05-08 14:30:00)', ... 'Callback', @(src, ~) obj.onFromToEdited_()); uicontrol('Parent', obj.FilterPanel_, 'Style', 'text', 'String', 'To:', ... 'Units', 'normalized', 'Position', [0.40 0.55 0.03 0.35], ... @@ -353,10 +363,15 @@ function buildFigure_(obj) uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'ToEdit', ... 'Units', 'normalized', 'Position', [0.43 0.55 0.10 0.35], ... 'String', '', ... + 'TooltipString', 'Custom end time (e.g. 2026-05-08 15:30:00)', ... 'Callback', @(src, ~) obj.onFromToEdited_()); % Severity toggles. - sevLabels = {'I', 'W', 'A'}; + sevLabels = {'I', 'W', 'A'}; + sevTooltips = { ... + 'Show info events (severity 1)', ... + 'Show warning events (severity 2)', ... + 'Show alarm events (severity 3)'}; for i = 1:3 uicontrol('Parent', obj.FilterPanel_, 'Style', 'togglebutton', ... 'String', sevLabels{i}, 'Tag', sprintf('SevBtn%d', i), ... @@ -365,6 +380,7 @@ function buildFigure_(obj) 'Position', [0.55 + (i-1)*0.03, 0.55, 0.025, 0.35], ... 'BackgroundColor', t.WidgetBorderColor, ... 'ForegroundColor', t.ForegroundColor, ... + 'TooltipString', sevTooltips{i}, ... 'Callback', @(src, ~) obj.onSevToggled_(i, get(src, 'Value'))); end @@ -374,27 +390,31 @@ function buildFigure_(obj) 'Value', 0, ... 'Units', 'normalized', 'Position', [0.65 0.55 0.07 0.35], ... 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'TooltipString', 'Show only currently-open (still active) events', ... 'Callback', @(src, ~) obj.setOpenOnly_(get(src, 'Value') == 1)); % Tag search. uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', ... 'Tag', 'TagSearch', 'String', '', ... 'Units', 'normalized', 'Position', [0.02 0.10 0.20 0.35], ... - 'TooltipString', 'Tag search…', ... + 'TooltipString', 'Substring filter on registered tag keys (empty = all tags)', ... 'Callback', @(src, ~) obj.onTagSearchChanged_(get(src, 'String'))); % Refresh + Auto + interval. uicontrol('Parent', obj.FilterPanel_, 'Style', 'pushbutton', 'String', 'Refresh', ... 'Units', 'normalized', 'Position', [0.74 0.55 0.07 0.35], ... + 'TooltipString', 'Re-read events from the EventStore and redraw', ... 'Callback', @(~, ~) obj.refresh()); uicontrol('Parent', obj.FilterPanel_, 'Style', 'checkbox', 'String', 'Auto', ... 'Tag', 'AutoChk', 'Value', 1, ... 'Units', 'normalized', 'Position', [0.82 0.55 0.05 0.35], ... 'BackgroundColor', t.WidgetBackground, 'ForegroundColor', t.ForegroundColor, ... + 'TooltipString', 'Auto-refresh while the companion is in Live mode', ... 'Callback', @(src, ~) obj.setAutoEnabled_(get(src, 'Value') == 1)); uicontrol('Parent', obj.FilterPanel_, 'Style', 'edit', 'Tag', 'IntervalEdit', ... 'String', sprintf('%g', obj.AutoPeriod_), ... 'Units', 'normalized', 'Position', [0.87 0.55 0.04 0.35], ... + 'TooltipString', 'Auto-refresh interval in seconds', ... 'Callback', @(src, ~) obj.onIntervalEdited_(get(src, 'String'))); % --- Slider in bottom panel -------------------------------- @@ -477,6 +497,50 @@ function onSliderRangeChanged_(obj, t1, t2) obj.refresh(); end + function updateSliderPreview_(obj, allEvents) + %UPDATESLIDERPREVIEW_ Feed event-marker dots into the TimeRangeSelector. + % allEvents — full unfiltered Event array (so the user sees the + % complete distribution while the Gantt above shows + % the filtered slice). + if isempty(obj.Selector_) || ~isvalid(obj.Selector_); return; end + try + if isempty(allEvents) + obj.Selector_.setEventMarkers([]); + return; + end + nowRef = now; + times = arrayfun(@(e) e.StartTime, allEvents); + ends = arrayfun(@(e) EventGanttCanvas.eventEndOrNow(e, nowRef), allEvents); + colors = zeros(numel(allEvents), 3); + for k = 1:numel(allEvents) + colors(k, :) = EventGanttCanvas.severityColor(allEvents(k).Severity); + end + tMin = min(times); + tMax = max(nowRef, max(ends)); + if isfinite(tMin) && isfinite(tMax) && tMax > tMin + obj.Selector_.setDataRange(tMin, tMax); + selStart = max(tMin, obj.TimeRange(1)); + selEnd = min(tMax, obj.TimeRange(2)); + if selEnd > selStart + % Suppress the slider's OnRangeChanged callback while + % programmatically syncing — without this the chain + % refresh -> setSelection -> OnRangeChanged -> + % onSliderRangeChanged_ -> refresh recurses infinitely. + savedCb = obj.Selector_.OnRangeChanged; + obj.Selector_.OnRangeChanged = []; + try + obj.Selector_.setSelection(selStart, selEnd); + catch + end + obj.Selector_.OnRangeChanged = savedCb; + end + end + obj.Selector_.setEventMarkers(times, colors); + catch + % Slider preview is non-critical — never crash refresh. + end + end + function onFromToEdited_(obj) %ONFROMTOEDITED_ Parse From/To edit fields and apply as custom range. fromCtl = findall(obj.hFigure, 'Tag', 'FromEdit'); diff --git a/libs/FastSenseCompanion/EventGanttCanvas.m b/libs/FastSenseCompanion/EventGanttCanvas.m index 6dba0705..b606e389 100644 --- a/libs/FastSenseCompanion/EventGanttCanvas.m +++ b/libs/FastSenseCompanion/EventGanttCanvas.m @@ -101,6 +101,12 @@ function draw(obj, events, theme) 'YTickLabel', keys, ... 'YLim', [0.5, numel(keys) + 0.5]); + % Datetime tick labels on the X axis (event times are datenums). + try + datetick(obj.hAxes, 'x', 'keeplimits'); + catch + end + hold(obj.hAxes, 'off'); % Wire bar click handler — single + double click distinguished From 5b0e6fb55644a2e345f6db4a62194231642252e9 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 21:46:37 +0200 Subject: [PATCH 18/26] fix(sensor-threshold): MonitorTag.fireEventsOnRisingEdges_ deduplicates against EventStore Pre-existing bug surfaced by the new event viewer. In live mode the industrial-plant demo accumulated ~30x duplicates of every closed event in the EventStore over a single minute. Root cause: SensorTag.updateData fires invalidate on its MonitorTag listener, which wipes cache_ and sets dirty_=true. The next getXY() falls into recompute_, which calls fireEventsOnRisingEdges_ over the parent's full history and unconditionally appends every closed run to the EventStore. Every refresh tick = one full re-emission of every historical event. Existing tests caught the cache-hit case (second getXY after a fresh recompute) but not the dirty/recompute case. Fix at the emit call site: query EventStore.getEventsForTag(obj.Key) at the start of fireEventsOnRisingEdges_, build a StartTime-keyed dedup index, and skip emission for any candidate whose StartTime matches an existing entry. For the open->closed transition (run previously stored open, now closes), close the existing event in place via EventStore.closeEvent rather than appending a duplicate. Also re-seed cache_.openEventId_ from the existing open event so the streaming appendData hot path can still close it later. Regression test: testNoDuplicateEventsOnRecomputeAfterInvalidate. Pre-fix: 1 -> 2 events after one invalidate, 1 -> 7 after six cycles. Post-fix: 1 -> 1 -> 1. Verified live: industrial-plant demo at +45s shows 4 unique events / 4 unique tuples / max 1 duplicate per tuple. All 67 tests across the 6 MonitorTag/EventStore suites pass; zero regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/SensorThreshold/MonitorTag.m | 61 ++++++++++++++++++++++++++++++ tests/suite/TestMonitorTagEvents.m | 38 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/libs/SensorThreshold/MonitorTag.m b/libs/SensorThreshold/MonitorTag.m index ae57b4d4..03aebf4a 100644 --- a/libs/SensorThreshold/MonitorTag.m +++ b/libs/SensorThreshold/MonitorTag.m @@ -865,11 +865,67 @@ function fireEventsOnRisingEdges_(obj, px, bin) [sI, eI] = obj.findRuns_(bin); % Phase 1012: detect trailing open run (last run ends at last bin index) lastOpenRun = ~isempty(eI) && eI(end) == numel(bin); + + % Dedup index: prevents recompute_ from re-emitting events that + % were already appended on a prior recompute_. In live mode the + % parent fires invalidate on every updateData, which wipes + % cache_ and forces the next getXY into recompute_; without this + % guard the same run is appended once per refresh tick (the + % industrial-plant demo accumulated ~30x duplicates of each + % closed event in a single minute). Keys are StartTime; for the + % open→closed transition we also remember the existing open + % event's Id so we can close it in place. + existingStarts = []; + existingOpenStart = NaN; + existingOpenId = ''; + if ~isempty(obj.EventStore) + try + prior = obj.EventStore.getEventsForTag(char(obj.Key)); + catch + prior = []; + end + if ~isempty(prior) + existingStarts = arrayfun(@(e) e.StartTime, prior); + openMask = arrayfun(@(e) logical(e.IsOpen), prior); + if any(openMask) + % Take the first open event's StartTime/Id; in + % normal usage there's at most one open event per + % monitor at a time. + openIdx = find(openMask, 1, 'first'); + existingOpenStart = prior(openIdx).StartTime; + existingOpenId = char(prior(openIdx).Id); + % Re-seed cache_.openEventId_ so the streaming + % appendData hot path can still close this event + % via EventStore.closeEvent on its next tail. + obj.cache_.openEventId_ = existingOpenId; + end + end + end + startsAlreadyEmitted = @(t) ~isempty(existingStarts) && ... + any(abs(existingStarts - t) <= max(1e-9, eps(max(abs(t), 1)) * 8)); + % Closed runs first for k = 1:numel(sI) if lastOpenRun && k == numel(sI), continue; end % last run is OPEN — handled below startT = px(sI(k)); endT = px(eI(k)); + if startsAlreadyEmitted(startT) + % Open→closed transition: the run was previously emitted + % as an open event; close that existing event in place + % rather than appending a duplicate. + if ~isempty(existingOpenId) && ... + ~isnan(existingOpenStart) && ... + abs(existingOpenStart - startT) <= max(1e-9, eps(max(abs(startT), 1)) * 8) + try + obj.EventStore.closeEvent(existingOpenId, endT, []); + catch + end + obj.cache_.openEventId_ = ''; + existingOpenId = ''; + existingOpenStart = NaN; + end + continue; + end ev = Event(startT, endT, char(obj.Parent.Key), char(obj.Key), NaN, 'upper'); if ~isempty(obj.EventStore) obj.EventStore.append(ev); @@ -877,6 +933,7 @@ function fireEventsOnRisingEdges_(obj, px, bin) ev.TagKeys = {char(obj.Key), char(obj.Parent.Key)}; EventBinding.attach(ev.Id, char(obj.Key)); EventBinding.attach(ev.Id, char(obj.Parent.Key)); + existingStarts(end+1) = startT; %#ok end if ~isempty(obj.OnEventStart), obj.OnEventStart(ev); end if ~isempty(obj.OnEventEnd), obj.OnEventEnd(ev); end @@ -884,6 +941,10 @@ function fireEventsOnRisingEdges_(obj, px, bin) % Phase 1012: open run (trailing) — emit IsOpen=true event if lastOpenRun && isempty(obj.cache_.openEventId_) startT = px(sI(end)); + if startsAlreadyEmitted(startT) + % Already emitted on a prior recompute_; nothing to do. + return; + end ev = Event(startT, NaN, char(obj.Parent.Key), char(obj.Key), NaN, 'upper'); ev.IsOpen = true; if ~isempty(obj.EventStore) diff --git a/tests/suite/TestMonitorTagEvents.m b/tests/suite/TestMonitorTagEvents.m index 1c86bfe5..fca7e2d4 100644 --- a/tests/suite/TestMonitorTagEvents.m +++ b/tests/suite/TestMonitorTagEvents.m @@ -176,6 +176,44 @@ function testNoDuplicateEventsOnSecondGetXY(testCase) 'Cache-hit getXY must NOT re-emit events'); end + function testNoDuplicateEventsOnRecomputeAfterInvalidate(testCase) + %TESTNODUPLICATEEVENTSONRECOMPUTEAFTERINVALIDATE + % Regression: in live mode, parent.updateData fires invalidate + % on the MonitorTag listener, which wipes cache_ and forces a + % full recompute_ on the next getXY. recompute_ must NOT + % re-emit events that were emitted on a prior recompute (which + % happened the very first time the user dumped the EventStore + % from the running industrial-plant demo: ~30x duplicates of + % each closed event). + x = 1:10; + y = [0 0 0 10 10 10 0 0 0 0]; + parent = SensorTag('p', 'X', x, 'Y', y); + store = EventStore(''); + m = MonitorTag('m', parent, @(xx, yy) yy > 5, 'EventStore', store); + [~, ~] = m.getXY(); + n1 = numel(store.getEvents()); + testCase.verifyEqual(n1, 1, 'first getXY must emit exactly 1 event'); + + % Simulate parent.updateData → cascade-invalidate to monitor. + m.invalidate(); + + % Next getXY triggers a full recompute_ over the parent's + % unchanged history. Must NOT re-emit the same event. + [~, ~] = m.getXY(); + n2 = numel(store.getEvents()); + testCase.verifyEqual(n2, 1, ... + 'recompute_ after invalidate must NOT duplicate already-emitted events'); + + % Idempotent under repeated invalidate/getXY cycles. + for i = 1:5 + m.invalidate(); + [~, ~] = m.getXY(); + end + n3 = numel(store.getEvents()); + testCase.verifyEqual(n3, 1, ... + 'multiple invalidate/getXY cycles must not accumulate duplicates'); + end + % ---- Native parent-X units ---- function testEventStartEndTimesUseNativeParentUnits(testCase) From 5ccd39a3cc45aa6a0514808c69300519e170c904 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:04:24 +0200 Subject: [PATCH 19/26] fix(dashboard): chip/multistatus circles stay round at any panel aspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the pxH/pxW-based rx scaling (computed once per render, stale after panel resize) with axes DataAspectRatio=[1 1 1] plus equal x/y radii in data units. Circles now render as circles regardless of how the panel resizes; MATLAB letterboxes the axes when needed and the indicators stay centered at their assigned slots. Affects ChipBarWidget (the 4-chip Subsystem Health row in the industrial-plant demo) and MultiStatusWidget (the 4-circle All Monitors panel) — both showed clearly stretched ovals in the live demo. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/Dashboard/ChipBarWidget.m | 25 +++++++++++++------------ libs/Dashboard/MultiStatusWidget.m | 24 +++++++++--------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/libs/Dashboard/ChipBarWidget.m b/libs/Dashboard/ChipBarWidget.m index 6dfe9360..5c6772d5 100644 --- a/libs/Dashboard/ChipBarWidget.m +++ b/libs/Dashboard/ChipBarWidget.m @@ -88,28 +88,29 @@ function render(obj, parentPanel) set(parentPanel, 'Units', oldUnits); pxH = pxPos(4); - % Single axes spanning full panel + % Single axes spanning full panel. + % DataAspectRatio=[1 1 1] forces equal data units in pixels so + % circles drawn with cos/sin stay circular regardless of how + % the panel resizes. MATLAB letterboxes the axes if needed; the + % chips remain centered at their integer x-slots. obj.hAx = axes('Parent', parentPanel, ... 'Units', 'normalized', ... 'Position', [0 0 1 1], ... 'Visible', 'off', ... 'HitTest', 'off', ... 'XLim', [0 nChips], ... - 'YLim', [0 1]); + 'YLim', [0 1], ... + 'DataAspectRatio', [1 1 1]); try set(obj.hAx, 'PickableParts', 'none'); catch, end try disableDefaultInteractivity(obj.hAx); catch, end hold(obj.hAx, 'on'); - % Compute aspect ratio correction so circles don't stretch - % Axes spans [0, nChips] x [0, 1] but panel is wider than tall, - % so x-radius must be shrunk relative to y-radius. - pxW = pxPos(3); - ry = 0.22; % radius in y-axis units - if pxW > 0 && pxH > 0 - rx = ry * (pxH / pxW) * nChips; % scale x-radius by panel aspect - else - rx = ry; - end + % Equal x/y radii in data units — axes DataAspectRatio handles + % the visual circularity. ry = 0.22 in y-data units (YLim=[0 1]) + % gives chips of diameter 0.44 with comfortable spacing relative + % to the per-chip x-slot of width 1. + ry = 0.22; + rx = ry; theta = linspace(0, 2*pi, 60); chipFontSz = max(6, min(9, round(pxH * 0.18))); diff --git a/libs/Dashboard/MultiStatusWidget.m b/libs/Dashboard/MultiStatusWidget.m index 04f82e51..d7bcf2a9 100644 --- a/libs/Dashboard/MultiStatusWidget.m +++ b/libs/Dashboard/MultiStatusWidget.m @@ -23,11 +23,15 @@ function render(obj, parentPanel) % Re-layout on resize so pixel-scaled fonts/geometry stay correct. try obj.hPanel.SizeChangedFcn = @(~,~) obj.relayout_(); catch, end theme = obj.getTheme(); + % DataAspectRatio=[1 1 1] forces equal data units in pixels so + % circles drawn with cos/sin remain circular regardless of how + % the panel resizes. MATLAB letterboxes the axes if needed. obj.hAxes = axes('Parent', parentPanel, ... 'Units', 'normalized', ... 'Position', [0.02 0.02 0.96 0.96], ... 'Visible', 'off', ... - 'XLim', [0 1], 'YLim', [0 1]); + 'XLim', [0 1], 'YLim', [0 1], ... + 'DataAspectRatio', [1 1 1]); obj.refresh(); end @@ -55,14 +59,9 @@ function refresh(obj) warnColor = theme.StatusWarnColor; alarmColor = theme.StatusAlarmColor; - % Compute aspect ratio correction for circles - oldUnits = get(obj.hPanel, 'Units'); - set(obj.hPanel, 'Units', 'pixels'); - pxPos = get(obj.hPanel, 'Position'); - set(obj.hPanel, 'Units', oldUnits); - pxW = pxPos(3); - pxH = pxPos(4); - + % Equal x/y radii — DataAspectRatio=[1 1 1] on the axes (set in + % render()) keeps the drawn ellipses perfectly circular at any + % panel aspect ratio. No pxW/pxH correction needed. for i = 1:n col = mod(i-1, cols); row = floor((i-1) / cols); @@ -72,13 +71,8 @@ function refresh(obj) item = expandedItems{i}; - % Draw indicator — aspect-ratio-corrected so circles stay round ry = 0.3 / max(cols, rows); - if pxW > 0 && pxH > 0 - rx = ry * (pxH / pxW); - else - rx = ry; - end + rx = ry; if isstruct(item) % Tag-first dispatch (v2.0 Tag API) — falls through to legacy From 4e8384a9eb0120d1f19ff95d4c84d1b5d0474414 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:04:41 +0200 Subject: [PATCH 20/26] fix(dashboard): widget content respects the 28-px button bar at panel top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widgets rendered text/icons into the full panel area (normalized [0, 1]) and computed adaptive font sizes from full panel height. The WidgetButtonBar (added by DashboardLayout, hosting the i + detach buttons) is a 28-px-tall opaque strip overlaying the top of the panel, so on shorter panels widget content was clipped under the bar — most visible on NumberWidget where large value digits were sliced in half. Adds DashboardWidget.panelMetrics_(parentPanel) returning pxW, pxH, and a content area (pxHContent = pxH - 32) plus its normalized top edge. NumberWidget, StatusWidget, and IconCardWidget now base both font sizes and uicontrol Position rectangles on this content area. ChipBarWidget and MultiStatusWidget already render in axes far enough below the bar that they don't clip; left untouched here to keep the diff small. Existing relayout_ SizeChangedFcn hooks pick up resize without changes. Verified live in the industrial-plant demo: temperature reads as "163.7 °C" with full vertical extent visible; the threshold-formula StatusWidget's "active when pressure>18bar" no longer collides with the bar; reactor-critical IconCardWidget's value sits below its header. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/Dashboard/DashboardWidget.m | 37 ++++++++++++++++++++++++++++++++ libs/Dashboard/IconCardWidget.m | 26 +++++++++++++--------- libs/Dashboard/NumberWidget.m | 29 ++++++++++++++----------- libs/Dashboard/StatusWidget.m | 18 ++++++++-------- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m index f19cf33b..6ecb93b1 100644 --- a/libs/Dashboard/DashboardWidget.m +++ b/libs/Dashboard/DashboardWidget.m @@ -196,6 +196,43 @@ function setTimeRange(~, ~, ~) end end end + + function [pxW, pxH, pxHContent, contentTopFrac] = panelMetrics_(~, parentPanel) + %PANELMETRICS_ Pixel dimensions and the content area below the + % WidgetButtonBar (28 px tall, 2 px inset, 2 px breathing room + % below the bar — see DashboardLayout.getOrCreateButtonBar_). + % + % Subclasses use pxHContent for adaptive font sizing so values + % aren't clipped under the bar, and contentTopFrac for the top + % edge of normalized-units uicontrols/axes inside parentPanel. + % + % Outputs: + % pxW — panel width in pixels + % pxH — panel height in pixels + % pxHContent — usable content height (pxH - 32), >= 20 + % contentTopFrac — y-coordinate (in normalized units) of the + % top edge of the content area inside the + % panel; use as the upper Y for uicontrol + % Position rectangles to keep them clear of + % the button bar. + BUTTON_BAR_RESERVE_PX = 32; % 28 px bar + 4 px breathing + try + oldUnits = get(parentPanel, 'Units'); + set(parentPanel, 'Units', 'pixels'); + pp = get(parentPanel, 'Position'); + set(parentPanel, 'Units', oldUnits); + pxW = pp(3); + pxH = pp(4); + catch + pxW = 200; pxH = 100; + end + pxHContent = max(20, pxH - BUTTON_BAR_RESERVE_PX); + if pxH > 0 + contentTopFrac = pxHContent / pxH; + else + contentTopFrac = 1; + end + end end % NOTE: Conceptually abstract -- every subclass MUST override these methods. diff --git a/libs/Dashboard/IconCardWidget.m b/libs/Dashboard/IconCardWidget.m index 65fc2c9d..54d731cd 100644 --- a/libs/Dashboard/IconCardWidget.m +++ b/libs/Dashboard/IconCardWidget.m @@ -97,18 +97,24 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font size from panel pixel height - oldUnits = get(parentPanel, 'Units'); - set(parentPanel, 'Units', 'pixels'); - pxPos = get(parentPanel, 'Position'); - set(parentPanel, 'Units', oldUnits); - pH = pxPos(4); - fontSz = max(7, min(14, round(pH * 0.28))); + % Adaptive font size from content area (panel minus the 28-px + % button bar at the top), so text isn't clipped under the bar. + [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); + fontSz = max(7, min(14, round(pHContent * 0.34))); + + % All sub-controls fit within [0, contentTop] vertically. + yMid = contentTop / 2; + iconY = max(0.05, yMid - 0.35 * contentTop); + iconH = max(0.20, 0.70 * contentTop); + valueY = max(0.05, yMid); + valueH = max(0.20, contentTop - valueY - 0.02); + labelY = 0.02; + labelH = max(0.15, yMid - labelY - 0.02); % Icon axes — small square at left, circle fits inside unit square obj.hIconAx = axes('Parent', parentPanel, ... 'Units', 'normalized', ... - 'Position', [0.02 0.15 0.16 0.70], ... + 'Position', [0.02 iconY 0.16 iconH], ... 'Visible', 'off', ... 'DataAspectRatio', [1 1 1], ... 'XLim', [-1.2 1.2], ... @@ -128,7 +134,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '--', ... 'Units', 'normalized', ... - 'Position', [0.20 0.45 0.75 0.50], ... + 'Position', [0.20 valueY 0.75 valueH], ... 'FontName', fontName, ... 'FontSize', fontSz + 2, ... 'FontWeight', 'bold', ... @@ -141,7 +147,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.20 0.05 0.75 0.40], ... + 'Position', [0.20 labelY 0.75 labelH], ... 'FontName', fontName, ... 'FontSize', max(6, fontSz - 1), ... 'FontWeight', 'normal', ... diff --git a/libs/Dashboard/NumberWidget.m b/libs/Dashboard/NumberWidget.m index 29647f6f..4e1df5fb 100644 --- a/libs/Dashboard/NumberWidget.m +++ b/libs/Dashboard/NumberWidget.m @@ -46,23 +46,26 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font sizes based on panel pixel height - oldUnits = get(parentPanel, 'Units'); - set(parentPanel, 'Units', 'pixels'); - pxPos = get(parentPanel, 'Position'); - set(parentPanel, 'Units', oldUnits); - pH = pxPos(4); % panel height in pixels + % Adaptive font sizes based on the content area (panel height + % minus the 28-px button bar at the top), so values aren't + % clipped under the bar on short panels. + [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); - valueFontSz = max(8, min(28, round(pH * 0.45))); - titleFontSz = max(7, min(14, round(pH * 0.22))); - trendFontSz = max(6, min(16, round(pH * 0.25))); + valueFontSz = max(8, min(28, round(pHContent * 0.55))); + titleFontSz = max(7, min(14, round(pHContent * 0.27))); + trendFontSz = max(6, min(16, round(pHContent * 0.30))); + + % All uicontrols span [yBot, contentTop] vertically, leaving + % the top strip clear of the button bar. + yBot = 0.02; + yH = max(0.05, contentTop - yBot); % Horizontal layout: [Title | Value+Trend | Units] obj.hTitleText = uicontrol('Parent', parentPanel, ... 'Style', 'text', ... 'String', obj.Title, ... 'Units', 'normalized', ... - 'Position', [0.02 0.02 0.28 0.96], ... + 'Position', [0.02 yBot 0.28 yH], ... 'FontName', fontName, ... 'FontSize', titleFontSz, ... 'FontWeight', 'bold', ... @@ -74,7 +77,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '--', ... 'Units', 'normalized', ... - 'Position', [0.31 0.02 0.40 0.96], ... + 'Position', [0.31 yBot 0.40 yH], ... 'FontName', fontName, ... 'FontSize', valueFontSz, ... 'FontWeight', 'bold', ... @@ -86,7 +89,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.72 0.02 0.08 0.96], ... + 'Position', [0.72 yBot 0.08 yH], ... 'FontName', fontName, ... 'FontSize', trendFontSz, ... 'ForegroundColor', fgColor, ... @@ -97,7 +100,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', obj.Units, ... 'Units', 'normalized', ... - 'Position', [0.80 0.02 0.18 0.96], ... + 'Position', [0.80 yBot 0.18 yH], ... 'FontName', fontName, ... 'FontSize', titleFontSz, ... 'ForegroundColor', fgColor * 0.5 + bgColor * 0.5, ... diff --git a/libs/Dashboard/StatusWidget.m b/libs/Dashboard/StatusWidget.m index d5948128..27c90145 100644 --- a/libs/Dashboard/StatusWidget.m +++ b/libs/Dashboard/StatusWidget.m @@ -59,18 +59,18 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font size - oldUnits = get(parentPanel, 'Units'); - set(parentPanel, 'Units', 'pixels'); - pxPos = get(parentPanel, 'Position'); - set(parentPanel, 'Units', oldUnits); - pH = pxPos(4); - fontSz = max(7, min(14, round(pH * 0.28))); + % Adaptive font size — based on content area (panel minus + % the 28-px button bar at the top). + [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); + fontSz = max(7, min(14, round(pHContent * 0.34))); + + yBot = 0.05; + yH = max(0.10, contentTop - yBot - 0.02); % Layout: [dot] [Name: value Units] obj.hAxes = axes('Parent', parentPanel, ... 'Units', 'normalized', ... - 'Position', [0.02 0.1 0.12 0.8], ... + 'Position', [0.02 yBot 0.12 yH], ... 'Visible', 'off', ... 'XLim', [-1.3 1.3], 'YLim', [-1.3 1.3], ... 'DataAspectRatio', [1 1 1], ... @@ -87,7 +87,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.16 0.02 0.82 0.96], ... + 'Position', [0.16 yBot 0.82 yH], ... 'FontName', fontName, ... 'FontSize', fontSz, ... 'FontWeight', 'bold', ... From 4abe5d734e52e88e89b6860c52c3c78f7f153b0d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:09:21 +0200 Subject: [PATCH 21/26] Revert "fix(dashboard): widget content respects the 28-px button bar at panel top" This reverts commit 4e8384a9eb0120d1f19ff95d4c84d1b5d0474414. --- libs/Dashboard/DashboardWidget.m | 37 -------------------------------- libs/Dashboard/IconCardWidget.m | 26 +++++++++------------- libs/Dashboard/NumberWidget.m | 29 +++++++++++-------------- libs/Dashboard/StatusWidget.m | 18 ++++++++-------- 4 files changed, 32 insertions(+), 78 deletions(-) diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m index 9180098d..cf53b3ea 100644 --- a/libs/Dashboard/DashboardWidget.m +++ b/libs/Dashboard/DashboardWidget.m @@ -230,43 +230,6 @@ function setTimeRange(~, ~, ~) end end end - - function [pxW, pxH, pxHContent, contentTopFrac] = panelMetrics_(~, parentPanel) - %PANELMETRICS_ Pixel dimensions and the content area below the - % WidgetButtonBar (28 px tall, 2 px inset, 2 px breathing room - % below the bar — see DashboardLayout.getOrCreateButtonBar_). - % - % Subclasses use pxHContent for adaptive font sizing so values - % aren't clipped under the bar, and contentTopFrac for the top - % edge of normalized-units uicontrols/axes inside parentPanel. - % - % Outputs: - % pxW — panel width in pixels - % pxH — panel height in pixels - % pxHContent — usable content height (pxH - 32), >= 20 - % contentTopFrac — y-coordinate (in normalized units) of the - % top edge of the content area inside the - % panel; use as the upper Y for uicontrol - % Position rectangles to keep them clear of - % the button bar. - BUTTON_BAR_RESERVE_PX = 32; % 28 px bar + 4 px breathing - try - oldUnits = get(parentPanel, 'Units'); - set(parentPanel, 'Units', 'pixels'); - pp = get(parentPanel, 'Position'); - set(parentPanel, 'Units', oldUnits); - pxW = pp(3); - pxH = pp(4); - catch - pxW = 200; pxH = 100; - end - pxHContent = max(20, pxH - BUTTON_BAR_RESERVE_PX); - if pxH > 0 - contentTopFrac = pxHContent / pxH; - else - contentTopFrac = 1; - end - end end % NOTE: Conceptually abstract -- every subclass MUST override these methods. diff --git a/libs/Dashboard/IconCardWidget.m b/libs/Dashboard/IconCardWidget.m index 54d731cd..65fc2c9d 100644 --- a/libs/Dashboard/IconCardWidget.m +++ b/libs/Dashboard/IconCardWidget.m @@ -97,24 +97,18 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font size from content area (panel minus the 28-px - % button bar at the top), so text isn't clipped under the bar. - [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); - fontSz = max(7, min(14, round(pHContent * 0.34))); - - % All sub-controls fit within [0, contentTop] vertically. - yMid = contentTop / 2; - iconY = max(0.05, yMid - 0.35 * contentTop); - iconH = max(0.20, 0.70 * contentTop); - valueY = max(0.05, yMid); - valueH = max(0.20, contentTop - valueY - 0.02); - labelY = 0.02; - labelH = max(0.15, yMid - labelY - 0.02); + % Adaptive font size from panel pixel height + oldUnits = get(parentPanel, 'Units'); + set(parentPanel, 'Units', 'pixels'); + pxPos = get(parentPanel, 'Position'); + set(parentPanel, 'Units', oldUnits); + pH = pxPos(4); + fontSz = max(7, min(14, round(pH * 0.28))); % Icon axes — small square at left, circle fits inside unit square obj.hIconAx = axes('Parent', parentPanel, ... 'Units', 'normalized', ... - 'Position', [0.02 iconY 0.16 iconH], ... + 'Position', [0.02 0.15 0.16 0.70], ... 'Visible', 'off', ... 'DataAspectRatio', [1 1 1], ... 'XLim', [-1.2 1.2], ... @@ -134,7 +128,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '--', ... 'Units', 'normalized', ... - 'Position', [0.20 valueY 0.75 valueH], ... + 'Position', [0.20 0.45 0.75 0.50], ... 'FontName', fontName, ... 'FontSize', fontSz + 2, ... 'FontWeight', 'bold', ... @@ -147,7 +141,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.20 labelY 0.75 labelH], ... + 'Position', [0.20 0.05 0.75 0.40], ... 'FontName', fontName, ... 'FontSize', max(6, fontSz - 1), ... 'FontWeight', 'normal', ... diff --git a/libs/Dashboard/NumberWidget.m b/libs/Dashboard/NumberWidget.m index 4e1df5fb..29647f6f 100644 --- a/libs/Dashboard/NumberWidget.m +++ b/libs/Dashboard/NumberWidget.m @@ -46,26 +46,23 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font sizes based on the content area (panel height - % minus the 28-px button bar at the top), so values aren't - % clipped under the bar on short panels. - [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); + % Adaptive font sizes based on panel pixel height + oldUnits = get(parentPanel, 'Units'); + set(parentPanel, 'Units', 'pixels'); + pxPos = get(parentPanel, 'Position'); + set(parentPanel, 'Units', oldUnits); + pH = pxPos(4); % panel height in pixels - valueFontSz = max(8, min(28, round(pHContent * 0.55))); - titleFontSz = max(7, min(14, round(pHContent * 0.27))); - trendFontSz = max(6, min(16, round(pHContent * 0.30))); - - % All uicontrols span [yBot, contentTop] vertically, leaving - % the top strip clear of the button bar. - yBot = 0.02; - yH = max(0.05, contentTop - yBot); + valueFontSz = max(8, min(28, round(pH * 0.45))); + titleFontSz = max(7, min(14, round(pH * 0.22))); + trendFontSz = max(6, min(16, round(pH * 0.25))); % Horizontal layout: [Title | Value+Trend | Units] obj.hTitleText = uicontrol('Parent', parentPanel, ... 'Style', 'text', ... 'String', obj.Title, ... 'Units', 'normalized', ... - 'Position', [0.02 yBot 0.28 yH], ... + 'Position', [0.02 0.02 0.28 0.96], ... 'FontName', fontName, ... 'FontSize', titleFontSz, ... 'FontWeight', 'bold', ... @@ -77,7 +74,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '--', ... 'Units', 'normalized', ... - 'Position', [0.31 yBot 0.40 yH], ... + 'Position', [0.31 0.02 0.40 0.96], ... 'FontName', fontName, ... 'FontSize', valueFontSz, ... 'FontWeight', 'bold', ... @@ -89,7 +86,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.72 yBot 0.08 yH], ... + 'Position', [0.72 0.02 0.08 0.96], ... 'FontName', fontName, ... 'FontSize', trendFontSz, ... 'ForegroundColor', fgColor, ... @@ -100,7 +97,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', obj.Units, ... 'Units', 'normalized', ... - 'Position', [0.80 yBot 0.18 yH], ... + 'Position', [0.80 0.02 0.18 0.96], ... 'FontName', fontName, ... 'FontSize', titleFontSz, ... 'ForegroundColor', fgColor * 0.5 + bgColor * 0.5, ... diff --git a/libs/Dashboard/StatusWidget.m b/libs/Dashboard/StatusWidget.m index 27c90145..d5948128 100644 --- a/libs/Dashboard/StatusWidget.m +++ b/libs/Dashboard/StatusWidget.m @@ -59,18 +59,18 @@ function render(obj, parentPanel) fgColor = theme.ForegroundColor; fontName = theme.FontName; - % Adaptive font size — based on content area (panel minus - % the 28-px button bar at the top). - [~, ~, pHContent, contentTop] = obj.panelMetrics_(parentPanel); - fontSz = max(7, min(14, round(pHContent * 0.34))); - - yBot = 0.05; - yH = max(0.10, contentTop - yBot - 0.02); + % Adaptive font size + oldUnits = get(parentPanel, 'Units'); + set(parentPanel, 'Units', 'pixels'); + pxPos = get(parentPanel, 'Position'); + set(parentPanel, 'Units', oldUnits); + pH = pxPos(4); + fontSz = max(7, min(14, round(pH * 0.28))); % Layout: [dot] [Name: value Units] obj.hAxes = axes('Parent', parentPanel, ... 'Units', 'normalized', ... - 'Position', [0.02 yBot 0.12 yH], ... + 'Position', [0.02 0.1 0.12 0.8], ... 'Visible', 'off', ... 'XLim', [-1.3 1.3], 'YLim', [-1.3 1.3], ... 'DataAspectRatio', [1 1 1], ... @@ -87,7 +87,7 @@ function render(obj, parentPanel) 'Style', 'text', ... 'String', '', ... 'Units', 'normalized', ... - 'Position', [0.16 yBot 0.82 yH], ... + 'Position', [0.16 0.02 0.82 0.96], ... 'FontName', fontName, ... 'FontSize', fontSz, ... 'FontWeight', 'bold', ... From 9b3b155eb780e33ebada53a6a6b521252849ad41 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:10:10 +0200 Subject: [PATCH 22/26] style(dashboard): wrap LastSyncedTimeRange_ comment to satisfy mh_style line_length The 195-char inline comment introduced in d3478d2b (260508-llw) exceeds the 160-char project limit and broke MISS_HIT mh_style on every PR opened against main. Lifts the explanation to a 3-line block comment above the property declaration; same content, identical semantics, no runtime impact. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/Dashboard/DashboardEngine.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index e08caed0..f36e11a1 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -69,7 +69,10 @@ hTimeResetBtn = [] % Reset button on time panel (260508-f7p — needed for theme switch) SliderDebounceTimer = [] % MATLAB timer for coalescing rapid slider events TimeRangeSelector_ = [] % TimeRangeSelector handle (replaces dual sliders) - LastSyncedTimeRange_ = [] % [tStart tEnd] cache of most recent broadcast (260508-llw); used by switchPage to re-apply current synced window to widgets realized on tab-switch + % [tStart tEnd] cache of most recent broadcast (260508-llw); used by + % switchPage to re-apply the current synced window to widgets that + % get realized on tab-switch. + LastSyncedTimeRange_ = [] Progress_ = [] % DashboardProgress instance (active during render) % Stale-data banner (shown during live mode when a widget's tMax stops advancing) hStaleBanner = [] % uipanel overlay; hidden unless live+stale+!dismissed From 392dded0cbd0ffd6af813995e600a1f83caad400 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:18:08 +0200 Subject: [PATCH 23/26] fix(companion): make discover-event-store handle-identity check Octave-portable Octave doesn't auto-define eq (==) on user-defined handle classes the way MATLAB does, so `found == es` errored with "eq method not defined for EventStore class" on the Octave CI job. Replace with a mutation- based identity check: set a sentinel on `es` and confirm `found` sees the same change. Proves both references point at the same object without relying on overloaded operators. Re-verified: runDiscoverEventStoreTests passes 3/3 locally on MATLAB. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/FastSenseCompanion/runDiscoverEventStoreTests.m | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/FastSenseCompanion/runDiscoverEventStoreTests.m b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m index c06d157c..bdffb59e 100644 --- a/libs/FastSenseCompanion/runDiscoverEventStoreTests.m +++ b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m @@ -33,7 +33,14 @@ function runDiscoverEventStoreTests() TagRegistry.register('m', mon); found = companionDiscoverEventStore(); - assert(~isempty(found) && found == es, ... + % Portable handle-identity check: MATLAB auto-defines == on handle + % classes but Octave doesn't (errors with "eq method not defined"). + % Mutate a property on `es` and confirm the same change is visible + % through `found` — proves both references point at the same object + % without relying on overloaded operators. + es.MaxBackups = 1337; + assert(~isempty(found) && isa(found, 'EventStore') && ... + found.MaxBackups == 1337, ... 'Test 2: companionDiscoverEventStore must return the MonitorTag''s EventStore.'); catch e TagRegistry.clear(); From c2f66d5e33afcb15b6d785a1fa2a3b2aefca2513 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:27:21 +0200 Subject: [PATCH 24/26] test(monitor-tag): cover open-event branches of recompute_ dedup fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two regression tests addressing codecov's 50% patch coverage on the fireEventsOnRisingEdges_ dedup change (PR #123 codecov comment, 16 uncovered lines). * testRecomputeWithOpenRunDoesNotDuplicate — trailing IsOpen=true run must not be re-emitted on recompute after invalidate. The original dedup test only exercised the closed-run path; this covers the parallel "skip already-emitted open" branch in the trailing-open-run emission block. * testRecomputeClosesExistingOpenEventInPlace — when a recompute_ sees a run that was previously stored as open but has now closed (parent grew with samples ending the run), the existing open event must be closed in place via EventStore.closeEvent — same Id preserved, EndTime set, IsOpen flipped to false — instead of being appended as a separate duplicate. Exercises the "open->closed transition" branch + the cache_.openEventId_ re-seeding path. Note SensorTag.updateData REPLACES X/Y wholesale (not append-only); the test passes the full extended grid [1..10] with the historical run preserved and trailing samples that drop below threshold, mirroring how the live pipeline re-publishes data files after each tick. All 14 tests in TestMonitorTagEvents pass; 67/67 across the 6 monitor + event-store suites; 62/64 in TestFastSenseCompanion (2 pre-existing fails unrelated to this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/suite/TestMonitorTagEvents.m | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/suite/TestMonitorTagEvents.m b/tests/suite/TestMonitorTagEvents.m index fca7e2d4..154991c2 100644 --- a/tests/suite/TestMonitorTagEvents.m +++ b/tests/suite/TestMonitorTagEvents.m @@ -214,6 +214,78 @@ function testNoDuplicateEventsOnRecomputeAfterInvalidate(testCase) 'multiple invalidate/getXY cycles must not accumulate duplicates'); end + function testRecomputeWithOpenRunDoesNotDuplicate(testCase) + %TESTRECOMPUTEWITHOPENRUNDOESNOTDUPLICATE + % Counterpart to the closed-run dedup test: a trailing OPEN + % run (Phase 1012 IsOpen=true emission path) must also not + % re-emit on recompute_ after invalidate. + x = 1:6; + y = [0 0 0 10 10 10]; % trailing run still open at the end + parent = SensorTag('p', 'X', x, 'Y', y); + store = EventStore(''); + m = MonitorTag('m', parent, @(xx, yy) yy > 5, 'EventStore', store); + + [~, ~] = m.getXY(); + evs1 = store.getEvents(); + testCase.verifyEqual(numel(evs1), 1, 'first getXY must emit 1 open event'); + testCase.verifyTrue(evs1(1).IsOpen, 'event must be IsOpen=true'); + + % Invalidate (simulates parent.updateData cascade) and recompute. + m.invalidate(); + [~, ~] = m.getXY(); + evs2 = store.getEvents(); + testCase.verifyEqual(numel(evs2), 1, ... + 'open-event recompute must NOT append a duplicate'); + testCase.verifyTrue(evs2(1).IsOpen, ... + 'existing event must remain open'); + + % Multiple invalidate/getXY cycles still stable. + for i = 1:3 + m.invalidate(); + [~, ~] = m.getXY(); + end + testCase.verifyEqual(numel(store.getEvents()), 1, ... + 'repeated open-run recomputes must not accumulate duplicates'); + end + + function testRecomputeClosesExistingOpenEventInPlace(testCase) + %TESTRECOMPUTECLOSESEXISTINGOPENEVENTINPLACE + % When a recompute_ sees a run that was previously stored as + % open but has now closed (because the parent grew with new + % samples that ended the run), the existing open event must + % be closed in place via EventStore.closeEvent — NOT appended + % as a separate duplicate closed event. + parent = SensorTag('p', 'X', 1:6, 'Y', [0 0 0 10 10 10]); + store = EventStore(''); + m = MonitorTag('m', parent, @(xx, yy) yy > 5, 'EventStore', store); + + [~, ~] = m.getXY(); + evs1 = store.getEvents(); + testCase.verifyEqual(numel(evs1), 1); + testCase.verifyTrue(evs1(1).IsOpen, 'precondition: stored as open'); + openId = char(evs1(1).Id); + + % Parent grows: replace the entire grid with the original run + % followed by samples that drop back below threshold. SensorTag + % updateData REPLACES the data wholesale — not append. This is + % what the live pipeline does when it re-publishes the full + % data file. updateData fires invalidate on the monitor, which + % wipes cache_ (including openEventId_) and forces the next + % getXY into a full recompute_ over the new grid. + parent.updateData(1:10, [0 0 0 10 10 10 0 0 0 0]); + [~, ~] = m.getXY(); % triggers recompute_ on the full grid + + evs2 = store.getEvents(); + testCase.verifyEqual(numel(evs2), 1, ... + 'open->closed transition must NOT append a duplicate event'); + testCase.verifyFalse(evs2(1).IsOpen, ... + 'existing open event must now be closed in place'); + testCase.verifyEqual(char(evs2(1).Id), openId, ... + 'event Id must be preserved (close in place, not re-append)'); + testCase.verifyEqual(evs2(1).EndTime, 6, ... + 'EndTime must reflect the last in-run sample (x=6)'); + end + % ---- Native parent-X units ---- function testEventStartEndTimesUseNativeParentUnits(testCase) From dc895b02c3923ea18541fc557d4d3dc753bebfdc Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:35:41 +0200 Subject: [PATCH 25/26] test(dashboard): skip test_dashboard_time_sync_all_pages on Octave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing Octave failure inherited from main commit d3478d2b (260508-llw). The cross-page time-broadcast path eventually calls xlim(), and Octave's __axis_limits__ wraps that in addlistener(..., 'PostSet', ...), which it doesn't implement — the suite errors with `'PostSet' undefined` on every case. The implementation under test is MATLAB-only by virtue of its xlim() dependency; nothing in the test logic is genuinely Octave- hostile. Mirror the existing pattern from test_dashboard_builder_interaction.m and other Octave-skipped flat tests: print SKIPPED and return cleanly. Class-based TestDashboardTimeSync (MATLAB-only suite) keeps full coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_dashboard_time_sync_all_pages.m | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_dashboard_time_sync_all_pages.m b/tests/test_dashboard_time_sync_all_pages.m index 57d68779..3dbf3389 100644 --- a/tests/test_dashboard_time_sync_all_pages.m +++ b/tests/test_dashboard_time_sync_all_pages.m @@ -12,6 +12,16 @@ function test_dashboard_time_sync_all_pages() % 3. case_unrealized_widget_on_tab_switch_inherits_synced_range (LLW-03) % 4. case_manual_zoom_widget_opts_out_of_broadcast (per-widget contract) % 5. case_single_page_dashboard_unaffected (allPageWidgets fallthrough) + if exist('OCTAVE_VERSION', 'builtin') + % Octave's __axis_limits__ wraps xlim() in a `addlistener(..., 'PostSet', ...)` + % path that requires the MATLAB Property Event system; on Octave it + % errors with `'PostSet' undefined`. The broadcastTimeRange code path + % under test ends in xlim(), so this entire suite is unreachable on + % Octave through no fault of the implementation. Verified manually + % via MATLAB; same coverage exists in suite/TestDashboardTimeSync. + fprintf(' SKIPPED on Octave (xlim() PostSet listener not supported by __axis_limits__).\n'); + return; + end add_paths_(); nPassed = 0; From e2fe8b7b485d32cdc81862961af05cfbdf44e3b1 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 8 May 2026 22:45:29 +0200 Subject: [PATCH 26/26] test(dashboard): search hCellPanel for chrome buttons after WidgetContentPanel split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing failure inherited from main commits d3478d2b/d2a8594 (260508-mhv full-width-widget-bar series). When DashboardLayout introduced WidgetContentPanel, widget.hPanel was redefined to point at the content sub-panel BELOW the chrome bar — the bar itself, hosting DetachButton + InfoIconButton, lives in the new outer hCellPanel. The test files weren't updated when that split landed, so findobj(w.hPanel, 'Tag', 'DetachButton'/'InfoIconButton') searches the wrong subtree and returns 0×0 GraphicsPlaceholder. Updates 7 findobj sites across TestDashboardDetach.m (1) and TestInfoTooltip.m (6) to search hCellPanel instead. No production behavior changes; same coverage, pointed at the correct panel. Verified locally: TestDashboardDetach 10/10 + TestInfoTooltip 13/13 pass on MATLAB after the fix. Same fix is needed on main; main's CI has been red since 939d902. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/suite/TestDashboardDetach.m | 2 +- tests/suite/TestInfoTooltip.m | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/suite/TestDashboardDetach.m b/tests/suite/TestDashboardDetach.m index 5f9e1a04..3d59647f 100644 --- a/tests/suite/TestDashboardDetach.m +++ b/tests/suite/TestDashboardDetach.m @@ -86,7 +86,7 @@ function testDetachButtonInjected(testCase) testCase.addTeardown(@() close(d.hFigure)); w = d.Widgets{1}; - btn = findobj(w.hPanel, 'Tag', 'DetachButton'); + btn = findobj(w.hCellPanel, 'Tag', 'DetachButton'); testCase.verifyNotEmpty(btn, ... 'DetachButton uicontrol should be injected into every widget panel after render()'); end diff --git a/tests/suite/TestInfoTooltip.m b/tests/suite/TestInfoTooltip.m index b679802f..4d9f1f96 100644 --- a/tests/suite/TestInfoTooltip.m +++ b/tests/suite/TestInfoTooltip.m @@ -49,7 +49,7 @@ function testInfoIconAppearsWhenDescriptionSet(testCase) % INFO-01: widget with Description gets an InfoIconButton after realizeWidget. widget = testCase.makeWidget('## Hello\n\nWorld'); testCase.Layout.realizeWidget(widget); - btn = findobj(widget.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(widget.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyNotEmpty(btn, 'InfoIconButton should appear when Description is set'); end @@ -57,7 +57,7 @@ function testInfoIconAbsentWhenDescriptionEmpty(testCase) % INFO-02: widget without Description gets no InfoIconButton after realizeWidget. widget = testCase.makeWidget(); % no Description testCase.Layout.realizeWidget(widget); - btn = findobj(widget.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(widget.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyEmpty(btn, 'InfoIconButton should NOT appear when Description is empty'); end @@ -146,7 +146,7 @@ function testAllWidgetTypesGetIconWhenDescriptionSet(testCase) w.hPanel = hp; layout = DashboardLayout(); layout.realizeWidget(w); - btn = findobj(w.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(w.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyNotEmpty(btn, ... sprintf('%s should have InfoIconButton when Description is set', cls)); catch e @@ -168,7 +168,7 @@ function testRealizeWidgetWithDescriptionAddsIcon(testCase) 'Position', [0 0 1 1], 'BorderType', 'none'); widget.hPanel = hp; layout.realizeWidget(widget); - btn = findobj(widget.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(widget.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyNotEmpty(btn, ... 'InfoIconButton should appear after realizeWidget with non-empty Description'); end @@ -182,7 +182,7 @@ function testEndToEndInfoIconAppearsViaEngine(testCase) set(d.hFigure, 'Visible', 'off'); testCase.addTeardown(@() close(d.hFigure)); w = d.Widgets{1}; - btn = findobj(w.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(w.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyNotEmpty(btn, ... 'InfoIconButton should appear via DashboardEngine.render() for widget with Description'); end @@ -195,7 +195,7 @@ function testEndToEndNoIconWhenDescriptionEmpty(testCase) set(d.hFigure, 'Visible', 'off'); testCase.addTeardown(@() close(d.hFigure)); w = d.Widgets{1}; - btn = findobj(w.hPanel, 'Tag', 'InfoIconButton'); + btn = findobj(w.hCellPanel, 'Tag', 'InfoIconButton'); testCase.verifyEmpty(btn, ... 'InfoIconButton should NOT appear for widget without Description'); end