This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e0483898728 test(explore): cover time range frames, comparison labels, 
and metric popover state (#44847)
e0483898728 is described below

commit e0483898728a79e5cd517d9048676431163eafe6
Author: Joe Li <[email protected]>
AuthorDate: Fri Oct 2 09:10:45 2026 -0700

    test(explore): cover time range frames, comparison labels, and metric 
popover state (#44847)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../controls/ComparisonRangeLabel.test.tsx         | 252 +++++++++++++++++++
 .../DateFilterControl/tests/CommonFrame.test.tsx   |  72 ++++++
 .../tests/DateFilterLabel.test.tsx                 | 193 ++++++++++++++
 .../DateFilterControl/tests/DateLabel.test.tsx     |  73 ++++++
 .../AdhocMetricPopoverTrigger.test.tsx             | 278 +++++++++++++++++++++
 5 files changed, 868 insertions(+)

diff --git 
a/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
 
b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
new file mode 100644
index 00000000000..853f370501f
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
@@ -0,0 +1,252 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
+import { fetchTimeRange } from '@superset-ui/core';
+import { ComparisonRangeLabel } from './ComparisonRangeLabel';
+
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  fetchTimeRange: jest.fn(),
+}));
+
+const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
+  typeof fetchTimeRange
+>;
+
+const HEADER = 'Actual range for comparison';
+const CURRENT_RANGE_LABEL = '2024-03-01 ≤ col < 2024-03-31';
+const COMPARISON_LABEL = '2023-03-01 ≤ col < 2023-03-31';
+
+const temporalRangeFilter = {
+  expressionType: 'SIMPLE',
+  subject: 'order_date',
+  operator: 'TEMPORAL_RANGE',
+  comparator: '2024-03-01 : 2024-03-31',
+  clause: 'WHERE',
+};
+
+const nonTemporalFilter = {
+  expressionType: 'SIMPLE',
+  subject: 'country',
+  operator: '==',
+  comparator: 'US',
+  clause: 'WHERE',
+};
+
+function renderLabel(formData: Record<string, unknown>) {
+  return render(<ComparisonRangeLabel name="time_compare" />, {
+    useRedux: true,
+    initialState: {
+      explore: {
+        form_data: { adhoc_filters: [temporalRangeFilter], ...formData },
+      },
+    },
+  });
+}
+
+// Lets the component's promise chain settle so "nothing rendered" assertions
+// are made after the async label computation, not before it starts.
+const settle = () =>
+  act(async () => {
+    await new Promise(resolve => {
+      setTimeout(resolve, 0);
+    });
+  });
+
+beforeEach(() => {
+  mockedFetchTimeRange.mockReset();
+  // The first call resolves the current range, calls with shifts resolve the
+  // comparison range.
+  mockedFetchTimeRange.mockImplementation(async (_range, _col, shifts) => ({
+    value: shifts?.length ? COMPARISON_LABEL : CURRENT_RANGE_LABEL,
+  }));
+});
+
+test('renders nothing when there is no TEMPORAL_RANGE filter', async () => {
+  const { container } = renderLabel({
+    adhoc_filters: [nonTemporalFilter],
+    time_compare: ['1 year ago'],
+  });
+
+  await settle();
+  expect(container).toBeEmptyDOMElement();
+  expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('renders nothing when a TEMPORAL_RANGE filter has no comparison 
configured', async () => {
+  const { container } = renderLabel({});
+
+  await settle();
+  expect(container).toBeEmptyDOMElement();
+  expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('renders the comparison range for an explicit time_compare shift', async 
() => {
+  renderLabel({ time_compare: ['1 month ago'] });
+
+  expect(await screen.findByText(HEADER)).toBeInTheDocument();
+  expect(screen.getByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['1 month ago'],
+  );
+});
+
+test.each([
+  ['y', '1 year ago'],
+  ['m', '1 month ago'],
+  ['w', '1 week ago'],
+])(
+  'maps the legacy time_comparison shorthand "%s" to "%s"',
+  async (shorthand, expectedShift) => {
+    renderLabel({ time_comparison: shorthand });
+
+    expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+    expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+      temporalRangeFilter.comparator,
+      temporalRangeFilter.subject,
+      [expectedShift],
+    );
+  },
+);
+
+test('ignores an unknown legacy time_comparison shorthand', async () => {
+  const { container } = renderLabel({ time_comparison: 'zzz' });
+
+  await settle();
+  expect(container).toBeEmptyDOMElement();
+  expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('prefers time_compare over the legacy time_comparison shorthand', async 
() => {
+  renderLabel({ time_compare: ['1 week ago'], time_comparison: 'y' });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['1 week ago'],
+  );
+  expect(mockedFetchTimeRange).not.toHaveBeenCalledWith(
+    expect.anything(),
+    expect.anything(),
+    ['1 year ago'],
+  );
+});
+
+test('custom shift resolves the offset from start_date_offset to the current 
range start', async () => {
+  renderLabel({
+    time_compare: ['custom'],
+    start_date_offset: '2024-01-01',
+  });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  // First the current range is resolved without shifts, then the comparison
+  // is requested with the day offset derived from start_date_offset.
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    1,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+  );
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    2,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['60 days ago'],
+  );
+});
+
+test('maps the legacy time_comparison shorthand "c" to a custom shift resolved 
from start_date_offset', async () => {
+  renderLabel({ time_comparison: 'c', start_date_offset: '2024-01-01' });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    1,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+  );
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    2,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['60 days ago'],
+  );
+});
+
+test('custom shift without start_date_offset never requests a comparison 
range', async () => {
+  renderLabel({ time_compare: ['custom'] });
+
+  await settle();
+  expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+  expect(screen.queryByText(COMPARISON_LABEL)).not.toBeInTheDocument();
+});
+
+test('custom shift does not request a comparison when start_date_offset is 
after the current range start', async () => {
+  renderLabel({
+    time_compare: ['custom'],
+    start_date_offset: '2024-06-01',
+  });
+
+  await waitFor(() => expect(mockedFetchTimeRange).toHaveBeenCalledTimes(1));
+  await settle();
+  expect(mockedFetchTimeRange).toHaveBeenCalledTimes(1);
+  expect(screen.queryByText(COMPARISON_LABEL)).not.toBeInTheDocument();
+});
+
+test('inherit shift shifts back by the length of the current range', async () 
=> {
+  renderLabel({ time_compare: ['inherit'] });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    1,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+  );
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    2,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['30 days ago'],
+  );
+});
+
+test('legacy "r" shorthand behaves like an inherit shift', async () => {
+  renderLabel({ time_comparison: 'r' });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    2,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['30 days ago'],
+  );
+});
+
+test('inherit is combined with regular shifts in one comparison request', 
async () => {
+  renderLabel({ time_compare: ['inherit', '1 year ago'] });
+
+  expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+  expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+    2,
+    temporalRangeFilter.comparator,
+    temporalRangeFilter.subject,
+    ['30 days ago', '1 year ago'],
+  );
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
new file mode 100644
index 00000000000..990b023dc46
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
@@ -0,0 +1,72 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { DateFilterTestKey } from '../utils';
+import { CommonFrame } from '../components';
+
+test('renders the title and one radio per common range', () => {
+  render(<CommonFrame onChange={jest.fn()} value="Last month" />);
+
+  expect(screen.getByTestId(DateFilterTestKey.CommonFrame)).toHaveTextContent(
+    'Configure Time Range: Last...',
+  );
+  const radios = screen.getAllByRole('radio');
+  const expectedNames = [
+    'Last day',
+    'Last week',
+    'Last month',
+    'Last quarter',
+    'Last year',
+  ];
+  expect(radios).toHaveLength(expectedNames.length);
+  expectedNames.forEach((name, index) => {
+    expect(radios[index]).toHaveAccessibleName(name);
+  });
+});
+
+test('checks the radio matching the value', () => {
+  render(<CommonFrame onChange={jest.fn()} value="Last quarter" />);
+
+  expect(screen.getByLabelText('Last quarter')).toBeChecked();
+  expect(screen.getByLabelText('Last week')).not.toBeChecked();
+});
+
+test('does not call onChange when the value is a common range', () => {
+  const onChange = jest.fn();
+  render(<CommonFrame onChange={onChange} value="Last year" />);
+
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+test('falls back to Last week when the value is not a common range', () => {
+  const onChange = jest.fn();
+  render(<CommonFrame onChange={onChange} value="previous calendar week" />);
+
+  expect(onChange).toHaveBeenCalledWith('Last week');
+  expect(screen.getByLabelText('Last week')).toBeChecked();
+});
+
+test('calls onChange with the selected range', async () => {
+  const onChange = jest.fn();
+  render(<CommonFrame onChange={onChange} value="Last week" />);
+
+  await userEvent.click(screen.getByLabelText('Last day'));
+
+  expect(onChange).toHaveBeenCalledWith('Last day');
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
index 8e28ce658b2..0aa914d2038 100644
--- 
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
@@ -27,6 +27,8 @@ import {
   userEvent,
   waitFor,
   fireEvent,
+  selectOption,
+  within,
 } from 'spec/helpers/testing-library';
 
 import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
@@ -77,6 +79,10 @@ beforeEach(() => {
   mockPopoverProps.length = 0;
 });
 
+afterEach(() => {
+  jest.useRealTimers();
+});
+
 function setup(
   props: Omit<DateFilterControlProps, 'name'> = defaultProps,
   store: any = mockStore({}),
@@ -368,3 +374,190 @@ test('hovering the description icon does not show the 
date range tooltip', async
   fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
   expect(tooltipOnClick).toHaveBeenCalled();
 });
+
+const RANGE_TYPE_LABEL = 'Range type';
+const INVALID_EXPRESSION_ERROR = 'Invalid time range expression';
+
+const FRAME_MARKERS = {
+  Common: () => screen.queryByTestId(DateFilterTestKey.CommonFrame),
+  Calendar: () => screen.queryByText('Configure Time Range: Previous...'),
+  Current: () => screen.queryByText('Configure Time Range: Current...'),
+  Custom: () => screen.queryByText('Configure custom time range'),
+  Advanced: () => screen.queryByText('Configure Advanced Time Range'),
+  'No filter': () => screen.queryByTestId(DateFilterTestKey.NoFilter),
+};
+
+type FrameName = keyof typeof FRAME_MARKERS;
+
+async function expectOnlyFrame(frame: FrameName) {
+  await waitFor(() => {
+    expect(FRAME_MARKERS[frame]()).toBeInTheDocument();
+  });
+  (Object.keys(FRAME_MARKERS) as FrameName[])
+    .filter(other => other !== frame)
+    .forEach(other => {
+      expect(FRAME_MARKERS[other]()).not.toBeInTheDocument();
+    });
+}
+
+test.each<[string, FrameName, string]>([
+  ['No filter', 'No filter', 'No filter'],
+  ['Last week', 'Common', 'Last'],
+  ['previous calendar month', 'Calendar', 'Previous'],
+  ['Current quarter', 'Current', 'Current'],
+  ['2021-03-16T00:00:00 : 2021-03-17T00:00:00', 'Custom', 'Custom'],
+  ['Last week : tomorrow', 'Advanced', 'Advanced'],
+])(
+  'opens on the frame that matches the value "%s"',
+  async (value, expectedFrame, expectedSelectLabel) => {
+    render(setup({ ...defaultProps, value }));
+
+    await userEvent.click(await screen.findByRole('button'));
+
+    await expectOnlyFrame(expectedFrame);
+    expect(screen.getByTitle(expectedSelectLabel)).toBeInTheDocument();
+  },
+);
+
+test.each<[string, FrameName]>([
+  ['Previous', 'Calendar'],
+  ['Current', 'Current'],
+  ['Custom', 'Custom'],
+  ['Advanced', 'Advanced'],
+  ['No filter', 'No filter'],
+])(
+  'range type select switches from the Last frame to the %s frame',
+  async (optionLabel, expectedFrame) => {
+    render(setup({ ...defaultProps, value: 'Last week' }));
+    await userEvent.click(screen.getByText('Last week'));
+    await expectOnlyFrame('Common');
+
+    await selectOption(optionLabel, RANGE_TYPE_LABEL);
+
+    await expectOnlyFrame(expectedFrame);
+  },
+);
+
+test('range type select switches back to the Last frame from another frame', 
async () => {
+  render(setup({ ...defaultProps, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await selectOption('Advanced', RANGE_TYPE_LABEL);
+  await expectOnlyFrame('Advanced');
+
+  await selectOption('Last', RANGE_TYPE_LABEL);
+
+  await expectOnlyFrame('Common');
+});
+
+test('selecting the No filter frame previews No filter as the actual time 
range', async () => {
+  render(setup({ ...defaultProps, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+
+  await selectOption('No filter', RANGE_TYPE_LABEL);
+
+  await expectOnlyFrame('No filter');
+  await waitFor(
+    () => {
+      expect(
+        within(screen.getByText('Actual time range').parentElement!).getByText(
+          'No filter',
+        ),
+      ).toBeInTheDocument();
+    },
+    { timeout: 3000 },
+  );
+});
+
+test('Apply after selecting the No filter frame saves the No filter range', 
async () => {
+  const onChange = jest.fn();
+  render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await selectOption('No filter', RANGE_TYPE_LABEL);
+
+  await userEvent.click(screen.getByTestId(DateFilterTestKey.ApplyButton));
+
+  expect(onChange).toHaveBeenCalledWith(NO_TIME_RANGE);
+});
+
+test('an invalid Advanced expression shows the error and disables Apply', 
async () => {
+  mockedFetchTimeRange.mockImplementation(async value =>
+    value.includes('bogus')
+      ? { error: INVALID_EXPRESSION_ERROR }
+      : { value: FIELD_TOOLTIP },
+  );
+  const onChange = jest.fn();
+  render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await selectOption('Advanced', RANGE_TYPE_LABEL);
+  await waitFor(() => {
+    expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeEnabled();
+  });
+
+  await userEvent.type(screen.getAllByRole('textbox')[0], 'bogus');
+
+  expect(
+    await screen.findByText(INVALID_EXPRESSION_ERROR, {}, { timeout: 3000 }),
+  ).toBeInTheDocument();
+  expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeDisabled();
+  await userEvent.click(screen.getByTestId(DateFilterTestKey.ApplyButton));
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+test('correcting an invalid Advanced expression clears the error and 
re-enables Apply', async () => {
+  mockedFetchTimeRange.mockImplementation(async value =>
+    value.includes('bogus')
+      ? { error: INVALID_EXPRESSION_ERROR }
+      : { value: FIELD_TOOLTIP },
+  );
+  render(setup({ ...defaultProps, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await selectOption('Advanced', RANGE_TYPE_LABEL);
+  const sinceInput = screen.getAllByRole('textbox')[0];
+  await userEvent.type(sinceInput, 'bogus');
+  await screen.findByText(INVALID_EXPRESSION_ERROR, {}, { timeout: 3000 });
+
+  await userEvent.clear(sinceInput);
+  await userEvent.type(sinceInput, 'today');
+
+  await waitFor(
+    () => {
+      expect(
+        screen.queryByText(INVALID_EXPRESSION_ERROR),
+      ).not.toBeInTheDocument();
+      expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeEnabled();
+    },
+    { timeout: 3000 },
+  );
+});
+
+test('Cancel restores the frame guessed from the saved value', async () => {
+  const onChange = jest.fn();
+  render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await selectOption('Advanced', RANGE_TYPE_LABEL);
+  await expectOnlyFrame('Advanced');
+
+  await userEvent.click(screen.getByTestId(DateFilterTestKey.CancelButton));
+  await waitFor(() => {
+    expect(screen.queryByText('Edit time range')).not.toBeInTheDocument();
+  });
+  await userEvent.click(screen.getByText('Last week'));
+
+  await expectOnlyFrame('Common');
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+test('Cancel discards an unsaved range selection', async () => {
+  render(setup({ ...defaultProps, value: 'Last week' }));
+  await userEvent.click(screen.getByText('Last week'));
+  await userEvent.click(screen.getByLabelText('Last month'));
+
+  await userEvent.click(screen.getByTestId(DateFilterTestKey.CancelButton));
+  await waitFor(() => {
+    expect(screen.queryByText('Edit time range')).not.toBeInTheDocument();
+  });
+  await userEvent.click(screen.getByText('Last week'));
+
+  expect(await screen.findByLabelText('Last week')).toBeChecked();
+  expect(screen.getByLabelText('Last month')).not.toBeChecked();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
new file mode 100644
index 00000000000..e4f6846b962
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createRef } from 'react';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { DateLabel } from '../components';
+
+test('renders a string label inside a button', () => {
+  render(<DateLabel name="time_range" label="Last week" />);
+
+  expect(screen.getByRole('button')).toHaveTextContent('Last week');
+});
+
+test('renders a node label as provided', () => {
+  render(
+    <DateLabel name="time_range" label={<em data-test="custom">Custom</em>} />,
+  );
+
+  expect(screen.getByTestId('custom')).toHaveTextContent('Custom');
+});
+
+test('identifies the label content by the control name', () => {
+  render(<DateLabel name="time_range" label="Last week" />);
+
+  expect(screen.getByText('Last week')).toHaveAttribute(
+    'id',
+    'date-label-time_range',
+  );
+});
+
+test('forwards the ref to the label content element', () => {
+  const ref = createRef<HTMLSpanElement>();
+  render(<DateLabel name="time_range" label="Last week" ref={ref} />);
+
+  expect(ref.current).toBe(screen.getByText('Last week'));
+});
+
+test('calls onClick when clicked', async () => {
+  const onClick = jest.fn();
+  render(<DateLabel name="time_range" label="Last week" onClick={onClick} />);
+
+  await userEvent.click(screen.getByRole('button'));
+
+  expect(onClick).toHaveBeenCalledTimes(1);
+});
+
+test('does not submit a surrounding form', async () => {
+  const onSubmit = jest.fn(e => e.preventDefault());
+  render(
+    <form onSubmit={onSubmit}>
+      <DateLabel name="time_range" label="Last week" />
+    </form>,
+  );
+
+  await userEvent.click(screen.getByRole('button'));
+
+  expect(onSubmit).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
 
b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
new file mode 100644
index 00000000000..d8ba48b6adf
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
@@ -0,0 +1,278 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  fireEvent,
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { AGGREGATES } from 'src/explore/constants';
+import AdhocMetric from './AdhocMetric';
+import AdhocMetricPopoverTrigger, {
+  AdhocMetricPopoverTriggerProps,
+} from './AdhocMetricPopoverTrigger';
+
+const columns = [
+  { type: 'VARCHAR(255)', column_name: 'source' },
+  { type: 'DOUBLE', column_name: 'value' },
+];
+
+const savedMetricsOptions = [
+  { id: 1, metric_name: 'count', expression: 'COUNT(*)' },
+  { id: 2, metric_name: 'sum_value', expression: 'SUM(value)' },
+];
+
+const createAdhocMetric = (column = columns[1]) =>
+  new AdhocMetric({ column, aggregate: AGGREGATES.SUM });
+
+const TRIGGER_TEXT = 'open metric popover';
+
+const createProps = (
+  overrides: Partial<AdhocMetricPopoverTriggerProps> = {},
+): AdhocMetricPopoverTriggerProps => ({
+  adhocMetric: createAdhocMetric(),
+  onMetricEdit: jest.fn(),
+  columns,
+  savedMetricsOptions,
+  savedMetric: {},
+  datasource: {
+    type: 'table',
+    id: 1,
+    uid: '1__table',
+    columnFormats: {},
+    verboseMap: {},
+  } as unknown as AdhocMetricPopoverTriggerProps['datasource'],
+  children: <button type="button">{TRIGGER_TEXT}</button>,
+  ...overrides,
+});
+
+const renderOptions = {
+  useRedux: true,
+  initialState: { explore: {} },
+};
+
+const renderTrigger = (
+  overrides: Partial<AdhocMetricPopoverTriggerProps> = {},
+) => {
+  const props = createProps(overrides);
+  const utils = render(<AdhocMetricPopoverTrigger {...props} />, 
renderOptions);
+  return {
+    ...utils,
+    props,
+    rerenderWith: (next: Partial<AdhocMetricPopoverTriggerProps>) =>
+      utils.rerender(<AdhocMetricPopoverTrigger {...props} {...next} />),
+  };
+};
+
+const popover = () => screen.queryByTestId('metrics-edit-popover');
+const titleTrigger = () => 
screen.queryByTestId('AdhocMetricEditTitle#trigger');
+const readOnlyTitle = () => screen.queryByTestId('AdhocMetricTitle');
+
+async function renameMetricTo(label: string) {
+  await userEvent.click(screen.getByTestId('AdhocMetricEditTitle#trigger'));
+  const input = await screen.findByTestId('AdhocMetricEditTitle#input');
+  await userEvent.clear(input);
+  await userEvent.type(input, label);
+  fireEvent.keyPress(input, { key: 'Enter', charCode: 13 });
+}
+
+test('uncontrolled: clicking the child opens the popover and Close dismisses 
it', async () => {
+  renderTrigger();
+  expect(popover()).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  expect(await 
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+  await waitFor(() => expect(popover()).not.toBeInTheDocument());
+});
+
+test('controlled: visible=true shows the popover without any interaction', 
async () => {
+  renderTrigger({ isControlledComponent: true, visible: true });
+
+  expect(await 
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+});
+
+test('controlled: visible=false keeps the popover closed', () => {
+  renderTrigger({ isControlledComponent: true, visible: false });
+
+  expect(popover()).not.toBeInTheDocument();
+});
+
+test('controlled: clicking the child reports the request through 
togglePopover', async () => {
+  const togglePopover = jest.fn();
+  renderTrigger({
+    isControlledComponent: true,
+    visible: false,
+    togglePopover,
+  });
+
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+  expect(togglePopover).toHaveBeenCalledWith(true);
+});
+
+test('controlled: Close calls the closePopover prop', async () => {
+  const closePopover = jest.fn();
+  renderTrigger({
+    isControlledComponent: true,
+    visible: true,
+    togglePopover: jest.fn(),
+    closePopover,
+  });
+
+  await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+  expect(closePopover).toHaveBeenCalledTimes(1);
+});
+
+test('controlled: the visible prop drives the popover across rerenders', async 
() => {
+  const { rerenderWith } = renderTrigger({
+    isControlledComponent: true,
+    visible: false,
+    togglePopover: jest.fn(),
+  });
+  expect(popover()).not.toBeInTheDocument();
+
+  rerenderWith({ visible: true });
+  expect(await 
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+
+  rerenderWith({ visible: false });
+  await waitFor(() => expect(popover()).not.toBeInTheDocument());
+});
+
+test('title editing is disabled while the Saved tab is active', async () => {
+  renderTrigger({
+    adhocMetric: new AdhocMetric({}),
+    savedMetric: savedMetricsOptions[0],
+  });
+
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+  expect(await screen.findByTestId('AdhocMetricTitle')).toHaveTextContent(
+    'count',
+  );
+  expect(titleTrigger()).not.toBeInTheDocument();
+});
+
+test('title editing is enabled on the Simple tab', async () => {
+  renderTrigger();
+
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+  expect(
+    await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+  ).toBeInTheDocument();
+  expect(readOnlyTitle()).not.toBeInTheDocument();
+});
+
+test('title editing follows the active tab when switching between Saved and 
Simple', async () => {
+  renderTrigger({
+    adhocMetric: new AdhocMetric({}),
+    savedMetric: savedMetricsOptions[0],
+  });
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  expect(await screen.findByTestId('AdhocMetricTitle')).toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole('tab', { name: 'Simple' }));
+  expect(
+    await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+  ).toBeInTheDocument();
+  expect(readOnlyTitle()).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole('tab', { name: 'Saved' }));
+  expect(await screen.findByTestId('AdhocMetricTitle')).toBeInTheDocument();
+  expect(titleTrigger()).not.toBeInTheDocument();
+});
+
+test('a custom title survives a rerender with the same optionName', async () 
=> {
+  const { props, rerenderWith } = renderTrigger();
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  await renameMetricTo('my custom label');
+  expect(
+    await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+  ).toHaveTextContent('my custom label');
+
+  rerenderWith({
+    adhocMetric: new AdhocMetric({
+      ...props.adhocMetric,
+      optionName: props.adhocMetric.optionName,
+    }),
+  });
+
+  expect(screen.getByTestId('AdhocMetricEditTitle#trigger')).toHaveTextContent(
+    'my custom label',
+  );
+});
+
+test('an external optionName change resets the edited title to the new metric 
label', async () => {
+  const { rerenderWith } = renderTrigger();
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  await renameMetricTo('my custom label');
+  expect(
+    await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+  ).toHaveTextContent('my custom label');
+
+  rerenderWith({ adhocMetric: createAdhocMetric(columns[0]) });
+
+  await waitFor(() => {
+    expect(
+      screen.getByTestId('AdhocMetricEditTitle#trigger'),
+    ).toHaveTextContent('SUM(source)');
+  });
+  expect(screen.queryByText('my custom label')).not.toBeInTheDocument();
+});
+
+test('an external optionName change adopts the custom label of the new 
metric', async () => {
+  const { rerenderWith } = renderTrigger();
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  await renameMetricTo('my custom label');
+
+  rerenderWith({
+    adhocMetric: new AdhocMetric({
+      column: columns[0],
+      aggregate: AGGREGATES.MAX,
+      hasCustomLabel: true,
+      label: 'label from elsewhere',
+    }),
+  });
+
+  await waitFor(() => {
+    expect(
+      screen.getByTestId('AdhocMetricEditTitle#trigger'),
+    ).toHaveTextContent('label from elsewhere');
+  });
+});
+
+test('saving passes the edited title to onMetricEdit', async () => {
+  const { props } = renderTrigger();
+  await userEvent.click(screen.getByText(TRIGGER_TEXT));
+  await renameMetricTo('my custom label');
+
+  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+  expect(props.onMetricEdit).toHaveBeenCalledWith(
+    expect.objectContaining({
+      label: 'my custom label',
+      hasCustomLabel: true,
+    }),
+    props.adhocMetric,
+  );
+});

Reply via email to