diff --git a/packages/app/src/components/Search/__tests__/DBSearchHeatmapChart.test.tsx b/packages/app/src/components/Search/__tests__/DBSearchHeatmapChart.test.tsx
new file mode 100644
index 0000000000..60223674c7
--- /dev/null
+++ b/packages/app/src/components/Search/__tests__/DBSearchHeatmapChart.test.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import {
+ BuilderChartConfigWithDateRange,
+ SourceKind,
+ TTraceSource,
+} from '@hyperdx/common-utils/dist/types';
+import { screen } from '@testing-library/react';
+
+import type DBDeltaChartType from '../../DBDeltaChart';
+import { DBSearchHeatmapChart } from '../DBSearchHeatmapChart';
+
+type FieldsState = {
+ value: string;
+ count: string;
+ scaleType: string;
+ xMin: number | null;
+ xMax: number | null;
+ yMin: number | null;
+ yMax: number | null;
+};
+
+function freshFieldsState(): FieldsState {
+ return {
+ value: 'Duration',
+ count: 'count()',
+ scaleType: 'log',
+ xMin: null,
+ xMax: null,
+ yMin: null,
+ yMax: null,
+ };
+}
+
+// Controls the values returned by useQueryStates each render.
+let mockFieldsState: FieldsState = freshFieldsState();
+
+jest.mock('nuqs', () => ({
+ parseAsFloat: { withDefault: jest.fn(() => 'parseAsFloat') },
+ parseAsString: { withDefault: jest.fn(() => 'parseAsString') },
+ useQueryStates: jest.fn(() => [mockFieldsState, jest.fn()]),
+}));
+
+jest.mock('@hyperdx/common-utils/dist/core/metadata', () => ({
+ tcFromSource: jest.fn(() => ({})),
+}));
+
+jest.mock('@/source', () => ({
+ getDurationMsExpression: jest.fn(() => 'Duration'),
+}));
+
+jest.mock('@/components/HeatmapSettingsDrawer', () =>
+ jest.fn(() =>
),
+);
+
+jest.mock('../../DBHeatmapChart', () => ({
+ __esModule: true,
+ default: jest.fn((props: { enabled?: boolean }) => (
+
+ )),
+ ColorLegend: jest.fn(() => color scale
),
+ darkPalette: ['#000'],
+ lightPalette: ['#fff'],
+ toHeatmapChartConfig: jest.fn(() => ({ heatmapConfig: {} })),
+}));
+
+// Capture props passed to DBDeltaChart so we can assert URL coords forward.
+type DBDeltaChartProps = React.ComponentProps;
+const deltaChartPropsLog: DBDeltaChartProps[] = [];
+jest.mock('../../DBDeltaChart', () => ({
+ __esModule: true,
+ default: jest.fn((props: DBDeltaChartProps) => {
+ deltaChartPropsLog.push(props);
+ return ;
+ }),
+}));
+
+// Re-import the mocked ColorLegend so we can assert that it's specifically
+// the heatmap color scale being passed as legendPrefix (not any truthy node).
+const { ColorLegend: MockedColorLegend } = jest.requireMock(
+ '../../DBHeatmapChart',
+);
+
+// Direct type annotations (no `as unknown as` casts) so adding a
+// required field to either schema fails this fixture at compile
+// time rather than silently passing.
+const baseSource: TTraceSource = {
+ id: 'trace-source',
+ kind: SourceKind.Trace,
+ name: 'Trace Source',
+ connection: 'conn',
+ from: { databaseName: 'otel', tableName: 'otel_traces' },
+ timestampValueExpression: 'Timestamp',
+ defaultTableSelectExpression: 'Timestamp',
+ durationExpression: 'Duration',
+ durationPrecision: 3,
+ traceIdExpression: 'TraceId',
+ spanIdExpression: 'SpanId',
+ parentSpanIdExpression: 'ParentSpanId',
+ spanNameExpression: 'SpanName',
+ spanKindExpression: 'SpanKind',
+};
+
+const baseChartConfig: BuilderChartConfigWithDateRange = {
+ dateRange: [new Date(0), new Date(1000)],
+ from: { databaseName: 'otel', tableName: 'otel_traces' },
+ timestampValueExpression: 'Timestamp',
+ connection: 'conn',
+ select: '',
+ where: '',
+ whereLanguage: 'sql',
+};
+
+describe('DBSearchHeatmapChart', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ deltaChartPropsLog.length = 0;
+ mockFieldsState = freshFieldsState();
+ });
+
+ it('renders DBDeltaChart unconditionally when no heatmap selection exists', () => {
+ // Regression test for #1899: the attribute distribution chart must
+ // render before any heatmap selection. A conditional wrapper here
+ // gates the always-on distribution mode and ships the feature off.
+ renderWithMantine(
+ ,
+ );
+
+ expect(screen.getByTestId('db-delta-chart')).toBeInTheDocument();
+ expect(deltaChartPropsLog.length).toBeGreaterThan(0);
+ expect(deltaChartPropsLog[deltaChartPropsLog.length - 1]).toMatchObject({
+ xMin: null,
+ xMax: null,
+ yMin: null,
+ yMax: null,
+ valueExpr: 'Duration',
+ });
+ });
+
+ it('forwards URL selection coordinates to DBDeltaChart', () => {
+ mockFieldsState = {
+ ...mockFieldsState,
+ xMin: 10,
+ xMax: 20,
+ yMin: 30,
+ yMax: 40,
+ };
+
+ renderWithMantine(
+ ,
+ );
+
+ expect(screen.getByTestId('db-delta-chart')).toBeInTheDocument();
+ expect(deltaChartPropsLog[deltaChartPropsLog.length - 1]).toMatchObject({
+ xMin: 10,
+ xMax: 20,
+ yMin: 30,
+ yMax: 40,
+ });
+ });
+
+ it('passes a ColorLegend as legendPrefix to DBDeltaChart', () => {
+ // Regression test: the parent must keep passing a ColorLegend as
+ // legendPrefix so the heatmap color scale appears above the
+ // distribution charts. DBDeltaChart renders this prop in its
+ // legend Flex. Truthiness alone would not catch a regression that
+ // swaps ColorLegend for any other element; pin the element type.
+ renderWithMantine(
+ ,
+ );
+
+ const lastProps = deltaChartPropsLog[deltaChartPropsLog.length - 1];
+ expect(lastProps.legendPrefix).toBeTruthy();
+ expect(React.isValidElement(lastProps.legendPrefix)).toBe(true);
+ expect((lastProps.legendPrefix as React.ReactElement).type).toBe(
+ MockedColorLegend,
+ );
+ });
+});
diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.tsx b/packages/app/src/components/__tests__/DBDeltaChart.test.tsx
new file mode 100644
index 0000000000..328805fbe1
--- /dev/null
+++ b/packages/app/src/components/__tests__/DBDeltaChart.test.tsx
@@ -0,0 +1,213 @@
+import React from 'react';
+import {
+ BuilderChartConfigWithDateRange,
+ Filter,
+} from '@hyperdx/common-utils/dist/types';
+import { screen } from '@testing-library/react';
+
+import { useQueriedChartConfig } from '@/hooks/useChartConfig';
+
+import DBDeltaChart from '../DBDeltaChart';
+
+jest.mock('@/hooks/useChartConfig', () => ({
+ useQueriedChartConfig: jest.fn(),
+}));
+
+// PropertyComparisonChart is the per-attribute renderer. Stubbing it out keeps
+// the legend assertions tight without dragging uPlot/element-size hooks into
+// the test environment.
+jest.mock('../PropertyComparisonChart', () => ({
+ __esModule: true,
+ CHART_GAP: 8,
+ CHART_HEIGHT: 100,
+ CHART_WIDTH: 200,
+ PAGINATION_HEIGHT: 32,
+ PropertyComparisonChart: ({ name }: { name: string }) => (
+ {name}
+ ),
+}));
+
+const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
+
+// Sentinel filter on baseConfig so the allSpans assertion can prove
+// the branch passes user-supplied filters through unchanged. Without
+// it, `expect(filters).toBeUndefined()` passes for both "no override"
+// and "filters dropped on the floor", which are indistinguishable.
+const sentinelFilter: Filter = {
+ type: 'sql',
+ condition: 'tenant_id = 42',
+};
+
+const baseConfig: BuilderChartConfigWithDateRange = {
+ dateRange: [new Date(0), new Date(1000)],
+ from: { databaseName: 'otel', tableName: 'otel_traces' },
+ timestampValueExpression: 'Timestamp',
+ connection: 'conn',
+ select: '',
+ where: '',
+ whereLanguage: 'sql',
+ filters: [sentinelFilter],
+};
+
+function renderChart(
+ overrides: Partial> = {},
+) {
+ return renderWithMantine(
+ ,
+ );
+}
+
+describe('DBDeltaChart', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseQueriedChartConfig.mockReturnValue({
+ data: { data: [], meta: [] },
+ error: undefined,
+ isLoading: false,
+ });
+ });
+
+ describe('distribution mode (no heatmap selection)', () => {
+ it('renders the "All spans" legend entry and the help hint', () => {
+ renderChart();
+
+ expect(screen.getByText('All spans')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Select an area on the chart above to enable comparisons',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('does not render the Selection/Background legend entries', () => {
+ renderChart();
+
+ expect(screen.queryByText('Selection')).not.toBeInTheDocument();
+ expect(screen.queryByText('Background')).not.toBeInTheDocument();
+ });
+
+ it('enables the allSpans query and disables the outlier/inlier queries', () => {
+ renderChart();
+
+ // The component fires three useQueriedChartConfig calls in order:
+ // outlier, inlier, allSpans.
+ const calls = mockUseQueriedChartConfig.mock.calls;
+ expect(calls).toHaveLength(3);
+
+ const [outlierCall, inlierCall, allSpansCall] = calls;
+ expect(outlierCall[1]).toMatchObject({ enabled: false });
+ expect(inlierCall[1]).toMatchObject({ enabled: false });
+ expect(allSpansCall[1]).toMatchObject({ enabled: true });
+ });
+
+ it('passes config.filters through to allSpans without selection filters', () => {
+ // Regression guard for two related failure modes:
+ // 1. allSpans reuses buildFilters() (keyed on xMin/xMax/yMin/yMax).
+ // With no selection those default to 0 and produce dead SQL
+ // mirroring the inlier branch. Length would grow past 1.
+ // 2. allSpans explicitly sets `filters: undefined` and drops the
+ // user-supplied filters on the floor. Length would drop to 0.
+ // Both are caught by asserting allSpans receives exactly the
+ // sentinel filter array from config, no more and no fewer.
+ renderChart();
+
+ const calls = mockUseQueriedChartConfig.mock.calls;
+ expect(calls).toHaveLength(3);
+ const allSpansConfig = calls[2][0];
+ expect(allSpansConfig.filters).toEqual([sentinelFilter]);
+
+ // Sanity: the outlier branch DOES inject extra filters built from
+ // the (defaulted-to-zero) coords, so its filters array is strictly
+ // longer than baseConfig.filters.
+ const outlierConfig = calls[0][0];
+ expect(outlierConfig.filters.length).toBeGreaterThan(1);
+ expect(outlierConfig.filters[0]).toEqual(sentinelFilter);
+ });
+ });
+
+ describe('partial-null selection coordinates', () => {
+ // The four-null conditional that originally gated
+ // used `&&` across xMin/xMax/yMin/yMax. A regression weakening the
+ // conjunction to `||` would let selection mode activate with any
+ // single coord set, which is wrong. These cases lock the conjunction
+ // in: any null coord must keep distribution mode active.
+ it.each([
+ ['only xMin set', { xMin: 1 }],
+ ['only xMax set', { xMax: 2 }],
+ ['only yMin set', { yMin: 1 }],
+ ['only yMax set', { yMax: 2 }],
+ ['three set, yMax null', { xMin: 1, xMax: 2, yMin: 1 }],
+ ['three set, xMin null', { xMax: 2, yMin: 1, yMax: 2 }],
+ ])('stays in distribution mode when %s', (_label, coords) => {
+ renderChart(coords);
+
+ expect(screen.getByText('All spans')).toBeInTheDocument();
+ expect(screen.queryByText('Selection')).not.toBeInTheDocument();
+
+ const calls = mockUseQueriedChartConfig.mock.calls;
+ expect(calls[2][1]).toMatchObject({ enabled: true });
+ expect(calls[0][1]).toMatchObject({ enabled: false });
+ expect(calls[1][1]).toMatchObject({ enabled: false });
+ });
+ });
+
+ describe('selection mode (all four coordinates set)', () => {
+ it('renders the Selection and Background legend entries', () => {
+ renderChart({ xMin: 1, xMax: 2, yMin: 1, yMax: 2 });
+
+ expect(screen.getByText('Selection')).toBeInTheDocument();
+ expect(screen.getByText('Background')).toBeInTheDocument();
+ });
+
+ it('does not render the "All spans" legend entry', () => {
+ renderChart({ xMin: 1, xMax: 2, yMin: 1, yMax: 2 });
+
+ expect(screen.queryByText('All spans')).not.toBeInTheDocument();
+ });
+
+ it('enables the outlier and inlier queries, disables allSpans', () => {
+ renderChart({ xMin: 1, xMax: 2, yMin: 1, yMax: 2 });
+
+ const calls = mockUseQueriedChartConfig.mock.calls;
+ const [outlierCall, inlierCall, allSpansCall] = calls;
+ expect(outlierCall[1]).toMatchObject({ enabled: true });
+ expect(inlierCall[1]).toMatchObject({ enabled: true });
+ expect(allSpansCall[1]).toMatchObject({ enabled: false });
+ });
+ });
+
+ describe('legendPrefix prop', () => {
+ // The divider is a vertical separator rendered immediately after the
+ // legendPrefix to visually divide it from the comparison legend. It's
+ // a `` with a `borderLeft` style. We assert its presence
+ // via the unique border style so the test is decoupled from Mantine's
+ // class hashing.
+ const dividerStyle = '1px solid var(--mantine-color-default-border)';
+
+ function queryDivider(container: HTMLElement) {
+ return Array.from(container.querySelectorAll('div')).find(
+ el => el.style.borderLeft === dividerStyle,
+ );
+ }
+
+ it('renders inside the legend flex when provided', () => {
+ const { container } = renderChart({
+ legendPrefix: color scale
,
+ });
+
+ expect(screen.getByTestId('prefix')).toBeInTheDocument();
+ expect(queryDivider(container)).toBeDefined();
+ });
+
+ it('omits the prefix and divider when no legendPrefix is provided', () => {
+ const { container } = renderChart();
+ expect(screen.queryByTestId('prefix')).not.toBeInTheDocument();
+ expect(queryDivider(container)).toBeUndefined();
+ });
+ });
+});