mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { createDataset } from '@core/dataset';
|
|
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
|
import { useDatasetStore } from '../stores/DatasetStore';
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import { ChartBuilderModal } from './ChartBuilderModal';
|
|
|
|
// The builder preview embeds a real Vega chart in an effect; stub the renderer so
|
|
// these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make
|
|
// it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the
|
|
// same class the component imports, so its `instanceof` check matches. The class is
|
|
// declared inside the factory because vi.mock is hoisted above module-scope code.
|
|
vi.mock('../services/chart-renderer', () => {
|
|
class ChartTooLargeError extends Error {
|
|
heightPx: number;
|
|
limitPx: number;
|
|
constructor(heightPx: number, limitPx: number) {
|
|
super('too large');
|
|
this.name = 'ChartTooLargeError';
|
|
this.heightPx = heightPx;
|
|
this.limitPx = limitPx;
|
|
}
|
|
}
|
|
return {
|
|
renderSpec: vi.fn(() =>
|
|
Promise.resolve({
|
|
destroy() {},
|
|
resize() {},
|
|
inspectData: () => null,
|
|
onDataChange: () => () => {},
|
|
}),
|
|
),
|
|
ChartTooLargeError,
|
|
};
|
|
});
|
|
|
|
// React 19 wants this flag set for act() to drive effects without warnings.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const T = new Date('2026-06-01T00:00:00Z');
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
useChartBuilderStore.getState().reset();
|
|
useDatasetStore.getState().reset();
|
|
useSnippetStore.getState().reset();
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
describe('ChartBuilderModal', () => {
|
|
test('renders without an infinite update loop when the config has warnings (regression)', async () => {
|
|
// Two numeric columns → default mark Point (clean). Switching to Bar makes it
|
|
// "two measures on a non-scatter" → a NON-EMPTY warnings array — the exact
|
|
// condition that previously looped because the warnings selector returned a
|
|
// fresh array of objects on every render. The fix derives warnings via useMemo
|
|
// over the stable `config` reference instead.
|
|
const ds = createDataset({
|
|
name: 'Nums',
|
|
data: [
|
|
{ a: 1, b: 2 },
|
|
{ a: 3, b: 4 },
|
|
],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
// `add` reassigns a collision-free id (it is the id authority), so read it back.
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
useChartBuilderStore.getState().setMark('bar');
|
|
expect(useChartBuilderStore.getState().config.mark).toBe('bar');
|
|
|
|
// If the component looped, this act() would throw "Maximum update depth exceeded".
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('Nums'); // the dataset picker names the data
|
|
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
|
|
});
|
|
|
|
test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Nums',
|
|
data: [
|
|
{ a: 1, b: 2 },
|
|
{ a: 3, b: 4 },
|
|
],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The hint renders a [Switch to Point] button (not just prose).
|
|
const fixButton = Array.from(container.querySelectorAll('button')).find(
|
|
(b) => b.textContent === 'Switch to Point',
|
|
);
|
|
expect(fixButton).toBeDefined();
|
|
expect(container.textContent).toContain('scatter');
|
|
|
|
await act(async () => {
|
|
fixButton!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Applying it switches the mark and the hint re-derives away.
|
|
expect(useChartBuilderStore.getState().config.mark).toBe('point');
|
|
expect(container.textContent).not.toContain('scatter');
|
|
});
|
|
|
|
test('shows the canvas-limit message when the chart resolves too large to render', async () => {
|
|
vi.useFakeTimers();
|
|
const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer');
|
|
vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383));
|
|
|
|
const ds = createDataset({
|
|
name: 'Big',
|
|
data: [
|
|
{ a: 1, b: 'x' },
|
|
{ a: 2, b: 'y' },
|
|
],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
// Drive the debounced render so renderSpec runs and rejects with the limit error.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
});
|
|
|
|
expect(container.textContent).toContain('larger than the browser can draw on a canvas');
|
|
expect(container.textContent).toContain('200,000'); // the measured height
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
|
|
vi.useFakeTimers();
|
|
const { renderSpec } = await import('../services/chart-renderer');
|
|
vi.mocked(renderSpec).mockClear();
|
|
const ds = createDataset({
|
|
name: 'Sales',
|
|
data: [
|
|
{ region: 'N', revenue: 100 },
|
|
{ region: 'S', revenue: 50 },
|
|
],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
const store = useChartBuilderStore.getState();
|
|
store.init(id);
|
|
store.addFilter();
|
|
const fid = useChartBuilderStore.getState().config.filters![0].id;
|
|
store.setFilterField(fid, 'revenue');
|
|
store.updateFilter(fid, { op: 'gt', value: '60' });
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
|
|
});
|
|
|
|
expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy();
|
|
const calls = vi.mocked(renderSpec).mock.calls;
|
|
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
|
|
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('the data preview discloses the dataset rows on demand (1D)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Sales',
|
|
data: [{ region: 'North', revenue: 100 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const toggle = Array.from(container.querySelectorAll('button')).find((b) =>
|
|
b.textContent?.includes('Preview rows'),
|
|
);
|
|
expect(toggle).toBeTruthy();
|
|
expect(container.querySelector('table')).toBeNull(); // collapsed by default
|
|
|
|
await act(async () => {
|
|
toggle!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.querySelector('table')).toBeTruthy();
|
|
expect(container.textContent).toContain('region');
|
|
expect(container.textContent).toContain('North');
|
|
});
|
|
|
|
test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => {
|
|
const ds = createDataset({
|
|
name: 'S',
|
|
data: [{ a: 1 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
const store = useChartBuilderStore.getState();
|
|
store.init(id);
|
|
store.addFilter();
|
|
const fid = useChartBuilderStore.getState().config.filters![0].id;
|
|
store.setFilterMode(fid, 'expression');
|
|
store.updateFilter(fid, { expr: 'datum.a *' });
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Polite, not assertive: live per-keystroke validation uses role="status" with a
|
|
// status glyph, never an assertive alert (council: APG Alert / WCAG 2.2.4).
|
|
const messages = Array.from(container.querySelectorAll('[role="status"]'));
|
|
const errorMsg = messages.find((n) => /nexpected|Invalid/.test(n.textContent ?? ''));
|
|
expect(errorMsg).toBeTruthy();
|
|
// The expression input is linked to its message and flagged invalid.
|
|
const exprInput = container.querySelector('input[aria-label="Filter expression"]');
|
|
expect(exprInput?.getAttribute('aria-invalid')).toBe('true');
|
|
expect(exprInput?.getAttribute('aria-describedby')).toBe(errorMsg?.id);
|
|
});
|
|
|
|
test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
|
|
const ds = createDataset({
|
|
name: 'S',
|
|
data: [{ a: 1 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
const refLink = () =>
|
|
Array.from(container.querySelectorAll('a')).find((a) =>
|
|
a.textContent?.includes('Vega expression'),
|
|
);
|
|
expect(refLink()).toBeUndefined(); // no expression yet → no reference link
|
|
|
|
await act(async () => {
|
|
useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression
|
|
await Promise.resolve();
|
|
});
|
|
expect(refLink()).toBeDefined();
|
|
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
|
|
});
|
|
|
|
test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Shop',
|
|
data: [{ region: 'E', sales: 5 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
const store = useChartBuilderStore.getState();
|
|
store.init(id);
|
|
store.setChannelColumn('x', null);
|
|
store.setChannelColumn('y', null);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
|
|
b.textContent?.includes('region'),
|
|
);
|
|
expect(fieldButton).toBeDefined();
|
|
|
|
await act(async () => {
|
|
fieldButton!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Unarmed, the click opens an explicit channel chooser (portaled to <body>)
|
|
// rather than silently filling the first empty seat (council 2026-06-12).
|
|
expect(useChartBuilderStore.getState().config.encodings.x).toBeNull();
|
|
const option = Array.from(document.body.querySelectorAll('button')).find((b) =>
|
|
b.textContent?.includes('Columns (X)'),
|
|
);
|
|
expect(option).toBeDefined();
|
|
|
|
await act(async () => {
|
|
option!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
|
|
field: 'region',
|
|
type: 'nominal',
|
|
});
|
|
});
|
|
|
|
test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Shop',
|
|
data: [{ region: 'E', sales: 5 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
const store = useChartBuilderStore.getState();
|
|
store.init(id);
|
|
store.setChannelColumn('x', null);
|
|
store.setChannelColumn('y', null);
|
|
store.focusChannel('y');
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The armed state announces itself at the shelf.
|
|
expect(container.textContent).toContain('Assigning to Y');
|
|
|
|
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
|
|
b.textContent?.includes('region'),
|
|
);
|
|
await act(async () => {
|
|
fieldButton!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(useChartBuilderStore.getState().config.encodings.y).toEqual({
|
|
field: 'region',
|
|
type: 'nominal',
|
|
});
|
|
expect(useChartBuilderStore.getState().activeChannel).toBeNull();
|
|
});
|
|
|
|
test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Sales',
|
|
data: [{ day: '2026-01-01', v: 1 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
const store = useChartBuilderStore.getState();
|
|
store.init(id);
|
|
store.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity
|
|
store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)"
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const trigger = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label^="Granularity for"]',
|
|
);
|
|
expect(trigger).toBeTruthy();
|
|
await act(async () => {
|
|
trigger!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// A selector list ('[aria-current="true"], button') would return the first
|
|
// button in document order — the "None (raw)" option — instead of the selection.
|
|
const focused = document.activeElement as HTMLElement;
|
|
expect(focused.getAttribute('aria-current')).toBe('true');
|
|
expect(focused.textContent).toContain('Month');
|
|
});
|
|
|
|
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
|
|
const ds = createDataset({
|
|
name: 'Shop',
|
|
data: [{ region: 'E', sales: 5 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The empty Colour slot offers a "Use a constant" ghost button (Colour is
|
|
// first in Marks).
|
|
const constButton = Array.from(container.querySelectorAll('button')).find(
|
|
(b) => b.textContent === 'Use a constant',
|
|
);
|
|
expect(constButton).toBeDefined();
|
|
|
|
await act(async () => {
|
|
constButton!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined();
|
|
// A colour picker renders for the constant.
|
|
expect(container.querySelector('input[type="color"]')).not.toBeNull();
|
|
});
|
|
|
|
// The intent front door's core logic (which chip lights, what each applies, gating)
|
|
// is covered in core/store; these check only the parts that live in the component —
|
|
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
|
|
// shared primitive), and that a click reshapes the chart.
|
|
describe('intent front door (the "What do you want to show?" strip)', () => {
|
|
// Two categories + one date + two measures → every intent applies; enough shape to
|
|
// light a default and to exercise an applied intent (Heatmap needs two categories).
|
|
const seedSuperstore = () => {
|
|
const ds = createDataset({
|
|
name: 'Superstore',
|
|
data: [
|
|
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
|
|
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
|
|
],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
return id;
|
|
};
|
|
|
|
const chips = (): HTMLButtonElement[] =>
|
|
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
|
|
|
|
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
|
|
seedSuperstore();
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const all = chips();
|
|
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
|
|
// Exactly one chip is in the tab order; the rest are roving (-1).
|
|
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
|
|
// The smart default for a category+count shape is Compare, so its chip is pressed.
|
|
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
|
|
expect(pressed).toHaveLength(1);
|
|
expect(pressed[0].textContent).toBe('Compare');
|
|
});
|
|
|
|
test('an inapplicable intent is disabled and names its reason', async () => {
|
|
// One category, one measure, no date and no second measure → Correlation/Time/
|
|
// Heatmap/Part-to-whole cannot apply.
|
|
const ds = createDataset({
|
|
name: 'Thin',
|
|
data: [{ region: 'E', sales: 5 }],
|
|
format: 'json',
|
|
source: 'inline',
|
|
now: T,
|
|
});
|
|
useDatasetStore.getState().add(ds);
|
|
const id = useDatasetStore.getState().datasets[0].id;
|
|
useChartBuilderStore.getState().init(id);
|
|
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
|
|
expect(correlation.getAttribute('aria-disabled')).toBe('true');
|
|
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
|
|
});
|
|
|
|
test('clicking an enabled chip reshapes the chart to that intent', async () => {
|
|
seedSuperstore();
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
|
|
await act(async () => {
|
|
heatmap.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
|
|
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
|
|
type: 'quantitative',
|
|
aggregate: 'count',
|
|
});
|
|
});
|
|
|
|
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
|
|
seedSuperstore();
|
|
await act(async () => {
|
|
root.render(<ChartBuilderModal />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const all = chips();
|
|
const start = all.findIndex((b) => b.tabIndex === 0);
|
|
const markBefore = useChartBuilderStore.getState().config.mark;
|
|
|
|
await act(async () => {
|
|
all[start].focus();
|
|
all[start].dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
|
|
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
|
|
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
|
|
});
|
|
});
|
|
});
|