mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Inspector: live data updates under interactive selections
This commit is contained in:
@@ -75,9 +75,11 @@ This is the at-a-glance list; keep it in sync with them.
|
|||||||
**Next (flagged for build):**
|
**Next (flagged for build):**
|
||||||
|
|
||||||
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
|
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
|
||||||
durable composition support across the data-facing features. Done: the Vega-Lite-fidelity
|
durable composition support across the data-facing features. **Complete** (M1–M5): the
|
||||||
reference classifier (`core/spec-data`) and the view-scoped editor data context. Remaining:
|
Vega-Lite-fidelity reference classifier (`core/spec-data`), the view-scoped editor data
|
||||||
per-view data inspection (`DataInspector` view selector), then view-scoped Extract.
|
context, per-view data inspection, view-scoped Extract (inline + self-defined `datasets`),
|
||||||
|
and live/interactive inspection. The durable contract is recorded in `docs/architecture`
|
||||||
|
05 (live inspection) and 07 (reference detection + extraction, §3.1–3.2).
|
||||||
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
|
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
|
||||||
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
|
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
|
||||||
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
|
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ when no chart is up), wrapping the view exactly like `toImageURL`. It works in t
|
|||||||
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
|
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
|
||||||
every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
|
every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
|
||||||
chooses which to show — labels never expose Vega's compiler names, see arch 10.)
|
chooses which to show — labels never expose Vega's compiler names, see arch 10.)
|
||||||
|
- **Stay live under interaction.** A selection that _filters_ a downstream view recomputes
|
||||||
|
that view's compiled table in place (no re-embed), so the open panel re-reads to track it —
|
||||||
|
"what am I visualizing now". `RenderHandle.onDataChange` attaches a debounced
|
||||||
|
`view.addDataListener` to each drawn table; a highlight selection (a `condition` encoding)
|
||||||
|
changes no data, so it never fires. Always live, no toggle — gated on the panel being open
|
||||||
|
like the read itself, and re-subscribed per settled render so it tracks the current handle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -82,19 +82,34 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export
|
|||||||
`columns · rows` cue. Enumerating by drawn table (not authored view) is forced by
|
`columns · rows` cue. Enumerating by drawn table (not authored view) is forced by
|
||||||
Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the
|
Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the
|
||||||
single-pair `core/result-data`.
|
single-pair `core/result-data`.
|
||||||
- **M5 — live / interactive inspection** — make the inspector react to interactive
|
- **M5 — live / interactive inspection** ✅ — the inspector reacts to interactive
|
||||||
selections. A selection-as-**filter** (`filter: {param}`) recomputes a downstream
|
selections. `RenderHandle.onDataChange` attaches a debounced `view.addDataListener`
|
||||||
view's `data_N` live, so the inspector should re-read on selection change to show
|
to each drawn table's resolved + input names; a selection-as-**filter**
|
||||||
the brushed result ("what am I visualizing _now_"); a selection-as-**highlight**
|
(`filter: {param}`) recomputes a downstream view's `data_N`, so its listener fires
|
||||||
(a `condition` encoding) changes no data, so nothing to react to. Needs a refresh
|
and `LivePreview` bumps a `liveEpoch` that re-reads the table ("what am I
|
||||||
model beyond the per-render `renderEpoch`: subscribe to the live view
|
visualizing _now_"); a selection-as-**highlight** (a `condition` encoding) changes
|
||||||
(`view.addDataListener` / selection signals), debounced (a brush drag pulses
|
no data, so nothing fires. The watcher is gated on the inspector being open
|
||||||
continuously — latency/interaction `/council` pass), and a default-on-vs-toggle
|
(a collapsed one costs nothing) and is **always live, no toggle** — the table just
|
||||||
choice. Selection `*_store` tables are not drawn, so the M4 enumeration already
|
tracks the brush; the ~120ms debounce coalesces a drag's continuous pulses.
|
||||||
ignores them.
|
Selection `*_store` tables are not drawn, so the M4 enumeration already ignores them.
|
||||||
- **M3 — view-scoped extract** — seed Extract from the focused view's inline data
|
- **M3 — view-scoped extract** ✅ — Extract is scoped to the view at the cursor.
|
||||||
(reusing the cursor-scope machinery) and rewrite that view's `data`.
|
`services/extract-action` resolves the focused view's data binding
|
||||||
|
(`dataBindingAtPath`) and lifts whichever of two embedded-data shapes it carries:
|
||||||
|
a view's inline **`data.values`** (`inlineValuesOf` → rewrite that view's `data`
|
||||||
|
block at its anchor path), or a **`{ name }` reference to a self-defined
|
||||||
|
`datasets` entry** (`selfDefinedPayloadOf` → `promoteSelfDefinedDataset`: drop the
|
||||||
|
`datasets` entry, and the map when it empties, so the same reference resolves to
|
||||||
|
the new library dataset; rename refs when the name changes, pre-filled with the
|
||||||
|
existing name). The toolbar offers Extract whenever any view carries either shape
|
||||||
|
(`specHasExtractableData`); a cursor in a view with neither (a library ref, url,
|
||||||
|
generator) gets a guide toast. A single-view spec resolves to the root binding
|
||||||
|
from any cursor, so the common case is unchanged. Confirm re-serializes in the
|
||||||
|
app's house style. A `lookup` transform's inline `from.data` is covered incidentally
|
||||||
|
— `dataBindingAtPath` finds it like any view binding (which also means the editor
|
||||||
|
_hints_ read the lookup table's columns when the cursor sits inside the transform;
|
||||||
|
acceptable for now, noted). The orphan case — a `datasets` entry no view references
|
||||||
|
— is out of scope (no view to scope the cursor to; it is dead data to delete).
|
||||||
|
|
||||||
Delivery is incremental, one milestone per commit, verified against real behavior.
|
Delivery is incremental, one milestone per commit, verified against real behavior.
|
||||||
The consolidated data-model contract write-up into `docs/architecture` (05/08)
|
The durable contract is recorded in `docs/architecture` 05 (live inspection) and 07
|
||||||
lands once the shape is final.
|
(reference detection + extraction, §3.1–3.2); this memo stays the point-in-time record.
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ vi.mock('../services/chart-renderer', () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
renderSpec: vi.fn(() =>
|
renderSpec: vi.fn(() =>
|
||||||
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
Promise.resolve({
|
||||||
|
destroy() {},
|
||||||
|
resize() {},
|
||||||
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
ChartTooLargeError,
|
ChartTooLargeError,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({
|
|||||||
resize() {},
|
resize() {},
|
||||||
toImageURL: () => Promise.resolve(''),
|
toImageURL: () => Promise.resolve(''),
|
||||||
inspectData: () => null,
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ interface DataInspectorPanelProps {
|
|||||||
* `renderEpoch`).
|
* `renderEpoch`).
|
||||||
*/
|
*/
|
||||||
getData: () => InspectedData | null;
|
getData: () => InspectedData | null;
|
||||||
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
|
/**
|
||||||
|
* Refresh trigger: bumps whenever the data to show may have changed, so the open
|
||||||
|
* table re-reads. The live-preview pane bumps it on each settled render *and* on
|
||||||
|
* an interactive selection that changes the inspected rows (live mode, M5); the
|
||||||
|
* builder bumps it on render only.
|
||||||
|
*/
|
||||||
renderEpoch: number;
|
renderEpoch: number;
|
||||||
/**
|
/**
|
||||||
* Explicit panel height (px) — the live-preview pane sets this from its
|
* Explicit panel height (px) — the live-preview pane sets this from its
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ vi.mock('../services/chart-renderer', () => ({
|
|||||||
},
|
},
|
||||||
resize() {},
|
resize() {},
|
||||||
inspectData: () => null,
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ export function LivePreview() {
|
|||||||
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
|
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
|
||||||
// each one is new data the inspector must pick up.
|
// each one is new data the inspector must pick up.
|
||||||
const [renderEpoch, setRenderEpoch] = useState(0);
|
const [renderEpoch, setRenderEpoch] = useState(0);
|
||||||
|
// Bumped (debounced, inside the handle) when an interactive selection changes
|
||||||
|
// the inspected data without a re-render — the live data inspector (spec §04;
|
||||||
|
// multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse
|
||||||
|
// re-reads the table without re-subscribing the listener; their sum is the
|
||||||
|
// inspector's single refresh trigger (each event bumps exactly one, so the sum
|
||||||
|
// is strictly monotonic — no collisions).
|
||||||
|
const [liveEpoch, setLiveEpoch] = useState(0);
|
||||||
|
|
||||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||||
@@ -397,6 +404,19 @@ export function LivePreview() {
|
|||||||
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
|
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
|
||||||
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
|
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
|
||||||
|
|
||||||
|
// Live data inspection (spec §04): while the inspector is open, re-read the table
|
||||||
|
// when an interactive selection changes the data it shows (a filtering brush). The
|
||||||
|
// handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire.
|
||||||
|
// Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current
|
||||||
|
// handle, and only while the inspector is open so a collapsed one costs nothing.
|
||||||
|
// handleRef is a ref (read, not a dep); the cleanup unsubscribes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inspectorOpen) return;
|
||||||
|
const handle = handleRef.current;
|
||||||
|
if (!handle) return;
|
||||||
|
return handle.onDataChange(() => setLiveEpoch((e) => e + 1));
|
||||||
|
}, [inspectorOpen, renderEpoch]);
|
||||||
|
|
||||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||||
// observe the element, so we do: one observer on the stable host node for the
|
// observe the element, so we do: one observer on the stable host node for the
|
||||||
// component's life. Only responsive fit modes depend on container size;
|
// component's life. Only responsive fit modes depend on container size;
|
||||||
@@ -478,7 +498,7 @@ export function LivePreview() {
|
|||||||
<DataInspector
|
<DataInspector
|
||||||
id="preview-data-inspector"
|
id="preview-data-inspector"
|
||||||
getData={getInspectData}
|
getData={getInspectData}
|
||||||
renderEpoch={renderEpoch}
|
renderEpoch={renderEpoch + liveEpoch}
|
||||||
heightPx={inspectorOpen ? inspectorHeight : undefined}
|
heightPx={inspectorOpen ? inspectorHeight : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import { Onboarding } from './Onboarding';
|
|||||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||||
// finalizes it on unmount.
|
// finalizes it on unmount.
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
renderSpec: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
destroy() {},
|
||||||
|
resize() {},
|
||||||
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const okHandle = () => ({
|
|||||||
resize() {},
|
resize() {},
|
||||||
toImageURL: () => Promise.resolve(''),
|
toImageURL: () => Promise.resolve(''),
|
||||||
inspectData: () => null,
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({
|
|||||||
resize() {},
|
resize() {},
|
||||||
toImageURL: () => Promise.resolve(''),
|
toImageURL: () => Promise.resolve(''),
|
||||||
inspectData: () => null,
|
inspectData: () => null,
|
||||||
|
onDataChange: () => () => {},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -66,6 +66,60 @@ export interface InspectedData {
|
|||||||
tables: InspectableTable[];
|
tables: InspectableTable[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce for the live data inspector (spec §04; multi-view scope doc M5). A
|
||||||
|
* brush drag pulses `addDataListener` continuously; coalescing to one re-read per
|
||||||
|
* ~quiet-frame keeps the table feeling live without thrashing the grid on every
|
||||||
|
* pixel of the drag.
|
||||||
|
*/
|
||||||
|
export const LIVE_INSPECT_DEBOUNCE_MS = 120;
|
||||||
|
|
||||||
|
/** The minimal Vega `View` surface the live-inspect watcher needs. */
|
||||||
|
interface DataChangeView {
|
||||||
|
addDataListener(name: string, handler: () => void): unknown;
|
||||||
|
removeDataListener(name: string, handler: () => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach debounced listeners to every table the inspector shows so an interactive
|
||||||
|
* selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the
|
||||||
|
* inspector live — see `RenderHandle.onDataChange`. Watches the union of each
|
||||||
|
* drawn table's resolved + input names (deduped); a highlight selection changes no
|
||||||
|
* data, so none fire. Returns an unsubscribe that cancels any pending re-read and
|
||||||
|
* detaches the listeners — but skips detaching once the view is finalized, since
|
||||||
|
* `view.finalize()` has already dropped every listener (and the unmount cleanup
|
||||||
|
* order can run this after the destroy). `isFinalized` is a getter, not a boolean,
|
||||||
|
* so it reflects the view's state at unsubscribe time, not subscribe time.
|
||||||
|
*/
|
||||||
|
export function watchInspectableData(
|
||||||
|
view: DataChangeView,
|
||||||
|
vgSpec: unknown,
|
||||||
|
onChange: () => void,
|
||||||
|
isFinalized: () => boolean,
|
||||||
|
): () => void {
|
||||||
|
if (isFinalized()) return () => {};
|
||||||
|
const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))];
|
||||||
|
if (names.length === 0) return () => {};
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const handler = (): void => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
onChange();
|
||||||
|
}, LIVE_INSPECT_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
for (const name of names) view.addDataListener(name, handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderHandle {
|
export interface RenderHandle {
|
||||||
/** Finalize the underlying Vega view and clear the node. */
|
/** Finalize the underlying Vega view and clear the node. */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
@@ -104,6 +158,16 @@ export interface RenderHandle {
|
|||||||
* "no chart" so the inspector can say which.
|
* "no chart" so the inspector can say which.
|
||||||
*/
|
*/
|
||||||
inspectData(): InspectedData | null;
|
inspectData(): InspectedData | null;
|
||||||
|
/**
|
||||||
|
* Subscribe to live changes of the inspected tables, for the data inspector's
|
||||||
|
* live mode (spec §04; multi-view scope doc M5). An interactive selection that
|
||||||
|
* *filters* a downstream view recomputes that view's compiled table in place —
|
||||||
|
* no re-embed — so a static inspector would show stale rows until the next full
|
||||||
|
* render; this fires (debounced) so the caller can re-read via `inspectData()`.
|
||||||
|
* A highlight selection (a `condition` encoding) changes no data, so it never
|
||||||
|
* fires. Returns an unsubscribe; a no-op when the view is already finalized.
|
||||||
|
*/
|
||||||
|
onDataChange(listener: () => void): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
@@ -323,5 +387,8 @@ export async function renderSpec(
|
|||||||
}));
|
}));
|
||||||
return { tables };
|
return { tables };
|
||||||
},
|
},
|
||||||
|
onDataChange(listener) {
|
||||||
|
return watchInspectableData(result.view, result.vgSpec, listener, () => finalized);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Live-inspection watcher (`watchInspectableData`) — the wiring behind
|
||||||
|
* `RenderHandle.onDataChange` (spec §04; multi-view scope doc M5). Verified against
|
||||||
|
* a fake Vega view + fake timers; the real selection→filter→data recompute is an
|
||||||
|
* integration behavior exercised manually (renderSpec is vega-embed-bound).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LIVE_INSPECT_DEBOUNCE_MS, watchInspectableData } from './chart-renderer';
|
||||||
|
|
||||||
|
/** A compiled-Vega-shape spec: one drawn table data_0, sourced from source_0. */
|
||||||
|
const vgSpec = {
|
||||||
|
data: [{ name: 'source_0' }, { name: 'data_0', source: 'source_0' }],
|
||||||
|
marks: [{ type: 'symbol', from: { data: 'data_0' } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeView() {
|
||||||
|
const listeners = new Map<string, Set<() => void>>();
|
||||||
|
return {
|
||||||
|
added: [] as string[],
|
||||||
|
removed: [] as string[],
|
||||||
|
addDataListener(name: string, handler: () => void) {
|
||||||
|
const set = listeners.get(name) ?? new Set<() => void>();
|
||||||
|
set.add(handler);
|
||||||
|
listeners.set(name, set);
|
||||||
|
this.added.push(name);
|
||||||
|
},
|
||||||
|
removeDataListener(name: string, handler: () => void) {
|
||||||
|
listeners.get(name)?.delete(handler);
|
||||||
|
this.removed.push(name);
|
||||||
|
},
|
||||||
|
fire(name: string) {
|
||||||
|
for (const handler of listeners.get(name) ?? []) handler();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe('watchInspectableData', () => {
|
||||||
|
it('watches both the resolved and input table of each drawn view', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
watchInspectableData(
|
||||||
|
view,
|
||||||
|
vgSpec,
|
||||||
|
() => {},
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
expect(new Set(view.added)).toEqual(new Set(['data_0', 'source_0']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces a burst of changes into a single re-read', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
watchInspectableData(view, vgSpec, onChange, () => false);
|
||||||
|
|
||||||
|
view.fire('data_0');
|
||||||
|
view.fire('data_0');
|
||||||
|
view.fire('data_0'); // a brush drag pulsing
|
||||||
|
expect(onChange).not.toHaveBeenCalled(); // still within the debounce window
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribe detaches listeners and cancels a pending re-read', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const stop = watchInspectableData(view, vgSpec, onChange, () => false);
|
||||||
|
|
||||||
|
view.fire('data_0');
|
||||||
|
stop();
|
||||||
|
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled(); // pending re-read cancelled
|
||||||
|
expect(new Set(view.removed)).toEqual(new Set(['data_0', 'source_0']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the view is already finalized at subscribe', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const stop = watchInspectableData(
|
||||||
|
view,
|
||||||
|
vgSpec,
|
||||||
|
() => {},
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
expect(view.added).toEqual([]);
|
||||||
|
stop(); // safe
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after finalize, unsubscribe cancels the timer but does not touch the dead view', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
let finalized = false;
|
||||||
|
const stop = watchInspectableData(view, vgSpec, onChange, () => finalized);
|
||||||
|
|
||||||
|
view.fire('data_0');
|
||||||
|
finalized = true; // view.finalize() ran (dropping its own listeners) before cleanup
|
||||||
|
stop();
|
||||||
|
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(view.removed).toEqual([]); // didn't call removeDataListener on a dead view
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for a spec that draws no inspectable table', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const stop = watchInspectableData(
|
||||||
|
view,
|
||||||
|
{ data: [], marks: [] },
|
||||||
|
() => {},
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
expect(view.added).toEqual([]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user