Skip to content
243 changes: 243 additions & 0 deletions packages/react-spectrum-charts-s2/src/VegaChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { render, waitFor } from '@testing-library/react';
import { Spec, View, expressionFunction } from 'vega';
import embed from 'vega-embed';

import { getTitleFontShorthand } from '@spectrum-charts/vega-spec-builder-s2';

import { VegaChart, VegaChartProps, resizeView } from './VegaChart';

jest.mock('vega-embed');
Expand All @@ -30,6 +32,7 @@ const createMockView = (): View =>
resize: mockResize,
height: mockHeight,
width: mockWidth,
signal: jest.fn(),
finalize: jest.fn(),
}) as unknown as View;

Expand All @@ -49,6 +52,20 @@ const defaultProps: VegaChartProps = {
width: 800,
};

// Overrides document.fonts (not implemented by jsdom) for the duration of a test. jest.spyOn
// can't reach it because it lives on the prototype, not the document instance.
const stubDocumentFonts = (fonts: unknown): (() => void) => {
const savedDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts');
Object.defineProperty(document, 'fonts', { get: () => fonts, configurable: true });
return () => {
if (savedDescriptor) {
Object.defineProperty(document, 'fonts', savedDescriptor);
} else {
delete (document as unknown as Record<string, unknown>).fonts;
}
};
};

describe('resizeView', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -92,6 +109,23 @@ describe('resizeView', () => {
});
});

describe('rscWrapTitle expression function', () => {
// The function is registered at module load time when VegaChart.tsx is imported above.
const fn = expressionFunction('rscWrapTitle') as (text: string, maxWidth: number) => string[];

test('returns text as a single-element array when it fits on one line', () => {
expect(fn('Page Views by Region', 440)).toStrictEqual(['Page Views by Region']);
});

test('wraps text onto multiple lines when it exceeds maxWidth', () => {
// jsdom measureText returns text.length px; maxWidth=40 fits "...by Product" (35) but not "...Category" (44)
expect(fn('Quarterly Revenue Growth by Product Category and Geographic Region', 40)).toStrictEqual([
'Quarterly Revenue Growth by Product',
'Category and Geographic Region',
]);
});
});

describe('rscContainerWidth expression function', () => {
// The function is registered at module load time when VegaChart.tsx is imported above.
const fn = expressionFunction('rscContainerWidth') as (this: unknown) => number;
Expand All @@ -113,6 +147,215 @@ describe('rscContainerWidth expression function', () => {
});
});

describe('title wrapping after font load', () => {
let mockView: View;

beforeEach(() => {
jest.clearAllMocks();
mockView = createMockView();
mockEmbed.mockResolvedValue({ view: mockView } as unknown as Awaited<ReturnType<typeof embed>>);
});

afterEach(() => {
jest.restoreAllMocks();
});

test('does not call view.signal when spec has no title signals', async () => {
render(<VegaChart {...defaultProps} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
// flush all pending promises so the fonts.ready path would have had a chance to run
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockView.signal).not.toHaveBeenCalled();
});

test('sets rscWrappedTitleText after fonts load when spec has a title signal', async () => {
const titleSpec: Spec = {
signals: [{ name: 'rscTitleText', value: 'My Title' }],
};
(mockView.signal as jest.Mock)
.mockReturnValueOnce('My Title') // read rscTitleText
.mockReturnValueOnce(440) // read rscTitleLimit
.mockReturnValue(mockView); // write rscWrappedTitleText — return view for .runAsync() chaining

render(<VegaChart {...defaultProps} spec={titleSpec} />);

await waitFor(() =>
expect(mockView.signal).toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array))
);
});

test('skips signal update when title text is empty', async () => {
const titleSpec: Spec = {
signals: [{ name: 'rscTitleText', value: '' }],
};
(mockView.signal as jest.Mock).mockReturnValueOnce('');

render(<VegaChart {...defaultProps} spec={titleSpec} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(mockView.signal).not.toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array));
});

test('skips signal update when view is stale at font load time', async () => {
let resolveFontsReady!: () => void;
const deferred = new Promise<void>((resolve) => {
resolveFontsReady = resolve;
});

// `load` resolves immediately so the pre-embed gate doesn't block this test's initial
// embed() call; `ready` stays pending so we control when the post-embed corrective path fires.
const restoreFonts = stubDocumentFonts({ load: jest.fn().mockResolvedValue([]), ready: deferred });

const titleSpec: Spec = {
signals: [{ name: 'rscTitleText', value: 'My Title' }],
};

const { unmount } = render(<VegaChart {...defaultProps} spec={titleSpec} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));

// Unmount sets chartView.current = undefined via effect cleanup
unmount();

// Resolve fonts.ready after unmount — stale view guard should prevent signal update
resolveFontsReady();
await new Promise((resolve) => setTimeout(resolve, 0));

expect(mockView.signal).not.toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array));

restoreFonts();
});
});

describe('initial embed font readiness gate', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEmbed.mockResolvedValue({ view: createMockView() } as unknown as Awaited<ReturnType<typeof embed>>);
});

afterEach(() => {
jest.useRealTimers();
});

const titleSpec: Spec = { signals: [{ name: 'rscTitleText', value: 'My Title' }] };

test('delays the initial embed until document.fonts.load resolves when a title is present', async () => {
let resolveLoad!: () => void;
const load = jest.fn().mockReturnValue(new Promise<void>((resolve) => (resolveLoad = resolve)));
const restoreFonts = stubDocumentFonts({ load });

render(<VegaChart {...defaultProps} spec={titleSpec} />);

expect(load).toHaveBeenCalledTimes(1);
expect(mockEmbed).not.toHaveBeenCalled();

resolveLoad();
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));

restoreFonts();
});

test('calls document.fonts.load with the same font shorthand wrapTitleText measures with', async () => {
const load = jest.fn().mockResolvedValue([]);
const restoreFonts = stubDocumentFonts({ load });

render(<VegaChart {...defaultProps} spec={titleSpec} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(load).toHaveBeenCalledWith(getTitleFontShorthand());

restoreFonts();
});

test('does not call document.fonts.load and embeds immediately when there is no title', async () => {
const load = jest.fn().mockResolvedValue([]);
const restoreFonts = stubDocumentFonts({ load });

render(<VegaChart {...defaultProps} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(load).not.toHaveBeenCalled();

restoreFonts();
});

test('does not call document.fonts.load and embeds immediately when the title is an empty string', async () => {
const load = jest.fn().mockResolvedValue([]);
const restoreFonts = stubDocumentFonts({ load });
const emptyTitleSpec: Spec = { signals: [{ name: 'rscTitleText', value: '' }] };

render(<VegaChart {...defaultProps} spec={emptyTitleSpec} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(load).not.toHaveBeenCalled();

restoreFonts();
});

test('still embeds if document.fonts.load rejects', async () => {
const load = jest.fn().mockRejectedValue(new Error('font fetch failed'));
const restoreFonts = stubDocumentFonts({ load });

render(<VegaChart {...defaultProps} spec={titleSpec} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));

restoreFonts();
});

test('proceeds with the initial embed after the timeout even if document.fonts.load never resolves', async () => {
jest.useFakeTimers();
const load = jest.fn().mockReturnValue(new Promise(() => {}));
const restoreFonts = stubDocumentFonts({ load });

render(<VegaChart {...defaultProps} spec={titleSpec} />);
expect(mockEmbed).not.toHaveBeenCalled();

await jest.advanceTimersByTimeAsync(250);

expect(mockEmbed).toHaveBeenCalledTimes(1);

restoreFonts();
});

test('gates only the first embed — a later re-embed does not wait on document.fonts.load again', async () => {
const load = jest.fn().mockResolvedValue([]);
const restoreFonts = stubDocumentFonts({ load });

const { rerender } = render(<VegaChart {...defaultProps} spec={titleSpec} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(load).toHaveBeenCalledTimes(1);

// A new spec object reference (e.g. from a hover-driven prop update upstream) re-runs the
// embed effect; it must re-embed synchronously rather than waiting on the font again.
const secondTitleSpec: Spec = { signals: [{ name: 'rscTitleText', value: 'My Title' }] };
rerender(<VegaChart {...defaultProps} spec={secondTitleSpec} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(2));
expect(load).toHaveBeenCalledTimes(1);

restoreFonts();
});

test('does not gate a title that first appears after an untitled initial mount', async () => {
const load = jest.fn().mockResolvedValue([]);
const restoreFonts = stubDocumentFonts({ load });

const { rerender } = render(<VegaChart {...defaultProps} spec={defaultSpec} />);
await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1));
expect(load).not.toHaveBeenCalled();

// Title arrives on a later render (e.g. computed from async data) — this must not be
// treated as the "initial" embed and delayed, since the chart is already visible.
rerender(<VegaChart {...defaultProps} spec={titleSpec} />);

await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(2));
expect(load).not.toHaveBeenCalled();

restoreFonts();
});
});

// AN-445759: regression tests for the init render cycle fix
describe('VegaChart init render cycle', () => {
beforeEach(() => {
Expand Down
Loading