diff --git a/.planning/STATE.md b/.planning/STATE.md index d45c0d4a..96f9863f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ milestone: v3.0 milestone_name: FastSense Companion status: shipped last_updated: "2026-05-12T09:20:00.000Z" -last_activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo. +last_activity: 2026-05-14 -- Quick task 260513-s0y shipped (PR #143): Tile + Close all buttons on FastSenseCompanion top toolbar with 3 tracking fixes (sync from Engines_, public trackOpenedFigure hook, de-maximize + Units=pixels coercion). Verified live on industrial-plant demo. 9/9 sub-tests + 64/64 regression PASS. progress: total_phases: 6 completed_phases: 2 @@ -20,7 +20,7 @@ Phase: 1028 Plan: Not started Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30 Status: Awaiting next milestone (run `/gsd:new-milestone` to scope v3.x or v4.0) -Last activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit V/A/L buttons to WidgetButtonBar with backward-compatible default. Verified on live industrial-plant demo. +Last activity: 2026-05-14 - Quick task 260513-s0y shipped as PR #143 (commits 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88). FastSenseCompanion: Tile + Close all toolbar buttons. Three tracking fixes uncovered by live verification: (1) syncOpenedFigures_ walks Engines_ — DashboardListPane fires OpenDashboardRequested BEFORE engine.render(), and run_demo's pre-rendered demo dashboard never goes through that event flow; (2) public trackOpenedFigure hook on the companion called from InspectorPane.onOpenDetail_ + CompanionEventViewer.openEventDashboard_ which spawn figures directly bypassing the event flow; (3) de-maximize + Units=pixels coercion before set(Position) — DashboardEngine.render defaults to Units=normalized so pixel rectangles got treated as screen fractions, pushing figures off-canvas (root cause of "Tile button does nothing" report). 9/9 sub-tests + 64/64 regression PASS. Verified on live industrial-plant demo. ### Quick Tasks Completed @@ -65,6 +65,7 @@ Last activity: 2026-05-13 - Completed quick task 260513-sfp: Added auto-y-limit | 260513-ovt | Preserve widget X and Y views across Live ticks + Follow toggle reaches every page — (1) added LiveViewMode='follow' guard inside FastSenseWidget.autoScaleY_, (2) removed `autoScaleY_(y)` from FastSenseWidget.refresh/update, (3) removed `broadcastTimeRange(tStart, tEnd)` from DashboardEngine.onLiveTick, (4) flipped FastSenseWidget.LiveViewMode default 'reset'→'preserve', (5) made FastSenseToolbar.syncFollowState public so FastSense.onXLimChanged's auto-disengage hook actually syncs the Follow button, (6) made DashboardEngine.{allPageWidgets,activePageWidgets} public + onFollowToggle uses allPageWidgets() so Follow actually flips every FastSenseWidget across all pages on multi-page dashboards (was silently no-op via swallowed MethodRestricted). Live mode is now strictly "append data only"; Follow does width-preserving slide with 2% right-edge gap. test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5, test_dashboard_range_selector_integration 2/2; verified end-to-end on industrial plant demo (Follow ON: XLim+0.140d toward tail, width preserved exactly, 2/2 widgets switched; OFF: 2/2 reverted) | 2026-05-13 | 498a5f3, ca5be95, 8d41c48, 63cdff4 | — | [260513-ovt-when-follow-button-is-pressed-y-axis-lim](./quick/260513-ovt-when-follow-button-is-pressed-y-axis-lim/) | | 260513-q7w | Debounced post-resize refresh + ZOMBIE-PANEL fix that stops widgets going white during drag-resize and tab switching — TWO parallel timers on every figure resize event (300 ms cheap two-pass refresh + 1.2 s unconditional rerenderWidgets backstop). switchPage cancels both timers AND waits up to 3 s for in-flight rerenderWidgets to complete before mutating state. `IsRerendering_` flag prevents rerender-cascade scheduling. Re-entrancy guard aborts instead of self-rescheduling. **Root-cause fix**: rerenderWidgets now deletes the OUTER cell panel (via hCellPanel, falling back to hPanel for pre-realization widgets) — previous code deleted only `hPanel` which after realization points to the INNER content panel, leaving the outer cell + its WidgetButtonBar chrome alive on the canvas as "zombies" that stacked up over multiple rerenders and painted over freshly switched-to pages. test_dashboard_range_selector_integration 2/2, test_dashboard_time_sync_all_pages 5/5; canvas-children-count canary verifies zero zombie accumulation across 4 rerenders + resize + tab switch (constant 29) | 2026-05-13 | 577bf95, 99c8808, 4eda604, bc305dc, 54d5aa0, 20bcd4c | — | [260513-q7w-during-dashboard-figure-resize-fastsense](./quick/260513-q7w-during-dashboard-figure-resize-fastsense/) | | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | +| 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | ## Progress Bar diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 7de21d8a..e80bcaae 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -1262,6 +1262,18 @@ function openEventDashboard_(obj, ev) end obj.bringFigureToFront_(d.hFigure); + + % Register with the companion so the Tile / Close all buttons treat + % the event-detail dashboard like any other companion-opened window. + % The DashboardEngine is ephemeral (not in Companion_.Engines_), so + % syncOpenedFigures_ won't find it on its own. + try + if ~isempty(obj.Companion_) && isvalid(obj.Companion_) && ... + ismethod(obj.Companion_, 'trackOpenedFigure') + obj.Companion_.trackOpenedFigure(d.hFigure); + end + catch + end end function onTableRowSelectionChanged_(obj, evt) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 249db577..acc2af04 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -91,6 +91,13 @@ 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) + % S0Y-01/02 -- companion-opened figure tracking. + OpenedFigures_ = [] % column vector of figure handles the companion opened + % (dashboards via onOpenDashboardRequested_, + % ad-hoc plots via onOpenAdHocPlotRequested_). + % Pruned of invalid handles before each iteration. + hTileBtn_ = [] % toolbar uibutton: Tile windows + hCloseAllBtn_ = [] % toolbar uibutton: Close all end methods (Access = public) @@ -224,11 +231,15 @@ obj.hToolbarPanel_.Layout.Column = [1 3]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; - % Inner 1x4 grid — col 1 = Events viewer button (Task 13); - % col 2 = Live: ON/OFF button; col 3 = flex spacer; - % col 4 = gear button. - hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 4]); - hToolbarGrid.ColumnWidth = {110, 110, '1x', 36}; + % Inner 1x6 grid: + % col 1 = Events viewer button (Task 13) (110) + % col 2 = Live: ON/OFF button (110) + % col 3 = Tile windows (S0Y-01) ( 70) + % col 4 = Close all (S0Y-02) ( 90) + % col 5 = flex spacer ('1x') + % col 6 = Settings gear ( 36) + hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 6]); + hToolbarGrid.ColumnWidth = {110, 110, 70, 90, '1x', 36}; hToolbarGrid.RowHeight = {'1x'}; hToolbarGrid.Padding = [4 0 4 0]; hToolbarGrid.ColumnSpacing = 8; @@ -259,10 +270,34 @@ obj.hLiveBtn_.Tooltip = 'Toggle live refresh of the inspector'; obj.hLiveBtn_.ButtonPushedFcn = @(~,~) obj.toggleLiveMode(); - % Col 4 — Settings gear. + % Col 3 — Tile windows (S0Y-01). + obj.hTileBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hTileBtn_.Layout.Row = 1; + obj.hTileBtn_.Layout.Column = 3; + obj.hTileBtn_.Text = 'Tile'; + obj.hTileBtn_.FontSize = 11; + obj.hTileBtn_.FontWeight = 'bold'; + obj.hTileBtn_.Tooltip = 'Arrange companion-opened windows in a grid'; + obj.hTileBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hTileBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hTileBtn_.ButtonPushedFcn = @(~,~) obj.tileOpenedWindows(); + + % Col 4 — Close all (S0Y-02). Uses Accent color to signal destructive action. + obj.hCloseAllBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hCloseAllBtn_.Layout.Row = 1; + obj.hCloseAllBtn_.Layout.Column = 4; + obj.hCloseAllBtn_.Text = 'Close all'; + obj.hCloseAllBtn_.FontSize = 11; + obj.hCloseAllBtn_.FontWeight = 'bold'; + obj.hCloseAllBtn_.Tooltip = 'Close every window the companion opened'; + obj.hCloseAllBtn_.BackgroundColor = obj.Theme_.Accent; + obj.hCloseAllBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hCloseAllBtn_.ButtonPushedFcn = @(~,~) obj.closeAllOpenedWindows(); + + % Col 6 — Settings gear. obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push'); obj.hSettingsBtn_.Layout.Row = 1; - obj.hSettingsBtn_.Layout.Column = 4; + obj.hSettingsBtn_.Layout.Column = 6; obj.hSettingsBtn_.Text = char(9881); % gear glyph obj.hSettingsBtn_.FontSize = 14; obj.hSettingsBtn_.Tooltip = 'Companion settings'; @@ -896,6 +931,144 @@ function openEventViewer(obj) obj.openEventViewer_(); end + function trackOpenedFigure(obj, hFig) + %TRACKOPENEDFIGURE Register a figure the companion opened so Tile / Close all see it. + % Public hook for code paths that spawn figures DIRECTLY (bypassing the + % OpenAdHocPlotRequested event flow) — for example InspectorPane's single-tag + % "Open Detail" handler, which calls openAdHocPlot inline. Pass the returned + % classical figure handle and the companion will dedupe + prune-aware-append + % it to OpenedFigures_. + % + % Silently no-ops on empty / invalid handles. + obj.trackOpenedFigure_(hFig); + end + + function tileOpenedWindows(obj) + %TILEOPENEDWINDOWS Arrange every figure the companion opened in a grid. + % Computes a roughly-square ceil(sqrt(N)) tiling on the monitor the + % companion lives on (or the primary monitor if that can't be determined), + % then sets each tracked figure's Position so the windows do not overlap. + % Figures opened outside the companion are not touched. Closed handles + % are pruned silently. + % + % No-op when no tracked figures exist (logs an info line for feedback). + % + % Errors: surfaced via uialert + log entry; never throws. + try + obj.syncOpenedFigures_(); + figs = obj.OpenedFigures_; + n = numel(figs); + if n == 0 + obj.addLogEntry('info', 'Tile: no companion-opened windows.'); + return; + end + + % Monitor selection -- use the monitor that contains the companion. + mons = get(groot, 'MonitorPositions'); % rows: [x y w h] + screenRect = mons(1, :); % default = primary + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + cp = obj.hFig_.Position; % [x y w h] + cx = cp(1) + cp(3)/2; cy = cp(2) + cp(4)/2; + for m = 1:size(mons, 1) + r = mons(m, :); + if cx >= r(1) && cx < r(1)+r(3) && cy >= r(2) && cy < r(2)+r(4) + screenRect = r; + break; + end + end + end + + % Reserve a margin so windows aren't flush with screen edges. + margin = 24; + gx = screenRect(1) + margin; + gy = screenRect(2) + margin; + gw = max(200, screenRect(3) - 2*margin); + gh = max(200, screenRect(4) - 2*margin); + + % Roughly-square grid: cols = ceil(sqrt(n)), rows = ceil(n/cols). + cols = ceil(sqrt(n)); + rows = ceil(n / cols); + tileW = floor(gw / cols); + tileH = floor(gh / rows); + + for k = 1:n + % Row-major fill, top-down so window 1 ends up top-left. + rIdx = ceil(k / cols); % 1..rows from top + cIdx = mod(k - 1, cols) + 1; % 1..cols from left + x = gx + (cIdx - 1) * tileW; + % MATLAB screen y grows upward -- flip so row 1 is at the top. + y = gy + (rows - rIdx) * tileH; + try + % distFig-style robustness: a maximized figure ignores + % set(Position) silently, and normalized units would + % treat our pixel rect as fractions of the screen -- + % both make Tile visually a no-op. Coerce both first. + f = figs(k); + try + if isprop(f, 'WindowState') && ... + ~strcmp(get(f, 'WindowState'), 'normal') + set(f, 'WindowState', 'normal'); + end + catch + end + try + set(f, 'Units', 'pixels'); + catch + end + set(f, 'Position', [x, y, tileW - 8, tileH - 8]); + catch + % Skip individual failures -- keep tiling the rest. + end + end + obj.addLogEntry('info', sprintf('Tiled %d window(s).', n)); + catch err + obj.addLogEntry('error', sprintf('Tile failed: %s', err.message)); + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, ... + sprintf('Failed to tile windows: %s', err.message), ... + 'FastSense Companion', 'Icon', 'error'); + end + end + end + + function closeAllOpenedWindows(obj) + %CLOSEALLOPENEDWINDOWS Close every figure the companion opened, then clear tracking. + % Iterates a SNAPSHOT of OpenedFigures_ and calls close(h) per handle -- + % honoring the figure's CloseRequestFcn (DashboardEngine's stops live + + % deletes the figure; openAdHocPlot's closeFcn_ does the same). Closed + % handles drop out via pruneOpenedFigures_ at the end. + % + % Figures opened outside the companion are not affected -- tracking is + % the only source of truth. + try + obj.syncOpenedFigures_(); + figs = obj.OpenedFigures_; % snapshot -- close() callbacks may mutate + n = numel(figs); + if n == 0 + obj.addLogEntry('info', 'Close all: no companion-opened windows.'); + return; + end + for k = 1:n + try + if ishandle(figs(k)) + close(figs(k)); + end + catch + % Per-figure failure -- continue with the rest. + end + end + obj.pruneOpenedFigures_(); + obj.addLogEntry('info', sprintf('Closed %d window(s).', n)); + catch err + obj.addLogEntry('error', sprintf('Close all failed: %s', err.message)); + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, ... + sprintf('Failed to close windows: %s', err.message), ... + 'FastSense Companion', 'Icon', 'error'); + end + end + end + function openEventViewer_internalForTest(obj) %OPENEVENTVIEWER_INTERNALFORTEST Test shim: call openEventViewer_ directly. obj.openEventViewer_(); @@ -911,6 +1084,24 @@ function openEventViewer_internalForTest(obj) f = obj.hFig_; end + function figs = getOpenedFiguresForTest_(obj) + %GETOPENEDFIGURESFORTEST_ Test helper: return the OpenedFigures_ tracking list. + % Used by test_companion_tile_close_buttons. Returns the raw column + % vector of figure handles (post-prune so callers see only valid + % handles). Do NOT call from production code -- this is a friend + % accessor for the test suite only. + obj.pruneOpenedFigures_(); + figs = obj.OpenedFigures_; + end + + function trackOpenedFigureForTest_(obj, hFig) + %TRACKOPENEDFIGUREFORTEST_ Test helper: drive the private trackOpenedFigure_. + % Lets test_companion_tile_close_buttons feed figure handles into + % OpenedFigures_ without spinning up a real DashboardListPane. Same + % dedupe + prune semantics as the production path. + obj.trackOpenedFigure_(hFig); + end + end methods (Access = private) @@ -1221,6 +1412,14 @@ function onOpenDashboardRequested_(obj, ~, ed) obj.resolveInspectorState_(); obj.addLogEntry('info', sprintf('Opened dashboard: %s', ... char(ed.Engine.Name))); + % S0Y-01: track the freshly opened dashboard figure so Tile / Close all see it. + try + if ~isempty(ed.Engine) && isvalid(ed.Engine) && ... + ~isempty(ed.Engine.hFigure) && ishandle(ed.Engine.hFigure) + obj.trackOpenedFigure_(ed.Engine.hFigure); + end + catch + end catch err obj.addLogEntry('error', sprintf('Open dashboard failed: %s', err.message)); uialert(obj.hFig_, err.message, 'FastSense Companion'); @@ -1347,6 +1546,57 @@ function clearEventViewerHandle_(obj) end end + function trackOpenedFigure_(obj, hFig) + %TRACKOPENEDFIGURE_ Append a figure handle to OpenedFigures_ (deduped, valid only). + if isempty(hFig) || ~ishandle(hFig); return; end + % Prune dead handles first; then dedupe by handle equality. + obj.pruneOpenedFigures_(); + for k = 1:numel(obj.OpenedFigures_) + if obj.OpenedFigures_(k) == hFig + return; % already tracked + end + end + obj.OpenedFigures_(end+1, 1) = hFig; + end + + function pruneOpenedFigures_(obj) + %PRUNEOPENEDFIGURES_ Drop closed / deleted handles from the tracking list. + if isempty(obj.OpenedFigures_); return; end + keep = arrayfun(@(h) ishandle(h) && isgraphics(h, 'figure'), ... + obj.OpenedFigures_); + obj.OpenedFigures_ = obj.OpenedFigures_(keep); + end + + function syncOpenedFigures_(obj) + %SYNCOPENEDFIGURES_ Reconcile OpenedFigures_ with reality before iterating. + % Two reasons we need this before every Tile / Close-all click: + % 1. DashboardListPane fires OpenDashboardRequested BEFORE it calls + % engine.render(), so the synchronous listener sees hFigure=[] + % and can't track on first open. + % 2. Engines passed into the constructor (or attached via setProject) + % may have already been rendered by the caller — they were never + % opened "through" the companion at all. + % Both cases are covered by pulling every Engines_{k}.hFigure that is + % currently alive into OpenedFigures_. Dead handles are pruned first; + % already-tracked handles are skipped (handle-equality dedupe). + obj.pruneOpenedFigures_(); + for k = 1:numel(obj.Engines_) + e = obj.Engines_{k}; + if isempty(e) || ~isvalid(e); continue; end + hFig = e.hFigure; + if isempty(hFig) || ~ishandle(hFig); continue; end + already = false; + for j = 1:numel(obj.OpenedFigures_) + if obj.OpenedFigures_(j) == hFig + already = true; break; + end + end + if ~already + obj.OpenedFigures_(end+1, 1) = hFig; + end + end + end + function onOpenAdHocPlotRequested_(obj, ~, evt) %ONOPENADHOCPLOTREQUESTED_ Listener for OpenAdHocPlotRequested event. % Resolves AdHocPlotEventData.TagKeys to Tag handles via Registry_, @@ -1382,7 +1632,12 @@ function onOpenAdHocPlotRequested_(obj, ~, evt) tags{end+1} = obj.Registry_.get(keys{k}); %#ok end end - [~, skipped] = openAdHocPlot(tags, mode, obj.Theme); + [hFig, skipped] = openAdHocPlot(tags, mode, obj.Theme); + % S0Y-01: track the ad-hoc figure so Tile / Close all see it. + try + obj.trackOpenedFigure_(hFig); + catch + end obj.addLogEntry('info', sprintf( ... 'Opened ad-hoc plot: %d tag(s) [%s]', ... numel(tags), char(mode))); diff --git a/libs/FastSenseCompanion/InspectorPane.m b/libs/FastSenseCompanion/InspectorPane.m index 7e4eb6b6..2957dca8 100644 --- a/libs/FastSenseCompanion/InspectorPane.m +++ b/libs/FastSenseCompanion/InspectorPane.m @@ -849,7 +849,16 @@ function onOpenDetail_(obj, tag) ~isempty(obj.Orchestrator_.Theme) preset = char(obj.Orchestrator_.Theme); end - openAdHocPlot({tag}, 'LinkedGrid', preset); + hFig = openAdHocPlot({tag}, 'LinkedGrid', preset); + % Register with the orchestrator so the companion's Tile / Close all + % buttons treat single-tag detail plots like any other opened window. + try + if ~isempty(obj.Orchestrator_) && isvalid(obj.Orchestrator_) && ... + ismethod(obj.Orchestrator_, 'trackOpenedFigure') + obj.Orchestrator_.trackOpenedFigure(hFig); + end + catch + end obj.log_('info', sprintf('Opened detail plot: %s', char(tag.Key))); catch ME obj.log_('error', sprintf('Open detail failed (%s): %s', char(tag.Key), ME.message)); diff --git a/tests/test_companion_tile_close_buttons.m b/tests/test_companion_tile_close_buttons.m new file mode 100644 index 00000000..04c3f95e --- /dev/null +++ b/tests/test_companion_tile_close_buttons.m @@ -0,0 +1,378 @@ +function test_companion_tile_close_buttons() +%TEST_COMPANION_TILE_CLOSE_BUTTONS Tile + Close-all toolbar buttons for FastSenseCompanion. +% +% Covers S0Y-01 (Tile windows) and S0Y-02 (Close all windows). Uses a +% hidden uifigure (Visible='off' set immediately after construction) and +% classical figures with Visible='off' so the test runner stays quiet. +% +% The test reads private state through the friend accessors +% FastSenseCompanion.getOpenedFiguresForTest_ and feeds figures in via +% trackOpenedFigureForTest_ so we exercise the same code path as the real +% onOpenDashboardRequested_ / onOpenAdHocPlotRequested_ hooks without +% spinning up a full DashboardListPane. +% +% See also FastSenseCompanion, openAdHocPlot, test_companion_filter_tags. + + add_paths_(); + if exist('OCTAVE_VERSION', 'builtin') ~= 0 + fprintf('Octave detected -- FastSenseCompanion is MATLAB-only; skipping.\n'); + return; + end + + nPassed = 0; nTotal = 0; + + [p, t] = test_tracking_on_dashboard_open_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_tracking_dedupes_same_figure_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_pruning_after_external_close_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_tile_geometry_no_overlap_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_close_all_clears_tracking_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_outside_figures_not_touched_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_toolbar_buttons_present_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_sync_pulls_prerendered_engine_(); nPassed = nPassed + p; nTotal = nTotal + t; + [p, t] = test_public_trackopenedfigure_hook_(); nPassed = nPassed + p; nTotal = nTotal + t; + + if nPassed == nTotal + fprintf(' All %d tests passed.\n', nTotal); + else + error('test_companion_tile_close_buttons: %d / %d passed', nPassed, nTotal); + end +end + +% ------------------------------------------------------------------------- +% Sub-tests +% ------------------------------------------------------------------------- + +function [passed, total] = test_tracking_on_dashboard_open_() +%TEST_TRACKING_ON_DASHBOARD_OPEN_ trackOpenedFigure_ appends DashboardEngine figures. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + d = DashboardEngine('S0Y-track-1'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figureCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + app.trackOpenedFigureForTest_(d.hFigure); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, 'expected 1 tracked figure, got %d', numel(figs)); + assert(figs(1) == d.hFigure, 'tracked figure must equal d.hFigure'); + + passed = 1; +end + +function [passed, total] = test_tracking_dedupes_same_figure_() +%TEST_TRACKING_DEDUPES_SAME_FIGURE_ Calling trackOpenedFigure_ twice on the same handle is a no-op. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + d = DashboardEngine('S0Y-dedupe'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figureCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + app.trackOpenedFigureForTest_(d.hFigure); + app.trackOpenedFigureForTest_(d.hFigure); % duplicate -- must NOT double-add + app.trackOpenedFigureForTest_(d.hFigure); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, ... + 'dedupe failed: expected 1 entry, got %d', numel(figs)); + + passed = 1; +end + +function [passed, total] = test_pruning_after_external_close_() +%TEST_PRUNING_AFTER_EXTERNAL_CLOSE_ Closed handles are dropped from tracking. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % Make two classical figures, track them, close one externally, then + % call tileOpenedWindows and confirm the dead handle is gone. + f1 = figure('Visible', 'off', 'NumberTitle', 'off', 'Name', 'S0Y-prune-1'); + f2 = figure('Visible', 'off', 'NumberTitle', 'off', 'Name', 'S0Y-prune-2'); + cleanupFigs = onCleanup(@() safe_delete_fig_([f1 f2])); %#ok + + app.trackOpenedFigureForTest_(f1); + app.trackOpenedFigureForTest_(f2); + assert(numel(app.getOpenedFiguresForTest_()) == 2, 'pre-close: expected 2 tracked'); + + % Close f1 outside the companion's lifecycle. + close(f1); + + % Tile should not error AND must drop the dead handle. + app.tileOpenedWindows(); + + figs = app.getOpenedFiguresForTest_(); + assert(numel(figs) == 1, ... + 'pruning failed: expected 1 tracked after close(f1), got %d', numel(figs)); + assert(figs(1) == f2, 'remaining tracked handle must be f2'); + + passed = 1; +end + +function [passed, total] = test_tile_geometry_no_overlap_() +%TEST_TILE_GEOMETRY_NO_OVERLAP_ After tile, the 4 figures form a non-overlapping grid. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + figs = gobjects(4, 1); + for k = 1:4 + figs(k) = figure( ... + 'Visible', 'off', ... + 'NumberTitle', 'off', ... + 'Name', sprintf('S0Y-tile-%d', k), ... + 'Position', [100 100 800 600]); + app.trackOpenedFigureForTest_(figs(k)); + end + cleanupFigs = onCleanup(@() safe_delete_fig_(figs)); %#ok + + app.tileOpenedWindows(); + + % Read back positions AFTER tile. + rects = zeros(4, 4); + for k = 1:4 + rects(k, :) = get(figs(k), 'Position'); + end + + % Pairwise non-overlap check. + for i = 1:4 + for j = i+1:4 + assert(~rects_overlap_(rects(i,:), rects(j,:)), ... + 'tile geometry: rect %d and %d overlap [%s] vs [%s]', ... + i, j, mat2str(rects(i,:)), mat2str(rects(j,:))); + end + end + + % Every rect must have positive width and height. + for k = 1:4 + assert(rects(k, 3) > 0 && rects(k, 4) > 0, ... + 'rect %d has non-positive size: %s', k, mat2str(rects(k,:))); + end + + passed = 1; +end + +function [passed, total] = test_close_all_clears_tracking_() +%TEST_CLOSE_ALL_CLEARS_TRACKING_ closeAllOpenedWindows closes every tracked fig + empties list. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + figs = gobjects(3, 1); + for k = 1:3 + figs(k) = figure('Visible', 'off', ... + 'NumberTitle', 'off', ... + 'Name', sprintf('S0Y-closeall-%d', k)); + app.trackOpenedFigureForTest_(figs(k)); + end + cleanupFigs = onCleanup(@() safe_delete_fig_(figs)); %#ok + + app.closeAllOpenedWindows(); + + for k = 1:3 + assert(~ishandle(figs(k)), ... + 'closeAll: figure %d still alive', k); + end + assert(isempty(app.getOpenedFiguresForTest_()), ... + 'closeAll: tracking list not cleared'); + % Companion's own uifigure must still be alive. + assert(isvalid(app), 'companion app handle must survive closeAll'); + + passed = 1; +end + +function [passed, total] = test_outside_figures_not_touched_() +%TEST_OUTSIDE_FIGURES_NOT_TOUCHED_ Figures opened outside the companion are not moved or closed. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % One TRACKED figure (the companion opens it). + fTracked = figure('Visible', 'off', ... + 'NumberTitle', 'off', 'Name', 'S0Y-tracked', ... + 'Position', [200 200 500 400]); + app.trackOpenedFigureForTest_(fTracked); + + % One OUTSIDE figure (user opened it; companion never sees it). + fOutside = figure('Visible', 'off', ... + 'NumberTitle', 'off', 'Name', 'S0Y-outside', ... + 'Position', [350 350 500 400]); + + cleanupFigs = onCleanup(@() safe_delete_fig_([fTracked fOutside])); %#ok + + origOutsidePos = get(fOutside, 'Position'); + + app.tileOpenedWindows(); + assert(isequal(get(fOutside, 'Position'), origOutsidePos), ... + 'outside figure was moved by tile -- it must not be touched'); + + app.closeAllOpenedWindows(); + assert(~ishandle(fTracked), 'tracked figure must be closed by closeAll'); + assert(ishandle(fOutside), ... + 'outside figure must survive closeAll -- it was not tracked'); + + passed = 1; +end + +function [passed, total] = test_toolbar_buttons_present_() +%TEST_TOOLBAR_BUTTONS_PRESENT_ Two new uibutton handles exist with the expected labels. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + % Probe via findall on the uifigure for buttons whose Text matches. + fig = app.getFigForTest_(); + btns = findall(fig, 'Type', 'uibutton'); + texts = arrayfun(@(b) char(b.Text), btns, 'UniformOutput', false); + + assert(any(strcmp(texts, 'Tile')), ... + 'toolbar: Tile button missing. Found buttons: %s', strjoin(texts, ', ')); + assert(any(strcmp(texts, 'Close all')), ... + 'toolbar: Close all button missing. Found buttons: %s', strjoin(texts, ', ')); + + passed = 1; +end + +function [passed, total] = test_sync_pulls_prerendered_engine_() +%TEST_SYNC_PULLS_PRERENDERED_ENGINE_ Engines rendered BEFORE construction get tracked. +% Reproduces the live-demo bug: run_demo builds + renders a DashboardEngine, +% THEN passes it to FastSenseCompanion. The OpenDashboardRequested event +% never fires for that engine, so the lazy tracking hook misses it. The +% eager syncOpenedFigures_ helper (called at the top of tileOpenedWindows +% and closeAllOpenedWindows) must pull the figure from Engines_ on first +% click so Tile / Close all work for the demo's initial dashboard. + total = 1; passed = 0; + + % Build + render the engine BEFORE the companion exists. + d = DashboardEngine('S0Y-sync-prerender'); + d.render(); + set(d.hFigure, 'Visible', 'off'); + figCleanup = onCleanup(@() safe_delete_fig_(d.hFigure)); %#ok + + % Construct companion over the already-rendered engine; do not fire + % OpenDashboardRequested ourselves. + app = FastSenseCompanion('Dashboards', {d}, 'Theme', 'dark'); + try + fig = app.getFigForTest_(); + if ~isempty(fig) && isvalid(fig); fig.Visible = 'off'; end + catch + end + appCleanup = onCleanup(@() safe_close_app_(app)); %#ok + + % Before any sync, OpenedFigures_ is empty -- nothing has tracked yet. + pre = app.getOpenedFiguresForTest_(); + assert(isempty(pre), ... + 'expected OpenedFigures_ empty before first sync, got %d entries', numel(pre)); + + % Calling tile triggers syncOpenedFigures_, which pulls d.hFigure from + % Engines_. Read the tracking list right after and verify. + app.tileOpenedWindows(); + post = app.getOpenedFiguresForTest_(); + assert(numel(post) == 1, ... + 'sync must surface 1 engine figure on Tile, got %d', numel(post)); + assert(post(1) == d.hFigure, ... + 'tracked figure must equal the engine''s hFigure'); + + % Snapshot the figure handle BEFORE closeAll -- DashboardEngine's + % CloseRequestFcn (stopLive + delete) may clear d.hFigure, leaving + % d.hFigure == [], and ishandle([]) returns [] which is not convertible + % to a scalar logical for assert(). + hFigSnap = d.hFigure; + app.closeAllOpenedWindows(); + assert(~ishandle(hFigSnap), ... + 'closeAllOpenedWindows must close the pre-rendered engine''s figure'); + + passed = 1; +end + +function [passed, total] = test_public_trackopenedfigure_hook_() +%TEST_PUBLIC_TRACKOPENEDFIGURE_HOOK_ Public trackOpenedFigure surfaces a foreign figure. +% Reproduces the live-demo gap reported after the sync fix shipped: a user +% opens a sensor detail plot via the inspector's "Open detail" button, which +% calls openAdHocPlot DIRECTLY (not via the OpenAdHocPlotRequested event). +% The pane now captures the returned hFig and forwards to the orchestrator's +% PUBLIC trackOpenedFigure method. Same hook is used by CompanionEventViewer's +% openEventDashboard_. This test exercises the public hook itself. + total = 1; passed = 0; + [app, cleanup] = make_app_(); %#ok + + f = figure('Visible', 'off', 'Name', 'S0Y-public-hook'); + figCleanup = onCleanup(@() safe_delete_fig_(f)); %#ok + + % BEFORE the public hook is called -- not tracked. + pre = app.getOpenedFiguresForTest_(); + assert(isempty(pre), 'expected empty pre, got %d', numel(pre)); + + % Public hook -- the same call InspectorPane.onOpenDetail_ and + % CompanionEventViewer.openEventDashboard_ make. + app.trackOpenedFigure(f); + + post = app.getOpenedFiguresForTest_(); + assert(numel(post) == 1, 'expected 1 tracked figure, got %d', numel(post)); + assert(post(1) == f, 'tracked figure must equal the one we passed in'); + + % Dedupe: calling the public hook twice on the same handle still yields 1. + app.trackOpenedFigure(f); + post2 = app.getOpenedFiguresForTest_(); + assert(numel(post2) == 1, 'expected dedupe, got %d', numel(post2)); + + % Close all closes it. + app.closeAllOpenedWindows(); + assert(~ishandle(f), 'closeAllOpenedWindows must close the publicly tracked figure'); + + passed = 1; +end + +% ------------------------------------------------------------------------- +% Helpers +% ------------------------------------------------------------------------- + +function [app, cleanup] = make_app_() +%MAKE_APP_ Build a hidden FastSenseCompanion and return (app, onCleanup). + app = FastSenseCompanion('Dashboards', {}, 'Theme', 'dark'); + % Hide immediately to keep CI quiet -- the constructor turns Visible on + % at the end of construction, so flip it back here. + try + fig = app.getFigForTest_(); + if ~isempty(fig) && isvalid(fig) + fig.Visible = 'off'; + end + catch + end + cleanup = onCleanup(@() safe_close_app_(app)); +end + +function safe_close_app_(app) +%SAFE_CLOSE_APP_ Best-effort companion teardown for onCleanup. + try + if isobject(app) && isvalid(app) + app.close(); + end + catch + end +end + +function safe_delete_fig_(figs) +%SAFE_DELETE_FIG_ Delete any still-valid figure handles. figs may be a vector. + for k = 1:numel(figs) + try + h = figs(k); + if ishandle(h) + delete(h); + end + catch + end + end +end + +function tf = rects_overlap_(a, b) +%RECTS_OVERLAP_ True iff rectangles a and b overlap in 2-D ([x y w h] form). + tf = ~(a(1)+a(3) <= b(1) || b(1)+b(3) <= a(1) || ... + a(2)+a(4) <= b(2) || b(2)+b(4) <= a(2)); +end + +function add_paths_() +%ADD_PATHS_ Make sure libs/ are on path; install if needed. + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); + if isempty(which('FastSenseCompanion')) + install(); + end +end