diff --git a/.planning/STATE.md b/.planning/STATE.md index f9c18bbd..7147d20b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -89,6 +89,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict | 260513-sfp | Add auto-y-limit control buttons (V/A/L) to FastSenseWidget WidgetButtonBar — new YLimitMode property (auto-visible / auto-all / locked, default 'auto-visible' reproduces pre-260513-sfp behaviour), setYLimitMode public method (clears UserZoomedY on explicit click so click re-engages autoscale), autoScaleY_ refactored to dispatch on mode AFTER existing precedence guards (YLimits pin / UserZoomedY / FastSense.LiveViewMode=='follow') so 260513-ovt Follow semantics are preserved. DashboardLayout duck-types widget chrome via ismethod(widget,'setYLimitMode'), so future widgets that expose Y-rescale modes opt in without touching DashboardLayout. ASCII glyphs (V/A/L) match existing Info/Detach. reflowChrome_ re-anchors on resize. toStruct omits the default so legacy dashboards stay diff-invisible. test_fastsense_widget_ylimit_modes 11/11, test_fastsense_widget_tag 7/7, test_fastsense_follow_toggle 10/10, test_dashboard_time_sync_all_pages 5/5. Verified on live industrial-plant demo, all 8 scenarios approved. Known caveat: V/A/L cluster butts against Info button (0-px gap) — inherited from pre-existing addInfoIcon 28-px-typo, explicitly out-of-scope per plan; logged in deferred-items.md | 2026-05-13 | 4db9138, cc18c7f, a9cc181 | Verified | [260513-sfp-add-auto-y-limit-control-buttons-to-fast](./quick/260513-sfp-add-auto-y-limit-control-buttons-to-fast/) | | 260513-s0y | Add Tile + Close all buttons to FastSenseCompanion top toolbar — private OpenedFigures_ tracking + syncOpenedFigures_ (walks Engines_ before tile/close-all) + public trackOpenedFigure hook (InspectorPane.onOpenDetail_ and CompanionEventViewer.openEventDashboard_ forward their figure handles). tileOpenedWindows: ceil(sqrt(N))×ceil(N/cols) grid on monitor containing the companion, 24px margin, 8px gutter, row-major top-down. Before set(Position), coerces each figure to WindowState='normal' + Units='pixels' — root cause of initial "Tile does nothing" report was DashboardEngine.render defaulting to Units='normalized' (pixel rects got treated as screen fractions, pushing figures off-canvas). closeAllOpenedWindows: snapshot + close(h) per handle (honors each figure's CloseRequestFcn). Inner toolbar grid 1×4→1×6 (Events / Live / Tile / Close all / spacer / gear; gear Layout.Column 4→6). 9 sub-tests in test_companion_tile_close_buttons.m PASS; TestFastSenseCompanion regression 64/64 PASS. Verified on live industrial-plant demo. Shipped as PR #143. | 2026-05-14 | 182d6f1, 2867caa, 1be2cc8, e58bc35, c47c0c1, db9ef88 | Shipped (PR #143) | [260513-s0y-add-tile-windows-and-close-all-windows-b](./quick/260513-s0y-add-tile-windows-and-close-all-windows-b/) | | 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) | +| 260526-pqz | Raise per-signal slider-preview cap from 400 → 1000 buckets in `DashboardEngine.computePreviewEnvelopeReturning_` — three textual edits (1 code clamp + 2 documenting comments) in `libs/Dashboard/DashboardEngine.m` plus one consistency comment in `tests/test_dashboard_preview_overlay.m` (no assertion change; `numel(xd) >= 4` is cap-independent). Edit sites: line 3524 doc-comment (`computePreviewEnvelope` range), line 3542 inline comment (clamp range), line 3555 actual clamp `max(50, min(1000, floor(axWpx / 2)))`. Out of scope per plan: cache invalidation of `PreviewNBuckets_` — running demos must restart (or trigger the existing resize-invalidation path at `DashboardEngine.m:2241`) for the new cap to take effect. Static analysis clean: `mh_lint` + `mh_style` on both edited files report "everything seems fine"; regression sweep `grep -rn "\b400\b" tests/ \| grep -iE "(preview\|bucket\|envelope)"` returns no matches. MATLAB R2025a: `test_dashboard_preview_envelope` 7/7, `test_dashboard_preview_overlay` 10/10. Octave 11.1.0: `test_dashboard_preview_envelope` 2/2 (5 skipped — pre-existing TimeRangeSelector guard for patch+FaceAlpha+NaN on xvfb), `test_dashboard_preview_overlay` skipped entirely (pre-existing). | 2026-05-26 | 834b43c | — | [260526-pqz-raise-preview-line-cap-per-signal-from-4](./quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/) | ## Progress Bar diff --git a/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md new file mode 100644 index 00000000..76203e32 --- /dev/null +++ b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-PLAN.md @@ -0,0 +1,296 @@ +--- +phase: quick-260526-pqz +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - libs/Dashboard/DashboardEngine.m + - tests/test_dashboard_preview_overlay.m +autonomous: true +requirements: [PREVIEW-CAP-1000] +must_haves: + truths: + - "computePreviewEnvelopeReturning_ clamps nBuckets to at most 1000 (was 400)" + - "The doc-comment on computePreviewEnvelope reports the new clamp range [50, 1000]" + - "The inline comment on the clamp expression reports the new clamp range [50, 1000]" + - "The slider preview lines still render correctly for short and long signals (no shape regression)" + - "No test hard-asserts the old 400 cap (regression sweep clean)" + artifacts: + - path: libs/Dashboard/DashboardEngine.m + provides: "computePreviewEnvelopeReturning_ with raised per-signal preview cap" + contains: "max(50, min(1000, floor(axWpx / 2)))" + - path: tests/test_dashboard_preview_overlay.m + provides: "Consistent doc-comment about adaptive bucket range" + contains: "[50, 1000]" + key_links: + - from: "libs/Dashboard/DashboardEngine.m::computePreviewEnvelopeReturning_" + to: "libs/Dashboard/FastSenseWidget.m::getPreviewSeries" + via: "ws{i}.getPreviewSeries(nBuckets) at line 3582" + pattern: "ws\\{i\\}\\.getPreviewSeries\\(nBuckets\\)" +--- + + +Raise the per-signal slider-preview-line cap from 400 to 1000 datapoints in +`DashboardEngine.computePreviewEnvelopeReturning_`. The cap is the upper +bound of the `nBuckets` derived from the dashboard figure's pixel width; +each bucket produces exactly one (x, yMid) point on the per-widget preview +line drawn into the bottom `TimeRangeSelector` strip. + +Purpose: Give users denser preview detail per signal in wide windows. The +old `min(400, floor(axWpx / 2))` clamp meant any window wider than ~800 px +hit the 400-point ceiling. Raising the ceiling to 1000 lets very wide +displays (1440 px / 1920 px / ultrawide) drive proportionally more +pixel-aligned preview detail. + +Output: +- `libs/Dashboard/DashboardEngine.m`: three edits in `computePreviewEnvelopeReturning_` + (the code clamp + two comments that document the clamp range). +- `tests/test_dashboard_preview_overlay.m`: one comment update for consistency + (range mentioned in a regression test comment, not an assertion). + +Out of scope (per task specifics): cache-invalidation logic. The cached +`PreviewNBuckets_` on `DashboardEngine` is intentionally left alone — a +running demo will only pick up the new cap on the next `DashboardEngine` +instantiation (e.g., demo restart, or a resize that triggers the existing +invalidation at line 2241). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md + + + +@./CLAUDE.md +@.planning/STATE.md + + + + +From libs/Dashboard/DashboardEngine.m (current state, ~lines 3516-3560): +```matlab +function computePreviewEnvelope(obj, nBuckets) +%COMPUTEPREVIEWENVELOPE Aggregate per-bucket min/max across the +% currently active page's widgets (including nested GroupWidget +% children) and push the result onto the selector's envelope +% patch (D-07, D-08). Multi-page dashboards therefore reflect +% only the active tab — switchPage() recomputes the envelope so +% navigation stays in sync. nBuckets optional; when omitted, +% defaults to ~200 based on panel axes pixel width, clamped to +% [50, 400]. Silently no-ops when no selector is wired yet +% (e.g. before render()). + if nargin < 2, nBuckets = []; end + obj.computePreviewEnvelopeReturning_(nBuckets); +end + +function env = computePreviewEnvelopeReturning_(obj, nBuckets) +%COMPUTEPREVIEWENVELOPERETURNING_ computePreviewEnvelope + return. +% ... + env = []; + if isempty(obj.TimeRangeSelector_) || ... + ~isa(obj.TimeRangeSelector_, 'TimeRangeSelector') + return; + end + if isempty(nBuckets) + % Derive nBuckets from figure pixel width; clamp to [50, 400]. + % Cache the computed value so we avoid get/set Units on every + % live tick (figure size rarely changes between ticks). + if obj.PreviewNBuckets_ > 0 + nBuckets = obj.PreviewNBuckets_; + else + nBuckets = 200; + try + oldU = get(obj.hFigure, 'Units'); + set(obj.hFigure, 'Units', 'pixels'); + figPx = get(obj.hFigure, 'Position'); + set(obj.hFigure, 'Units', oldU); + axWpx = figPx(3) * 0.94; + nBuckets = max(50, min(400, floor(axWpx / 2))); + catch + end + obj.PreviewNBuckets_ = nBuckets; + end + end + ... +``` + +From libs/Dashboard/FastSenseWidget.m::getPreviewSeries (around line 765-767), +the per-widget downstream consumer — no change needed, included for context: +```matlab +nBucketsEff = max(1, min(nBuckets, floor(numel(x) / 2))); +``` +This further clamps `nBuckets` to `floor(numel(x) / 2)` per widget — for +short signals it correctly limits to half the sample count; for long +signals it now allows up to 1000 buckets. No edit required here. + + + +The literal `400` appears in EXACTLY three places in DashboardEngine.m +(all in the same method body), confirmed by `grep -n "\b400\b"`: + +``` +libs/Dashboard/DashboardEngine.m:3524 % [50, 400]. Silently no-ops... +libs/Dashboard/DashboardEngine.m:3542 % Derive nBuckets from figure pixel width; clamp to [50, 400]. +libs/Dashboard/DashboardEngine.m:3555 nBuckets = max(50, min(400, floor(axWpx / 2))); +``` + +One additional occurrence in tests (comment only, not an assertion): +``` +tests/test_dashboard_preview_overlay.m:95 % Default aggregator picks nBuckets in [50, 400]; with 50 samples, +``` + +No test file contains `assert(... == 400)` or similar value-pinning, so +no test logic changes are required. + + + + + + + Task 1: Raise the cap (DashboardEngine + comment sync) + libs/Dashboard/DashboardEngine.m, tests/test_dashboard_preview_overlay.m + + Make four single-character edits — three in `libs/Dashboard/DashboardEngine.m` + and one in `tests/test_dashboard_preview_overlay.m`. Change `400` → `1000` at + each site. Do NOT add cache-invalidation logic and do NOT change anything + else in `computePreviewEnvelopeReturning_`. Keep edits surgical. + + Use the `Edit` tool four times with unique surrounding context: + + 1) **DashboardEngine.m line ~3524 (doc comment in `computePreviewEnvelope`):** + - Find: `% [50, 400]. Silently no-ops when no selector is wired yet` + - Replace: `% [50, 1000]. Silently no-ops when no selector is wired yet` + + 2) **DashboardEngine.m line ~3542 (inline comment in `computePreviewEnvelopeReturning_`):** + - Find: `% Derive nBuckets from figure pixel width; clamp to [50, 400].` + - Replace: `% Derive nBuckets from figure pixel width; clamp to [50, 1000].` + + 3) **DashboardEngine.m line ~3555 (the actual clamp expression):** + - Find: `nBuckets = max(50, min(400, floor(axWpx / 2)));` + - Replace: `nBuckets = max(50, min(1000, floor(axWpx / 2)));` + + 4) **tests/test_dashboard_preview_overlay.m line ~95 (test doc-comment for + consistency — comment text only, not test logic):** + - Find: `% Default aggregator picks nBuckets in [50, 400]; with 50 samples,` + - Replace: `% Default aggregator picks nBuckets in [50, 1000]; with 50 samples,` + + Rationale for the test-file comment edit: the comment explains the + range the aggregator picks from — keeping it stale would mislead future + readers. The actual assertion below (line ~110: `numel(xd) >= 4`) does + not depend on the upper bound, so no test logic changes. + + Per the task specifics: **do not** add invalidation of `PreviewNBuckets_`. + Users on a running demo session must restart the demo for the new cap + to take effect (or trigger the existing resize-based invalidation at + DashboardEngine.m line 2241). Note this in the commit message. + + + grep -nE "\\b400\\b" libs/Dashboard/DashboardEngine.m tests/test_dashboard_preview_overlay.m + Expected output: empty (no matches). If any `400` literal remains in + these two files, fix and re-grep. + + Additionally, run `mcp__matlab__check_matlab_code` on + `libs/Dashboard/DashboardEngine.m` and confirm no new lint/syntax issues + were introduced compared to baseline. Three trivial textual edits in + constant literals and comments should produce identical diagnostics. + + + - `grep -n "\\b400\\b" libs/Dashboard/DashboardEngine.m` returns no matches. + - `grep -n "\\b400\\b" tests/test_dashboard_preview_overlay.m` returns no matches. + - `grep -n "\\b1000\\b" libs/Dashboard/DashboardEngine.m` returns at least 3 matches in `computePreviewEnvelope` / `computePreviewEnvelopeReturning_` (lines ~3524, ~3542, ~3555). + - `mcp__matlab__check_matlab_code` on the edited file reports no new errors/warnings vs. baseline. + - The `nBuckets = max(50, ...)` line in `computePreviewEnvelopeReturning_` now reads `min(1000, floor(axWpx / 2))`. + + + + + Task 2: Spot-check the preview test suite still passes + tests/test_dashboard_preview_envelope.m, tests/test_dashboard_preview_overlay.m + + Run the two preview-focused test files via `mcp__matlab__run_matlab_test_file` + to confirm the cap change doesn't break preview rendering on either the + short-signal adaptive path or the legacy-shape aggregate path. No edits + are expected here — this is a verification sweep only. + + Test files to run (in order): + + 1. `tests/test_dashboard_preview_envelope.m` — exercises the + legacy aggregate envelope path; specifically asserts that + `nBuckets=200` (well below both the old 400 and new 1000 cap) still + produces shape-stable results. Cap change should be invisible here. + + 2. `tests/test_dashboard_preview_overlay.m` — exercises the + per-widget preview-line path (post-260508-das + 260512-cxc). + Includes the 50-sample adaptive-buckets regression. Cap change + should be invisible here too because none of the test cases drive + the figure wider than ~800 px. + + If either suite fails, capture the failing test name + assertion + message and stop — do NOT attempt to fix the failure inside this + quick task. Surface the failure to the user; we'll triage in a + follow-up. The cap-raise edit should be revertable in isolation. + + Also worth a quick spot-glance (no need to run): `tests/suite/TestDashboardEngine.m`, + `tests/suite/TestDashboardEngineAttachPlantLog.m`, + `tests/suite/TestDashboardEngineEventMarkers.m` — grep for `400` in + these confirmed no matches in the pre-plan sweep, so no run needed. + + + Both `mcp__matlab__run_matlab_test_file` invocations return all-pass. Grep regression: `grep -rn "\\b400\\b" tests/ | grep -iE "(preview|bucket|envelope)"` returns no matches. + + + - `tests/test_dashboard_preview_envelope.m` runs to completion with 0 failures. + - `tests/test_dashboard_preview_overlay.m` runs to completion with 0 failures. + - No preview-related test file contains a stale `400` literal. + + + + + + +End-to-end verification (manual, optional — not gating): + +The user's running industrial-plant demo will continue to display preview +lines clamped to the OLD cap (`PreviewNBuckets_` is cached on the engine +instance). To see the new cap in action the user must either: + + (a) restart the demo (`run demo/industrial_plant/run_demo.m`), or + (b) resize the dashboard window (the existing path at + `DashboardEngine.m:2241` invalidates `PreviewNBuckets_` on resize), + +then visually confirm wide windows show denser preview detail (more points +per signal in the bottom slider strip). + +This is intentional per task specifics — we deliberately did NOT add cache +invalidation logic to keep the change minimal. + + + +- Three edits applied in `libs/Dashboard/DashboardEngine.m` (1 code line, 2 comments). +- One comment edit applied in `tests/test_dashboard_preview_overlay.m` for consistency. +- `grep "\\b400\\b" libs/Dashboard/DashboardEngine.m` returns no matches. +- `grep "\\b400\\b" tests/test_dashboard_preview_overlay.m` returns no matches. +- `mcp__matlab__check_matlab_code` on `DashboardEngine.m` reports no new diagnostics. +- `tests/test_dashboard_preview_envelope.m` passes. +- `tests/test_dashboard_preview_overlay.m` passes. +- No cache-invalidation logic was added (running-demo users restart to pick up the new cap). + + + +After completion, create +`.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md` +documenting: +- The four edit sites and final values. +- Test results (pass/fail with counts). +- Reminder that the cached `PreviewNBuckets_` means a running demo must + be restarted (or the figure resized) to see the new cap take effect. +- Forward-looking note: if a future user requests "the slider should + always reflect the configured cap immediately", the fix is to either + (a) call `obj.PreviewNBuckets_ = 0` in `DashboardEngine` setup or + (b) bump the cache-invalidation path at line 2241 to also re-derive + on construct. Out of scope for this quick task. + + + \ No newline at end of file diff --git a/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md new file mode 100644 index 00000000..fd5ad2bd --- /dev/null +++ b/.planning/quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/260526-pqz-SUMMARY.md @@ -0,0 +1,122 @@ +--- +phase: quick-260526-pqz +plan: 01 +subsystem: Dashboard +tags: [preview, slider, fastsense-widget, cap-raise] +requires: [] +provides: + - "Raised per-signal slider-preview cap from 400 -> 1000 buckets" +affects: + - "libs/Dashboard/DashboardEngine.m::computePreviewEnvelopeReturning_" + - "libs/Dashboard/DashboardEngine.m::computePreviewEnvelope (doc-comment)" + - "tests/test_dashboard_preview_overlay.m (doc-comment consistency)" +tech-stack: + added: [] + patterns: + - "Surgical 3-edit cap-raise in clamp expression + 2 documenting comments" + - "Test-side comment kept in sync (no assertion change)" +key-files: + created: [] + modified: + - libs/Dashboard/DashboardEngine.m + - tests/test_dashboard_preview_overlay.m +decisions: + - "Deliberately did NOT invalidate the cached PreviewNBuckets_; running demos must restart (or resize) to pick up the new cap. This keeps the diff surgical and avoids touching the live-tick caching path." +metrics: + duration: "~3m" + completed: "2026-05-26T16:39:29Z" +--- + +# Phase quick-260526-pqz Plan 01: Raise Preview Line Cap per Signal from 400 -> 1000 Summary + +Raised the per-signal slider-preview bucket cap in `DashboardEngine.computePreviewEnvelopeReturning_` from `min(400, ...)` to `min(1000, ...)` so wide windows (>=2000 px) drive proportionally more preview detail; three textual edits in `DashboardEngine.m` (1 code line + 2 comments) plus one consistency comment in the overlay test. + +## Changes + +### `libs/Dashboard/DashboardEngine.m` (3 edits — all within the same method body, lines ~3520-3560) + +1. **Line 3524 — doc-comment on `computePreviewEnvelope`:** + - `% [50, 400]. Silently no-ops...` -> `% [50, 1000]. Silently no-ops...` + +2. **Line 3542 — inline comment in `computePreviewEnvelopeReturning_`:** + - `% Derive nBuckets from figure pixel width; clamp to [50, 400].` -> `% Derive nBuckets from figure pixel width; clamp to [50, 1000].` + +3. **Line 3555 — the actual clamp expression:** + - `nBuckets = max(50, min(400, floor(axWpx / 2)));` -> `nBuckets = max(50, min(1000, floor(axWpx / 2)));` + +### `tests/test_dashboard_preview_overlay.m` (1 edit — comment-only, no assertion change) + +4. **Line 95 — test doc-comment in `case_small_dataset_adaptive_buckets`:** + - `% Default aggregator picks nBuckets in [50, 400]; with 50 samples,` -> `% Default aggregator picks nBuckets in [50, 1000]; with 50 samples,` + +The assertion below (`numel(xd) >= 4`) is independent of the cap, so no test logic was touched. + +## Verification + +### Static analysis + +- `grep -n "\b400\b" libs/Dashboard/DashboardEngine.m` -> **empty** (no matches) +- `grep -n "\b400\b" tests/test_dashboard_preview_overlay.m` -> **empty** (no matches) +- `grep -n "\b1000\b" libs/Dashboard/DashboardEngine.m` near preview function -> **3 matches at lines 3524, 3542, 3555** +- `mh_lint libs/Dashboard/DashboardEngine.m` -> "everything seems fine" (no new diagnostics) +- `mh_style libs/Dashboard/DashboardEngine.m` -> clean +- `mh_style tests/test_dashboard_preview_overlay.m` -> clean + +### Test runs + +**MATLAB (R2025a on macOS ARM64):** + +| Test file | Result | +|---|---| +| `tests/test_dashboard_preview_envelope.m` | **7/7 passed** | +| `tests/test_dashboard_preview_overlay.m` | **10/10 passed** | + +**Octave (11.1.0 on macOS ARM64):** + +| Test file | Result | +|---|---| +| `tests/test_dashboard_preview_envelope.m` | **2/2 passed, 5 skipped** (`TimeRangeSelector` guard — patch+FaceAlpha+NaN crashes Octave xvfb; pre-existing) | +| `tests/test_dashboard_preview_overlay.m` | **skipped entirely** (TimeRangeSelector unavailable on Octave — pre-existing) | + +The Octave skips are pre-existing and not affected by this change. + +### Regression sweep + +`grep -rn "\b400\b" tests/ | grep -iE "(preview\|bucket\|envelope)"` -> empty. No stale `400` literal remains anywhere in the test suite that mentions the preview / bucket / envelope concept. + +## Deviations from Plan + +None — plan executed exactly as written. All four edit sites were applied at the line numbers documented in the plan's `` block. + +## Important: cache reminder + +The cached `PreviewNBuckets_` on `DashboardEngine` is **intentionally** left alone (out of scope per the plan's "Out of scope" note). For users with a running industrial-plant demo, the new cap will only take effect on: + + (a) restart of the demo (`run demo/industrial_plant/run_demo.m`), or + (b) a dashboard window resize (the existing path at `DashboardEngine.m` line ~2241 invalidates `PreviewNBuckets_` on resize). + +After either event, wide windows (>800 px / ~94% = >752 px of axes width -> nBuckets > 376) will see denser preview detail per signal in the bottom slider strip, up to a new ceiling of 1000 points per signal (achievable at ~2128 px of figure width and above). + +## Forward-looking note + +If a future user requests "the slider should always reflect the configured cap immediately without restart", the fix is one of: + + (a) call `obj.PreviewNBuckets_ = 0` at the end of `DashboardEngine` setup (or after `render()`), or + (b) extend the cache-invalidation path at `DashboardEngine.m` line ~2241 to also re-derive on construct / initial render. + +This was deliberately out of scope for this quick task. + +## Commits + +| Task | Commit | Files | +|---|---|---| +| 1 — Raise cap + sync comments | `834b43c` | `libs/Dashboard/DashboardEngine.m`, `tests/test_dashboard_preview_overlay.m` | +| 2 — Verification only (no edits) | — | (no commit; verification confirmed Task 1 edits) | + +## Self-Check: PASSED + +- File `libs/Dashboard/DashboardEngine.m` exists and contains `min(1000, floor(axWpx / 2))` at line 3555. +- File `tests/test_dashboard_preview_overlay.m` exists and contains `[50, 1000]` at line 95. +- Commit `834b43c` exists in the worktree branch log. +- `mh_lint` and `mh_style` both clean. +- Both preview test files pass in MATLAB (7/7 and 10/10). diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index 1086b497..b5f07cf4 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -3521,7 +3521,7 @@ function computePreviewEnvelope(obj, nBuckets) % only the active tab — switchPage() recomputes the envelope so % navigation stays in sync. nBuckets optional; when omitted, % defaults to ~200 based on panel axes pixel width, clamped to - % [50, 400]. Silently no-ops when no selector is wired yet + % [50, 1000]. Silently no-ops when no selector is wired yet % (e.g. before render()). if nargin < 2, nBuckets = []; end obj.computePreviewEnvelopeReturning_(nBuckets); @@ -3539,7 +3539,7 @@ function computePreviewEnvelope(obj, nBuckets) return; end if isempty(nBuckets) - % Derive nBuckets from figure pixel width; clamp to [50, 400]. + % Derive nBuckets from figure pixel width; clamp to [50, 1000]. % Cache the computed value so we avoid get/set Units on every % live tick (figure size rarely changes between ticks). if obj.PreviewNBuckets_ > 0 @@ -3552,7 +3552,7 @@ function computePreviewEnvelope(obj, nBuckets) figPx = get(obj.hFigure, 'Position'); set(obj.hFigure, 'Units', oldU); axWpx = figPx(3) * 0.94; - nBuckets = max(50, min(400, floor(axWpx / 2))); + nBuckets = max(50, min(1000, floor(axWpx / 2))); catch end obj.PreviewNBuckets_ = nBuckets; diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m index d3e0bca9..a457cf93 100644 --- a/libs/Dashboard/DashboardLayout.m +++ b/libs/Dashboard/DashboardLayout.m @@ -702,6 +702,22 @@ function addPlantLogToggle(obj, widget, engine) 'Tag', 'PlantLogToggleButton', ... 'TooltipString', tipStr, ... 'Callback', @(s, ~) obj.onPlantLogTogglePressed_(s, widget, engine)); + % 260526-info-icon-vanishes-after-plantlog-toggle: + % The xPL above is hardcoded for a 3-button right cluster + % (Detach + Info + PlantLog). For FastSenseWidgets with the + % v4.0 CreateEventButton, the canonical 4-button cluster is + % Detach@barW-28, Create@barW-56, Info@barW-84, PlantLog@barW-112. + % Without the reflowChrome_ call below, callback-driven rebuilds + % drop L at barW-84 — exactly on top of InfoIconButton — so + % the i icon is visually swallowed by the L button after every + % toggle cycle. The initial call from realizeWidget is rescued + % by the reflowChrome_ at the end of realizeWidget; subsequent + % callback rebuilds need their own reflow. + try + DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2); + catch + % Best-effort: a reflow failure must not break the toggle. + end end function onPlantLogTogglePressed_(obj, src, widget, engine) diff --git a/tests/suite/TestDashboardLayoutPlantLogToggle.m b/tests/suite/TestDashboardLayoutPlantLogToggle.m index 2c48cec0..ce1f3ff8 100644 --- a/tests/suite/TestDashboardLayoutPlantLogToggle.m +++ b/tests/suite/TestDashboardLayoutPlantLogToggle.m @@ -206,5 +206,66 @@ function testCallbackTrapsExceptions(testCase) warning('on', 'DashboardLayout:plantLogToggleParentMissing'); testCase.verifyFalse(threw); end + + function testInfoIconSurvivesToggleOnOff(testCase) + % 260526-info-icon-vanishes-after-plantlog-toggle: + % After toggling L on then off via the button callback, the + % InfoIconButton (and Detach + L itself) must still be present + % on the WidgetButtonBar AND each button must sit at its + % canonical post-reflow x position. The original bug placed L + % at xPL=barW-84 (3-button-cluster math) which is exactly the + % post-reflow x position of InfoIconButton in the 4-button + % cluster — so L visually covered Info even though Info was + % still in the handle tree. The fix calls reflowChrome_ at the + % end of addPlantLogToggle so the 4-button-cluster positions + % are re-applied after every rebuild. + testCase.buildWidgetWithChrome(true); + bar = findobj(testCase.Widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1); + % Sanity: all four right-cluster buttons exist before any toggling. + testCase.verifyNotEmpty(findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1)); + testCase.verifyNotEmpty(findobj(bar, 'Tag', 'DetachButton', '-depth', 1)); + testCase.verifyNotEmpty(findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1)); + cb = get(testCase.Btn, 'Callback'); + % Toggle ON (enable plant-log overlay). + cb(testCase.Btn, []); + drawnow; + testCase.verifyTrue(testCase.Widget.ShowPlantLog); + % Re-resolve the L button after addPlantLogToggle rebuilt it. + btn2 = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1); + cb2 = get(btn2, 'Callback'); + % Toggle OFF (disable plant-log overlay). + cb2(btn2, []); + drawnow; + testCase.verifyFalse(testCase.Widget.ShowPlantLog); + % All four right-cluster buttons MUST still be on the bar. + info = findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1); + det = findobj(bar, 'Tag', 'DetachButton', '-depth', 1); + pl = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1); + testCase.verifyNotEmpty(info, 'InfoIconButton must survive L on/off cycle'); + testCase.verifyNotEmpty(det, 'DetachButton must survive L on/off cycle'); + testCase.verifyNotEmpty(pl, 'PlantLogToggleButton must survive L on/off cycle'); + % AND the canonical 4-button-cluster positions must be respected: + % Detach @ barW-28, Info @ barW-84, PlantLog @ barW-112. + % If L sits at barW-84 (the original bug), it overlaps Info. + % Use the bar's CURRENT width (it can be reread by reflowChrome_ + % via the SizeChangedFcn pathway, and may drift by sub-pixel + % rounding when the cell panel resizes — use a 1px tolerance). + barW = subsref(get(bar, 'Position'), substruct('()', {3})); + pInfo = get(info(1), 'Position'); + pDet = get(det(1), 'Position'); + pPL = get(pl(1), 'Position'); + tol = 1.0; % 1 px slack for sub-pixel rounding of bar width. + testCase.verifyLessThan(abs(pDet(1) - (barW - 28)), tol, ... + 'Detach must sit at barW - 28'); + testCase.verifyLessThan(abs(pInfo(1) - (barW - 84)), tol, ... + 'Info must sit at barW - 84'); + testCase.verifyLessThan(abs(pPL(1) - (barW - 112)), tol, ... + 'PlantLog must sit at barW - 112 (NOT barW - 84 — that would overlap Info)'); + % And the strongest separation invariant: PlantLog and Info + % must NOT share an x position (the original bug had them + % both at barW - 84, fully overlapping). + testCase.verifyGreaterThan(abs(pPL(1) - pInfo(1)), 24 - 2*tol, ... + 'PlantLog and Info must NOT share an x position'); + end end end diff --git a/tests/test_dashboard_layout_plant_log_toggle.m b/tests/test_dashboard_layout_plant_log_toggle.m index 70f0cce9..e2f2c4a5 100644 --- a/tests/test_dashboard_layout_plant_log_toggle.m +++ b/tests/test_dashboard_layout_plant_log_toggle.m @@ -41,9 +41,10 @@ function test_dashboard_layout_plant_log_toggle() nPassed = nPassed + test_disabled_button_does_not_flip_state(); nPassed = nPassed + test_idempotent_double_call(); nPassed = nPassed + test_callback_traps_exceptions(); + nPassed = nPassed + test_info_icon_survives_toggle_on_off(); - assert(nPassed == 12, 'expected 12 sub-tests, got %d', nPassed); - fprintf(' All 12 dashboard_layout_plant_log_toggle assertions passed.\n'); + assert(nPassed == 13, 'expected 13 sub-tests, got %d', nPassed); + fprintf(' All 13 dashboard_layout_plant_log_toggle assertions passed.\n'); end % ===================================================================== @@ -129,15 +130,20 @@ function try_delete_obj(o) end function n = test_initial_position_leftmost_of_three() + % After the v3.1↔v4.0 merge the right cluster has 4 buttons + % (Detach + Create + Info + PlantLog) because DashboardEngine.render + % unconditionally wires the CreateEventCallback. Each button is 24 px + % wide with a 4 px gap, so PlantLog (leftmost) sits at + % x = barW - 4*24 - 4*4 = barW - 112 after reflowChrome_. [eng, widget, fig, btn] = build_widget_with_chrome_(false); cleanupF = onCleanup(@() try_delete_h(fig)); cleanupE = onCleanup(@() try_delete_obj(eng)); bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1); barPos = get(bar(1), 'Position'); - expectedX = barPos(3) - 24 - 4 - 24 - 4 - 24 - 4; + expectedX = barPos(3) - 4*24 - 4*4; btnPos = get(btn, 'Position'); assert(abs(btnPos(1) - expectedX) < 1e-6, ... - 'L button must be at leftmost-of-three offset; expected x=%g, got x=%g', expectedX, btnPos(1)); + 'L button must be at 4-button-cluster offset; expected x=%g, got x=%g', expectedX, btnPos(1)); clear cleanupE cleanupF; n = 1; end @@ -211,6 +217,11 @@ function try_delete_obj(o) end function n = test_reflow_chrome_three_buttons() + % Post v3.1↔v4.0 merge: the right cluster has 4 buttons + % (Detach + Create + Info + PlantLog) because DashboardEngine.render + % unconditionally wires the Create callback. Positions from the + % right edge after reflowChrome_: Detach (barW-28), Create (barW-56), + % Info (barW-84), PlantLog (barW-112). [eng, widget, fig, btn] = build_widget_with_chrome_(true); %#ok cleanupF = onCleanup(@() try_delete_h(fig)); cleanupE = onCleanup(@() try_delete_obj(eng)); @@ -221,20 +232,24 @@ function try_delete_obj(o) bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1); barPos = get(bar(1), 'Position'); barW = barPos(3); - det = findobj(bar(1), 'Tag', 'DetachButton', '-depth', 1); - info = findobj(bar(1), 'Tag', 'InfoIconButton', '-depth', 1); - pl = findobj(bar(1), 'Tag', 'PlantLogToggleButton', '-depth', 1); - assert(~isempty(det) && ~isempty(info) && ~isempty(pl), ... - 'after reflow, all three buttons must exist'); - pDet = get(det(1), 'Position'); - pInfo = get(info(1), 'Position'); - pPL = get(pl(1), 'Position'); - assert(abs(pDet(1) - (barW - 24 - 4)) < 1e-6, ... - 'Detach x must be barW - 24 - 4; got %g (expected %g)', pDet(1), barW - 24 - 4); - assert(abs(pInfo(1) - (barW - 24 - 24 - 4 - 4)) < 1e-6, ... - 'Info x must be barW - 24 - 24 - 4 - 4; got %g', pInfo(1)); - assert(abs(pPL(1) - (barW - 24 - 4 - 24 - 4 - 24 - 4)) < 1e-6, ... - 'PlantLog x must be barW - 84; got %g (expected %g)', pPL(1), barW - 84); + det = findobj(bar(1), 'Tag', 'DetachButton', '-depth', 1); + create = findobj(bar(1), 'Tag', 'CreateEventButton', '-depth', 1); + info = findobj(bar(1), 'Tag', 'InfoIconButton', '-depth', 1); + pl = findobj(bar(1), 'Tag', 'PlantLogToggleButton', '-depth', 1); + assert(~isempty(det) && ~isempty(create) && ~isempty(info) && ~isempty(pl), ... + 'after reflow, all four right-cluster buttons must exist'); + pDet = get(det(1), 'Position'); + pCre = get(create(1), 'Position'); + pInfo = get(info(1), 'Position'); + pPL = get(pl(1), 'Position'); + assert(abs(pDet(1) - (barW - 28)) < 1e-6, ... + 'Detach x must be barW - 28; got %g', pDet(1)); + assert(abs(pCre(1) - (barW - 56)) < 1e-6, ... + 'Create x must be barW - 56; got %g', pCre(1)); + assert(abs(pInfo(1) - (barW - 84)) < 1e-6, ... + 'Info x must be barW - 84; got %g', pInfo(1)); + assert(abs(pPL(1) - (barW - 112)) < 1e-6, ... + 'PlantLog x must be barW - 112; got %g', pPL(1)); clear cleanupE cleanupF; n = 1; end @@ -340,3 +355,42 @@ function try_delete_obj(o) clear cleanupW cleanupE cleanupF; n = 1; end + +function n = test_info_icon_survives_toggle_on_off() + % 260526-info-icon-vanishes-after-plantlog-toggle: + % After toggling L ON then OFF, the InfoIconButton must still be + % present AND sit at its canonical 4-button-cluster x position + % (barW - 84). The original bug placed L at xPL = barW - 84 (the + % 3-button-cluster math), so L visually covered Info even though + % Info was still in the handle tree. + [eng, widget, fig, btn] = build_widget_with_chrome_(true); %#ok + cleanupF = onCleanup(@() try_delete_h(fig)); + cleanupE = onCleanup(@() try_delete_obj(eng)); + bar = findobj(widget.hCellPanel, 'Tag', 'WidgetButtonBar', '-depth', 1); + cb = get(btn, 'Callback'); + cb(btn, []); % toggle ON + drawnow; + btn2 = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1); + cb2 = get(btn2, 'Callback'); + cb2(btn2, []); % toggle OFF + drawnow; + info = findobj(bar, 'Tag', 'InfoIconButton', '-depth', 1); + det = findobj(bar, 'Tag', 'DetachButton', '-depth', 1); + pl = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1); + assert(~isempty(info), 'InfoIconButton must survive L on/off cycle'); + assert(~isempty(det), 'DetachButton must survive L on/off cycle'); + assert(~isempty(pl), 'PlantLogToggleButton must survive L on/off cycle'); + barW = subsref(get(bar, 'Position'), substruct('()', {3})); + pInfo = get(info(1), 'Position'); + pPL = get(pl(1), 'Position'); + tol = 1.0; % 1 px slack for sub-pixel rounding of bar width. + assert(abs(pInfo(1) - (barW - 84)) < tol, ... + 'Info must sit at barW - 84 (canonical post-reflow); got x=%g (barW=%g)', pInfo(1), barW); + assert(abs(pPL(1) - (barW - 112)) < tol, ... + 'PlantLog must sit at barW - 112 (canonical 4-button-cluster); got x=%g (barW=%g)', pPL(1), barW); + % Hard separation invariant: PlantLog and Info must NOT share x. + assert(abs(pPL(1) - pInfo(1)) > 24 - 2*tol, ... + 'PlantLog and Info must NOT share an x position'); + clear cleanupE cleanupF; + n = 1; +end diff --git a/tests/test_dashboard_preview_overlay.m b/tests/test_dashboard_preview_overlay.m index dfeb8a59..ee7a5cc1 100644 --- a/tests/test_dashboard_preview_overlay.m +++ b/tests/test_dashboard_preview_overlay.m @@ -92,7 +92,7 @@ function case_two_widgets_have_preview_lines() function case_small_dataset_adaptive_buckets() %CASE_SMALL_DATASET_ADAPTIVE_BUCKETS Regression: 50 samples must still preview. - % Default aggregator picks nBuckets in [50, 400]; with 50 samples, + % Default aggregator picks nBuckets in [50, 1000]; with 50 samples, % the old hard floor `if numel(x) < nBuckets, return` returned []. x = linspace(0, 100, 50); y = sin(x * 0.2);