mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
|
||
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
|
||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
|
||
|
||
describe('InteractiveSparkline hover behavior', () => {
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
cleanup();
|
||
});
|
||
|
||
it('keeps the sparkline on shell, runtime, and model owners', () => {
|
||
expect(interactiveSparklineSource).toContain('useInteractiveSparklineState');
|
||
expect(interactiveSparklineSource).not.toContain('createEffect');
|
||
expect(interactiveSparklineSource).not.toContain('createSignal');
|
||
expect(interactiveSparklineSource).not.toContain('scheduleSparkline');
|
||
expect(interactiveSparklineStateSource).toContain('scheduleSparkline');
|
||
expect(interactiveSparklineStateSource).toContain('createSignal');
|
||
expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData');
|
||
expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState');
|
||
});
|
||
|
||
it('shows a vertical dashed hover line and a tooltip', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
rangeLabel="1h"
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 40 },
|
||
{ timestamp: now - 10_000, value: 50 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
const svg = container.querySelector('svg');
|
||
expect(svg).not.toBeNull();
|
||
if (!svg) return;
|
||
|
||
// JSDOM returns zeros by default, but the hover math requires a non-zero width.
|
||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
({
|
||
left: 0,
|
||
top: 100,
|
||
width: 200,
|
||
height: 50,
|
||
right: 200,
|
||
bottom: 150,
|
||
x: 0,
|
||
y: 100,
|
||
toJSON: () => ({}),
|
||
}) as unknown as DOMRect;
|
||
|
||
fireEvent.mouseMove(svg, { clientX: 199, clientY: 110 });
|
||
|
||
expect(svg.querySelector('line[stroke-dasharray="3 3"]')).toBeInTheDocument();
|
||
expect(await screen.findByText('CPU')).toBeInTheDocument();
|
||
expect(screen.getByText('50.0%')).toBeInTheDocument();
|
||
|
||
fireEvent.mouseLeave(svg);
|
||
expect(svg.querySelector('line[stroke-dasharray="3 3"]')).toBeNull();
|
||
expect(screen.queryByText('CPU')).toBeNull();
|
||
});
|
||
|
||
it('publishes synchronized hover identity and clears it on leave', () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
const onHoverSyncChange = vi.fn();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
hoverSourceKey="cpu"
|
||
onHoverSyncChange={onHoverSyncChange}
|
||
highlightNearestSeriesOnHover
|
||
series={[
|
||
{
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 40 },
|
||
{ timestamp: now - 10_000, value: 50 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
const svg = container.querySelector('svg');
|
||
expect(svg).not.toBeNull();
|
||
if (!svg) return;
|
||
|
||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
({
|
||
left: 0,
|
||
top: 100,
|
||
width: 200,
|
||
height: 50,
|
||
right: 200,
|
||
bottom: 150,
|
||
x: 0,
|
||
y: 100,
|
||
toJSON: () => ({}),
|
||
}) as unknown as DOMRect;
|
||
|
||
fireEvent.mouseMove(svg, { clientX: 199, clientY: 125 });
|
||
|
||
const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0];
|
||
expect(publishedHover).toMatchObject({
|
||
sourceKey: 'cpu',
|
||
seriesId: 'alpha',
|
||
});
|
||
expect(publishedHover?.timestamp).toBe(now - 10_000);
|
||
|
||
fireEvent.mouseLeave(svg);
|
||
expect(onHoverSyncChange).toHaveBeenLastCalledWith(null);
|
||
});
|
||
|
||
it('keeps a synchronized hover timestamp visible even when the synced series is absent locally', () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
hoverSourceKey="memory"
|
||
hoverSync={{
|
||
sourceKey: 'cpu',
|
||
seriesId: 'missing-series',
|
||
timestamp: now - 10_000,
|
||
}}
|
||
series={[
|
||
{
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 40 },
|
||
{ timestamp: now - 10_000, value: 50 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
const root = container.firstElementChild;
|
||
expect(root?.getAttribute('data-active-hover-timestamp')).toBe(String(now - 10_000));
|
||
const svg = container.querySelector('svg');
|
||
expect(svg?.querySelector('line[stroke-dasharray="3 3"]')).toBeInTheDocument();
|
||
expect(screen.queryByText('Alpha')).toBeNull();
|
||
});
|
||
|
||
it('limits tooltip rows and shows the "+N more series" affordance', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const makeSeries = (i: number, value: number) => ({
|
||
name: `S${i}`,
|
||
color: `#${String(i).padStart(2, '0')}0000`,
|
||
data: [
|
||
{ timestamp: now - 30_000, value },
|
||
{ timestamp: now - 10_000, value },
|
||
],
|
||
});
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
series={[1, 2, 3, 4, 5, 6, 7, 8].map((i) => makeSeries(i, i * 10))}
|
||
maxTooltipRows={6}
|
||
/>
|
||
));
|
||
|
||
const svg = container.querySelector('svg');
|
||
expect(svg).not.toBeNull();
|
||
if (!svg) return;
|
||
|
||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
({
|
||
left: 0,
|
||
top: 100,
|
||
width: 200,
|
||
height: 50,
|
||
right: 200,
|
||
bottom: 150,
|
||
x: 0,
|
||
y: 100,
|
||
toJSON: () => ({}),
|
||
}) as unknown as DOMRect;
|
||
|
||
fireEvent.mouseMove(svg, { clientX: 199, clientY: 110 });
|
||
|
||
expect(await screen.findByText('S1')).toBeInTheDocument();
|
||
expect(screen.getByText('S6')).toBeInTheDocument();
|
||
expect(screen.queryByText('S7')).toBeNull();
|
||
expect(screen.getByText('+2 more series')).toBeInTheDocument();
|
||
});
|
||
|
||
it('clamps tooltip position so it stays in the viewport', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 40 },
|
||
{ timestamp: now - 10_000, value: 50 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
const svg = container.querySelector('svg');
|
||
expect(svg).not.toBeNull();
|
||
if (!svg) return;
|
||
|
||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
({
|
||
left: 0,
|
||
top: 10,
|
||
width: 200,
|
||
height: 50,
|
||
right: 200,
|
||
bottom: 60,
|
||
x: 0,
|
||
y: 10,
|
||
toJSON: () => ({}),
|
||
}) as unknown as DOMRect;
|
||
|
||
fireEvent.mouseMove(svg, { clientX: 1, clientY: 11 });
|
||
|
||
const cpuLabel = await screen.findByText('CPU');
|
||
const tooltip = cpuLabel.closest('div[style]') as HTMLElement | null;
|
||
expect(tooltip).not.toBeNull();
|
||
if (!tooltip) return;
|
||
|
||
const left = Number.parseFloat(tooltip.style.left);
|
||
const top = Number.parseFloat(tooltip.style.top);
|
||
expect(left).toBeGreaterThanOrEqual(100);
|
||
expect(top).toBeGreaterThan(40);
|
||
});
|
||
|
||
it('locks a hovered series on click and unlocks on second click', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
highlightNearestSeriesOnHover
|
||
series={[
|
||
{
|
||
name: 'High',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 90 },
|
||
{ timestamp: now - 10_000, value: 90 },
|
||
],
|
||
},
|
||
{
|
||
name: 'Low',
|
||
color: '#00ff00',
|
||
data: [
|
||
{ timestamp: now - 30_000, value: 10 },
|
||
{ timestamp: now - 10_000, value: 10 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
const svg = container.querySelector('svg');
|
||
expect(svg).not.toBeNull();
|
||
if (!svg) return;
|
||
|
||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||
({
|
||
left: 0,
|
||
top: 100,
|
||
width: 200,
|
||
height: 100,
|
||
right: 200,
|
||
bottom: 200,
|
||
x: 0,
|
||
y: 100,
|
||
toJSON: () => ({}),
|
||
}) as unknown as DOMRect;
|
||
|
||
// Hover near the high series and lock it.
|
||
fireEvent.mouseMove(svg, { clientX: 150, clientY: 110 });
|
||
expect(await screen.findByText('High')).toBeInTheDocument();
|
||
expect(screen.queryByText('Low')).toBeNull();
|
||
fireEvent.click(svg);
|
||
|
||
// Scrub near the low series: lock should keep High selected.
|
||
fireEvent.mouseMove(svg, { clientX: 150, clientY: 190 });
|
||
expect(await screen.findByText('High')).toBeInTheDocument();
|
||
expect(screen.queryByText('Low')).toBeNull();
|
||
|
||
// Click again to unlock, then low should become selectable.
|
||
fireEvent.click(svg);
|
||
fireEvent.mouseMove(svg, { clientX: 150, clientY: 190 });
|
||
expect(await screen.findByText('Low')).toBeInTheDocument();
|
||
expect(screen.queryByText('High')).toBeNull();
|
||
});
|
||
|
||
it('breaks the rendered line when there is a large gap in data', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-02T00:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="24h"
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 23 * 60 * 60_000, value: 40 },
|
||
{ timestamp: now - 22 * 60 * 60_000, value: 42 },
|
||
{ timestamp: now - 5 * 60_000, value: 70 },
|
||
{ timestamp: now - 2 * 60_000, value: 75 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
await Promise.resolve();
|
||
|
||
const paths = container.querySelectorAll('path[vector-effect="non-scaling-stroke"]');
|
||
expect(paths.length).toBeGreaterThan(1);
|
||
});
|
||
|
||
it('keeps evenly spaced sparse data connected as a single line', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-02T00:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const data = Array.from({ length: 30 }, (_, index) => ({
|
||
timestamp: now - (60 - index * 2) * 60_000,
|
||
value: 50 + (index % 5),
|
||
}));
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data,
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
await Promise.resolve();
|
||
|
||
const paths = container.querySelectorAll('path[vector-effect="non-scaling-stroke"]');
|
||
expect(paths.length).toBe(1);
|
||
});
|
||
|
||
it('renders tiered-density data (sparse + medium + dense) as a single continuous line', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-08T00:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
// Simulate tiered mock data: sparse (~65min), medium (~2min), dense (~1min)
|
||
const data: { timestamp: number; value: number }[] = [];
|
||
// Sparse tier: 7d–24h ago, ~65min intervals (~133 points)
|
||
for (let i = 0; i < 133; i++) {
|
||
data.push({
|
||
timestamp: now - 7 * 24 * 60 * 60_000 + i * 65 * 60_000,
|
||
value: 30 + Math.sin(i * 0.1) * 10,
|
||
});
|
||
}
|
||
// Medium tier: 24h–2h ago, ~2min intervals (~660 points)
|
||
const mediumStart = now - 24 * 60 * 60_000;
|
||
for (let i = 0; i < 660; i++) {
|
||
data.push({
|
||
timestamp: mediumStart + i * 2 * 60_000,
|
||
value: 35 + Math.sin(i * 0.05) * 8,
|
||
});
|
||
}
|
||
// Dense tier: last 2h, ~1min intervals (~120 points)
|
||
const denseStart = now - 2 * 60 * 60_000;
|
||
for (let i = 0; i < 120; i++) {
|
||
data.push({
|
||
timestamp: denseStart + i * 60_000,
|
||
value: 40 + Math.sin(i * 0.2) * 5,
|
||
});
|
||
}
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="7d"
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data,
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
await Promise.resolve();
|
||
|
||
// After LTTB downsampling + P90 gap detection, tiered data should render
|
||
// as a single continuous segment, not fragmented by tier transitions.
|
||
const paths = container.querySelectorAll('path[vector-effect="non-scaling-stroke"]');
|
||
expect(paths.length).toBe(1);
|
||
});
|
||
|
||
it('can bridge the leading window-start gap when requested', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-02T00:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
bridgeLeadingGap
|
||
series={[
|
||
{
|
||
name: 'CPU',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 60 * 60_000, value: 55 },
|
||
{ timestamp: now - 5 * 60_000, value: 55 },
|
||
{ timestamp: now - 4 * 60_000, value: 60 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
await Promise.resolve();
|
||
|
||
const paths = container.querySelectorAll('path[vector-effect="non-scaling-stroke"]');
|
||
expect(paths.length).toBe(1);
|
||
});
|
||
|
||
it('renders only the active series when isolate mode is enabled', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2024-01-02T00:00:00Z'));
|
||
const now = Date.now();
|
||
|
||
const { container } = render(() => (
|
||
<InteractiveSparkline
|
||
timeRange="1h"
|
||
activeSeriesDisplay="isolate"
|
||
highlightSeriesId="beta"
|
||
series={[
|
||
{
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
color: '#ff0000',
|
||
data: [
|
||
{ timestamp: now - 30 * 60_000, value: 30 },
|
||
{ timestamp: now - 5 * 60_000, value: 45 },
|
||
],
|
||
},
|
||
{
|
||
id: 'beta',
|
||
name: 'Beta',
|
||
color: '#00ff00',
|
||
data: [
|
||
{ timestamp: now - 30 * 60_000, value: 70 },
|
||
{ timestamp: now - 5 * 60_000, value: 80 },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
));
|
||
|
||
await Promise.resolve();
|
||
|
||
const root = container.firstElementChild as HTMLElement | null;
|
||
expect(root).not.toBeNull();
|
||
expect(root?.getAttribute('data-active-series-display')).toBe('isolate');
|
||
expect(root?.getAttribute('data-rendered-series-count')).toBe('1');
|
||
|
||
const paths = container.querySelectorAll('path[vector-effect="non-scaling-stroke"]');
|
||
expect(paths.length).toBe(1);
|
||
});
|
||
});
|