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/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/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 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 diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m new file mode 100644 index 00000000..0224baf7 --- /dev/null +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -0,0 +1,679 @@ +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 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 refresh(obj) + %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) + %GETCANVASFORTEST_ Test-only accessor for the canvas helper. + 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_) && ... + strcmp(obj.AutoTimer_.Running, 'on'); + end + + function t = getAutoTimerForTest_(obj) + %GETAUTOTIMERFORTEST_ Test accessor: return AutoTimer_ handle (may be []). + 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) + 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 (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 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', '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 + obj.refresh(); + end + + 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'); + + % 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], ... + 'BackgroundColor', t.WidgetBackground, ... + 'BorderType', 'none'); + obj.AxesPanel_ = uipanel('Parent', obj.hFigure, ... + 'Units', 'normalized', ... + '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.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.18 0.10 0.78 0.85], ... + 'Color', t.WidgetBackground, ... + '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. + 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}, ... + 'Tag', 'PresetBtn', ... + 'Units', 'normalized', ... + '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 + + % 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', '', ... + '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], ... + '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', '', ... + 'TooltipString', 'Custom end time (e.g. 2026-05-08 15:30:00)', ... + 'Callback', @(src, ~) obj.onFromToEdited_()); + + % Severity toggles. + 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), ... + 'Value', 1, ... + 'Units', 'normalized', ... + '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 + + % 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, ... + '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', '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 -------------------------------- + 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)); + 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 + + 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 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'); + 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 + + 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/libs/FastSenseCompanion/EventGanttCanvas.m b/libs/FastSenseCompanion/EventGanttCanvas.m new file mode 100644 index 00000000..b606e389 --- /dev/null +++ b/libs/FastSenseCompanion/EventGanttCanvas.m @@ -0,0 +1,202 @@ +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 + + 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]); + + % 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 + % 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) + 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/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 60dca88a..848317ce 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -15,23 +15,28 @@ % 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: % 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) @@ -56,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 @@ -85,13 +91,15 @@ 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) 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 @@ -105,6 +113,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 +154,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 +194,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, ... @@ -202,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; @@ -404,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_) @@ -668,6 +718,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 @@ -686,6 +737,7 @@ function stopLiveMode(obj) obj.IsLive = false; obj.updateLiveButton_(); obj.addLogEntry('info', 'Live mode OFF'); + notify(obj, 'LiveModeChanged'); end function toggleLiveMode(obj) @@ -895,6 +947,34 @@ 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 + + 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) @@ -1266,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/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..bdffb59e --- /dev/null +++ b/libs/FastSenseCompanion/runDiscoverEventStoreTests.m @@ -0,0 +1,74 @@ +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. + + nPassed = 0; + + % --- Test 1: empty registry -> [] returned --- + TagRegistry.clear(); + store = companionDiscoverEventStore(); + assert(isempty(store), ... + 'Test 1: companionDiscoverEventStore must return [] for an empty registry.'); + TagRegistry.clear(); + nPassed = nPassed + 1; + + % --- Test 2: MonitorTag with EventStore -> handle returned --- + storePath = ''; + 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(); + % 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(); + if exist(storePath, 'file') == 2 + delete(storePath); + end + rethrow(e); + end + TagRegistry.clear(); + if exist(storePath, 'file') == 2 + delete(storePath); + end + nPassed = nPassed + 1; + + % --- Test 3: MonitorTag without EventStore -> [] returned --- + TagRegistry.clear(); + 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(); + 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/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/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/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m new file mode 100644 index 00000000..910783a8 --- /dev/null +++ b/tests/suite/TestCompanionEventViewer.m @@ -0,0 +1,355 @@ +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 + + % --- 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 + + % --- 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 + + % --- 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 + + % --- 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 + + % --- 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 + + % --- 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 + +% --- 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 + +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 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/TestEventGanttCanvas.m b/tests/suite/TestEventGanttCanvas.m new file mode 100644 index 00000000..a7d3f2e2 --- /dev/null +++ b/tests/suite/TestEventGanttCanvas.m @@ -0,0 +1,144 @@ +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 + + 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 + +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 + +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 diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index 46f338b8..eaeb4dad 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1006,6 +1006,184 @@ function testCloseEventsDetachedWindowOnlyAffectsEvents(testCase) 'Live detached uifigure must NOT be torn down by the events close'); end + % ---- Task 1: Auto-discover EventStore from registry ---- + + 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()); + testCase.verifyWarningFree(@() evalc('runDiscoverEventStoreTests()'), ... + '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 + + % ---- 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 + + % ---- 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) 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 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 diff --git a/tests/suite/TestMonitorTagEvents.m b/tests/suite/TestMonitorTagEvents.m index 1c86bfe5..154991c2 100644 --- a/tests/suite/TestMonitorTagEvents.m +++ b/tests/suite/TestMonitorTagEvents.m @@ -176,6 +176,116 @@ 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 + + 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) 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 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;