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

sadpandajoe pushed a commit to branch explore-rtl-crud-controls
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6aa62bba103d125af8c6bc11403fa49c0f62e6bb
Author: Joe Li <[email protected]>
AuthorDate: Wed Sep 30 17:11:25 2026 -0700

    test(explore): cover column config, conditional formatting, and contour 
controls
    
    Add React Testing Library coverage for the CRUD-style controls:
    column config rows, popover edits and pruning, conditional formatting
    add/edit/remove flows, and the contour list, popover, and option.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../ColumnConfigControl.test.tsx                   | 293 +++++++++++++++++++++
 .../ControlForm/ControlForm.test.tsx               | 108 ++++++++
 .../ConditionalFormattingControl.test.tsx          | 216 ++++++++++++++-
 .../controls/ContourControl/ContourOption.test.tsx | 111 ++++++++
 .../ContourControl/ContourPopoverControl.test.tsx  | 277 +++++++++++++++++++
 .../ContourControl/ContourPopoverTrigger.test.tsx  | 133 ++++++++++
 .../controls/ContourControl/index.test.tsx         | 195 ++++++++++++++
 7 files changed, 1332 insertions(+), 1 deletion(-)

diff --git 
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
 
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
new file mode 100644
index 00000000000..e752c36d280
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
@@ -0,0 +1,293 @@
+/**
+ * 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,
+  waitFor,
+  within,
+} from 'spec/helpers/testing-library';
+import { GenericDataType } from '@apache-superset/core/common';
+import ColumnConfigControl from './ColumnConfigControl';
+
+type ControlProps = Parameters<typeof ColumnConfigControl>[0];
+
+const makeColumns = (count: number) => {
+  const colnames = Array.from({ length: count }, (_, i) => `col_${i + 1}`);
+  return {
+    colnames,
+    coltypes: colnames.map(() => GenericDataType.String),
+  };
+};
+
+const renderControl = (props: Partial<ControlProps> = {}) => {
+  const onChange = jest.fn();
+  const utils = render(
+    <ColumnConfigControl
+      name="column_config"
+      label="Customize columns"
+      columnsPropsObject={makeColumns(3)}
+      value={{}}
+      onChange={onChange}
+      {...props}
+    />,
+  );
+  return { onChange, ...utils };
+};
+
+// The checkbox control renders its label in the header next to the input
+// without associating them, so resolve the input through the header.
+const findCheckbox = async (label: string) => {
+  const header = (await screen.findByText(label)).closest(
+    '.ControlHeader',
+  ) as HTMLElement;
+  return within(header).getByRole('checkbox');
+};
+
+const openColumn = async (name: string) => {
+  await userEvent.click(screen.getByText(name));
+  return findCheckbox('Truncate Cells');
+};
+
+test('renders one row per column when no columns are applied', () => {
+  renderControl();
+  expect(screen.getByText('col_1')).toBeInTheDocument();
+  expect(screen.getByText('col_2')).toBeInTheDocument();
+  expect(screen.getByText('col_3')).toBeInTheDocument();
+});
+
+test('renders only the rows listed in appliedColumnNames', () => {
+  renderControl({ appliedColumnNames: ['col_1', 'col_3'] });
+  expect(screen.getByText('col_1')).toBeInTheDocument();
+  expect(screen.queryByText('col_2')).not.toBeInTheDocument();
+  expect(screen.getByText('col_3')).toBeInTheDocument();
+});
+
+test('renders nothing when there are no columns', () => {
+  const { container } = renderControl({
+    columnsPropsObject: { colnames: [], coltypes: [] },
+  });
+  expect(container).toBeEmptyDOMElement();
+});
+
+test('does not offer to show more columns at the 12 column limit', () => {
+  renderControl({ columnsPropsObject: makeColumns(12) });
+  expect(screen.getByText('col_12')).toBeInTheDocument();
+  expect(screen.queryByText('Show all columns')).not.toBeInTheDocument();
+});
+
+test('truncates to 10 rows and offers to show all columns above 12', () => {
+  renderControl({ columnsPropsObject: makeColumns(13) });
+  expect(screen.getByText('col_10')).toBeInTheDocument();
+  expect(screen.queryByText('col_11')).not.toBeInTheDocument();
+  expect(screen.getByText('Show all columns')).toBeInTheDocument();
+});
+
+test('expands and collapses the full column list via the toggle', async () => {
+  renderControl({ columnsPropsObject: makeColumns(13) });
+
+  await userEvent.click(screen.getByText('Show all columns'));
+  expect(screen.getByText('col_13')).toBeInTheDocument();
+
+  await userEvent.click(screen.getByText('Show less columns'));
+  expect(screen.queryByText('col_13')).not.toBeInTheDocument();
+  expect(screen.getByText('Show all columns')).toBeInTheDocument();
+});
+
+test('counts only applied columns toward the show more threshold', () => {
+  renderControl({
+    columnsPropsObject: makeColumns(20),
+    appliedColumnNames: ['col_1', 'col_2'],
+  });
+  expect(screen.queryByText('Show all columns')).not.toBeInTheDocument();
+});
+
+test('opens the popover with the column config form on click', async () => {
+  renderControl();
+  expect(screen.queryByText('Truncate Cells')).not.toBeInTheDocument();
+  expect(await openColumn('col_2')).toBeInTheDocument();
+});
+
+test('shows the stored config for the column in the popover', async () => {
+  renderControl({ value: { col_2: { truncateLongCells: true } } });
+  expect(await openColumn('col_2')).toBeChecked();
+});
+
+test('calls onChange after the debounce with the edited column config', async 
() => {
+  const { onChange } = renderControl();
+  const checkbox = await openColumn('col_2');
+
+  await userEvent.click(checkbox);
+  expect(onChange).not.toHaveBeenCalled();
+
+  await waitFor(() =>
+    expect(onChange).toHaveBeenCalledWith({
+      col_2: { truncateLongCells: true },
+    }),
+  );
+});
+
+test('merges the edit into existing configs of other known columns', async () 
=> {
+  const { onChange } = renderControl({
+    value: { col_1: { columnWidth: 50 } },
+  });
+  await userEvent.click(await openColumn('col_2'));
+
+  await waitFor(() =>
+    expect(onChange).toHaveBeenCalledWith({
+      col_1: { columnWidth: 50 },
+      col_2: { truncateLongCells: true },
+    }),
+  );
+});
+
+test('keeps existing keys of the edited column alongside the new one', async 
() => {
+  const { onChange } = renderControl({
+    value: { col_2: { columnWidth: 80 } },
+  });
+  await userEvent.click(await openColumn('col_2'));
+
+  await waitFor(() =>
+    expect(onChange).toHaveBeenCalledWith({
+      col_2: { columnWidth: 80, truncateLongCells: true },
+    }),
+  );
+});
+
+test('drops stored configs for columns that are no longer known', async () => {
+  const { onChange } = renderControl({
+    value: {
+      col_1: { columnWidth: 50 },
+      removed_col: { columnWidth: 99 },
+    },
+  });
+  await userEvent.click(await openColumn('col_2'));
+
+  await waitFor(() => expect(onChange).toHaveBeenCalled());
+  const saved = onChange.mock.calls[0][0];
+  expect(saved).not.toHaveProperty('removed_col');
+  expect(saved).toHaveProperty('col_1', { columnWidth: 50 });
+});
+
+test('drops configs of columns hidden by appliedColumnNames on edit', async () 
=> {
+  const { onChange } = renderControl({
+    appliedColumnNames: ['col_1', 'col_2'],
+    value: { col_3: { columnWidth: 30 } },
+  });
+  await userEvent.click(await openColumn('col_1'));
+
+  await waitFor(() => expect(onChange).toHaveBeenCalled());
+  expect(onChange.mock.calls[0][0]).toEqual({
+    col_1: { truncateLongCells: true },
+  });
+});
+
+const timeComparisonProps = (
+  timeComparisonColumnMap: Record<string, boolean>,
+): Partial<ControlProps> => ({
+  columnsPropsObject: {
+    colnames: ['metric_a', 'metric_b'],
+    coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+    timeComparisonColumnMap,
+  },
+});
+
+test('adds a General tab for time comparison columns', async () => {
+  renderControl(timeComparisonProps({ metric_a: true }));
+  await userEvent.click(screen.getByText('metric_a'));
+
+  const tabs = await screen.findAllByRole('tab');
+  expect(tabs.map(tab => tab.textContent)).toEqual([
+    'General',
+    'Column Settings',
+    'Number formatting',
+  ]);
+  expect(await findCheckbox('Display column in the 
chart')).toBeInTheDocument();
+});
+
+test('omits the General tab for columns that are not time comparison columns', 
async () => {
+  renderControl(timeComparisonProps({ metric_a: true }));
+  await userEvent.click(screen.getByText('metric_b'));
+
+  const tabs = await screen.findAllByRole('tab');
+  expect(tabs.map(tab => tab.textContent)).toEqual([
+    'Column Settings',
+    'Number formatting',
+  ]);
+});
+
+test('saves General tab edits under the time comparison column name', async () 
=> {
+  const { onChange } = renderControl(timeComparisonProps({ metric_a: true }));
+  await userEvent.click(screen.getByText('metric_a'));
+  await userEvent.click(await findCheckbox('Display column in the chart'));
+
+  await waitFor(() =>
+    expect(onChange).toHaveBeenCalledWith({ metric_a: { visible: false } }),
+  );
+});
+
+test('shows a hidden-column icon on child columns configured as not visible', 
() => {
+  renderControl({
+    columnsPropsObject: {
+      ...makeColumns(2),
+      childColumnMap: { col_2: true },
+    },
+    value: { col_1: { visible: false }, col_2: { visible: false } },
+  });
+  // col_1 is hidden too but is not a child column, so only col_2 shows the 
icon.
+  expect(screen.getAllByRole('img', { name: 'eye-invisible' 
})).toHaveLength(1);
+});
+
+// The time column is reported as `__timestamp` by the query but displayed as
+// "Time". The control currently keys edits by the displayed name, while the
+// table plugins look a column's config up by the raw query column name.
+const timestampProps = (
+  value: ControlProps['value'] = {},
+): Partial<ControlProps> => ({
+  columnsPropsObject: {
+    colnames: ['__timestamp', 'name'],
+    coltypes: [GenericDataType.Temporal, GenericDataType.String],
+  },
+  value,
+});
+
+test('labels the __timestamp column with its alias', () => {
+  renderControl(timestampProps());
+  expect(screen.getByText('Time')).toBeInTheDocument();
+  expect(screen.queryByText('__timestamp')).not.toBeInTheDocument();
+});
+
+test('does not alias columns without an alias entry', () => {
+  renderControl(timestampProps());
+  expect(screen.getByText('name')).toBeInTheDocument();
+});
+
+test('reads the stored config of an aliased column by its raw column name', 
async () => {
+  renderControl(timestampProps({ __timestamp: { horizontalAlign: 'right' } }));
+  await userEvent.click(screen.getByText('Time'));
+  const right = await screen.findByRole('radio', { name: /align-right/ });
+  expect(right).toBeChecked();
+});
+
+test('does not read back a config stored under the alias', async () => {
+  renderControl(timestampProps({ Time: { horizontalAlign: 'right' } }));
+  await userEvent.click(screen.getByText('Time'));
+  const left = await screen.findByRole('radio', { name: /align-left/ });
+  expect(left).toBeChecked();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
 
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
new file mode 100644
index 00000000000..4722ab5713f
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
@@ -0,0 +1,108 @@
+/**
+ * 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,
+  within,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import ControlForm, { ControlFormItem, ControlFormRow } from '.';
+
+const noDigits = (value: unknown) =>
+  /\d/.test(String(value)) ? 'Digits are not allowed' : false;
+
+const renderForm = (
+  props: {
+    value?: Record<string, string>;
+    validators?: ((value: unknown) => string | false)[];
+    debounceDelay?: number;
+  } = {},
+) => {
+  const onChange = jest.fn();
+  render(
+    <ControlForm onChange={onChange} value={props.value}>
+      <ControlFormRow>
+        <ControlFormItem
+          name="title"
+          controlType="Input"
+          label="Title"
+          description="Title description"
+          validators={props.validators}
+          debounceDelay={props.debounceDelay}
+        />
+        <ControlFormItem
+          name="note"
+          controlType="Input"
+          label="Note"
+          description="Note description"
+        />
+      </ControlFormRow>
+    </ControlForm>,
+  );
+  return { onChange };
+};
+
+test('seeds each field from the matching key of the form value', () => {
+  renderForm({ value: { title: 'Hello', note: 'World' } });
+  expect(
+    screen
+      .getAllByRole('textbox')
+      .map(input => (input as HTMLInputElement).value),
+  ).toEqual(['Hello', 'World']);
+});
+
+test('propagates an edit to the form merged with the existing value', async () 
=> {
+  const { onChange } = renderForm({ value: { note: 'World' } });
+  await userEvent.type(screen.getAllByRole('textbox')[0], 'Hi');
+
+  await waitFor(() =>
+    expect(onChange).toHaveBeenLastCalledWith({ note: 'World', title: 'Hi' }),
+  );
+});
+
+test('debounces rapid keystrokes into a single form change', async () => {
+  const { onChange } = renderForm();
+  await userEvent.type(screen.getAllByRole('textbox')[0], 'abc');
+
+  await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
+  expect(onChange).toHaveBeenCalledWith({ title: 'abc' });
+});
+
+test('does not propagate a value that fails validation and shows the error', 
async () => {
+  const { onChange } = renderForm({ validators: [noDigits] });
+  await userEvent.type(screen.getAllByRole('textbox')[0], '1');
+
+  const errorBadge = await screen.findByTestId('error-tooltip');
+  await userEvent.hover(within(errorBadge).getByRole('img'));
+  expect(await screen.findByText('Digits are not 
allowed')).toBeInTheDocument();
+  await new Promise(resolve => setTimeout(resolve, 400));
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+test('skips validation for an emptied field and propagates the empty value', 
async () => {
+  const { onChange } = renderForm({
+    value: { title: 'x' },
+    validators: [() => 'always invalid'],
+  });
+  await userEvent.clear(screen.getAllByRole('textbox')[0]);
+
+  await waitFor(() => expect(onChange).toHaveBeenCalledWith({ title: '' }));
+  expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
 
b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
index b40806bbe71..0662d0738b2 100644
--- 
a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
@@ -16,7 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render, screen } from 'spec/helpers/testing-library';
+import { ComponentProps } from 'react';
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'spec/helpers/testing-library';
 import { Comparator } from '@superset-ui/chart-controls';
 import { GenericDataType } from '@apache-superset/core/common';
 import ConditionalFormattingControl from './ConditionalFormattingControl';
@@ -101,3 +108,210 @@ test('renders verbose column name when available', () => {
   );
   expect(screen.getByText('My Column is false')).toBeInTheDocument();
 });
+
+const numericColumns = [
+  { label: 'Column A', value: 'col_a', dataType: GenericDataType.Numeric },
+  { label: 'Column B', value: 'col_b', dataType: GenericDataType.Numeric },
+];
+
+const greaterThanA: ConditionalFormattingConfig = {
+  column: 'col_a',
+  operator: Comparator.GreaterThan,
+  targetValue: 1,
+  colorScheme: 'colorSuccess',
+};
+
+const lessThanB: ConditionalFormattingConfig = {
+  column: 'col_b',
+  operator: Comparator.LessThan,
+  targetValue: 2,
+  colorScheme: 'colorSuccess',
+};
+
+const renderCrud = (
+  props: Partial<ComponentProps<typeof ConditionalFormattingControl>> = {},
+) => {
+  const onChange = jest.fn();
+  const utils = render(
+    <ConditionalFormattingControl
+      {...defaultProps}
+      columnOptions={numericColumns}
+      onChange={onChange}
+      {...props}
+    />,
+  );
+  return { onChange, ...utils };
+};
+
+const lastChange = (onChange: jest.Mock) =>
+  onChange.mock.calls[onChange.mock.calls.length - 1][0];
+
+// The popover Selects render their options inline next to the trigger, so
+// scope the option lookup to the Select that was opened.
+const pickColumn = async (optionLabel: string) => {
+  const trigger = await screen.findByRole('combobox', {
+    name: 'Select column',
+  });
+  const container = trigger.closest('.ant-select')!
+    .parentElement as HTMLElement;
+  await userEvent.click(trigger);
+  await userEvent.click(await within(container).findByText(optionLabel));
+};
+
+test('opens the add formatter popover from the add label', async () => {
+  renderCrud();
+  expect(screen.queryByText('Add new formatter')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByText('Add new color formatter'));
+
+  expect(await screen.findByText('Add new formatter')).toBeInTheDocument();
+  expect(screen.getByText('Apply')).toBeInTheDocument();
+});
+
+test('adds a formatter and reports it through onChange when the popover is 
applied', async () => {
+  const { onChange } = renderCrud();
+
+  await userEvent.click(screen.getByText('Add new color formatter'));
+  await pickColumn('Column B');
+  await userEvent.click(await screen.findByText('Apply'));
+
+  await waitFor(() =>
+    expect(lastChange(onChange)).toEqual([
+      expect.objectContaining({ column: 'col_b' }),
+    ]),
+  );
+  expect(screen.getByText('col_b')).toBeInTheDocument();
+  await waitFor(() =>
+    expect(screen.queryByText('Add new formatter')).not.toBeInTheDocument(),
+  );
+});
+
+test('appends a new formatter after the existing ones', async () => {
+  const { onChange } = renderCrud({ value: [greaterThanA] });
+
+  await userEvent.click(screen.getByText('Add new color formatter'));
+  await pickColumn('Column B');
+  await userEvent.click(await screen.findByText('Apply'));
+
+  await waitFor(() => expect(lastChange(onChange)).toHaveLength(2));
+  expect(lastChange(onChange)[0]).toEqual(greaterThanA);
+  expect(lastChange(onChange)[1]).toEqual(
+    expect.objectContaining({ column: 'col_b' }),
+  );
+});
+
+test('opens the edit popover prefilled from the clicked formatter', async () 
=> {
+  renderCrud({ value: [greaterThanA, lessThanB] });
+
+  await userEvent.click(screen.getByText('col_b < 2'));
+
+  expect(await screen.findByText('Edit formatter')).toBeInTheDocument();
+  expect(screen.getByLabelText('Target value')).toHaveValue('2');
+});
+
+test('replaces the edited formatter at its index and keeps the others', async 
() => {
+  const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+  await userEvent.click(screen.getByText('col_b < 2'));
+  await screen.findByText('Edit formatter');
+  const targetValue = screen.getByLabelText('Target value');
+  await userEvent.clear(targetValue);
+  await userEvent.type(targetValue, '7');
+  await userEvent.click(screen.getByText('Apply'));
+
+  await waitFor(() =>
+    expect(lastChange(onChange)).toEqual([
+      greaterThanA,
+      expect.objectContaining({
+        column: 'col_b',
+        operator: Comparator.LessThan,
+        targetValue: 7,
+      }),
+    ]),
+  );
+  expect(screen.getByText('col_b < 7')).toBeInTheDocument();
+  expect(screen.queryByText('col_b < 2')).not.toBeInTheDocument();
+});
+
+test('edits the first formatter without touching later ones', async () => {
+  const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+  await userEvent.click(screen.getByText('col_a > 1'));
+  await screen.findByText('Edit formatter');
+  const targetValue = screen.getByLabelText('Target value');
+  await userEvent.clear(targetValue);
+  await userEvent.type(targetValue, '9');
+  await userEvent.click(screen.getByText('Apply'));
+
+  await waitFor(() =>
+    expect(lastChange(onChange)).toEqual([
+      expect.objectContaining({ column: 'col_a', targetValue: 9 }),
+      lessThanB,
+    ]),
+  );
+});
+
+test('deletes a formatter with its close button', async () => {
+  const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+  const closeButtons = screen.getAllByRole('button', { name: /close/i });
+  expect(closeButtons).toHaveLength(2);
+  await userEvent.click(closeButtons[0]);
+
+  await waitFor(() => expect(lastChange(onChange)).toEqual([lessThanB]));
+  expect(screen.queryByText('col_a > 1')).not.toBeInTheDocument();
+  expect(screen.getByText('col_b < 2')).toBeInTheDocument();
+});
+
+test('deleting the only formatter reports an empty list', async () => {
+  const { onChange } = renderCrud({ value: [greaterThanA] });
+
+  await userEvent.click(screen.getByRole('button', { name: /close/i }));
+
+  await waitFor(() => expect(lastChange(onChange)).toEqual([]));
+});
+
+test('prunes formatters whose column is not among the column options', async 
() => {
+  const { onChange } = renderCrud({
+    value: [greaterThanA, lessThanB],
+    columnOptions: [numericColumns[1]],
+    removeIrrelevantConditions: true,
+  });
+
+  await waitFor(() => expect(lastChange(onChange)).toEqual([lessThanB]));
+  expect(screen.queryByText('col_a > 1')).not.toBeInTheDocument();
+  expect(screen.getByText('col_b < 2')).toBeInTheDocument();
+});
+
+test('prunes a formatter once its column leaves the column options', async () 
=> {
+  const { onChange, rerender } = renderCrud({
+    value: [greaterThanA, lessThanB],
+    removeIrrelevantConditions: true,
+  });
+  expect(lastChange(onChange)).toEqual([greaterThanA, lessThanB]);
+
+  rerender(
+    <ConditionalFormattingControl
+      {...defaultProps}
+      columnOptions={[numericColumns[0]]}
+      onChange={onChange}
+      value={[greaterThanA, lessThanB]}
+      removeIrrelevantConditions
+    />,
+  );
+
+  await waitFor(() => expect(lastChange(onChange)).toEqual([greaterThanA]));
+  expect(screen.queryByText('col_b < 2')).not.toBeInTheDocument();
+});
+
+test('keeps formatters for missing columns when pruning is disabled', async () 
=> {
+  const { onChange } = renderCrud({
+    value: [greaterThanA, lessThanB],
+    columnOptions: [numericColumns[1]],
+    removeIrrelevantConditions: false,
+  });
+
+  await waitFor(() => expect(onChange).toHaveBeenCalled());
+  expect(lastChange(onChange)).toEqual([greaterThanA, lessThanB]);
+  expect(screen.getByText('col_a > 1')).toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
new file mode 100644
index 00000000000..8172abccfce
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * 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 ContourOption from './ContourOption';
+import { ContourOptionProps, ContourType } from './types';
+
+const color = { r: 10, g: 20, b: 30, a: 100 };
+
+const isoline: ContourType = {
+  lowerThreshold: 4,
+  color,
+  strokeWidth: 3,
+  zIndex: 10,
+};
+
+const isoband: ContourType = {
+  lowerThreshold: 6,
+  upperThreshold: 10,
+  color,
+  zIndex: 20,
+};
+
+const renderOption = (props: Partial<ContourOptionProps> = {}) => {
+  const handlers = {
+    saveContour: jest.fn(),
+    onClose: jest.fn(),
+    onShift: jest.fn(),
+  };
+  render(
+    <ContourOption contour={isoline} index={0} {...handlers} {...props} />,
+    { useDnd: true },
+  );
+  return handlers;
+};
+
+test('labels an isoline with its threshold, color and stroke width', () => {
+  renderOption({ contour: isoline });
+  expect(
+    screen.getByText(
+      'Threshold: 4, color: rgba(10, 20, 30, 1), stroke width: 3',
+    ),
+  ).toBeInTheDocument();
+});
+
+test('labels an isoband with its threshold range and color but no stroke 
width', () => {
+  renderOption({ contour: isoband });
+  expect(
+    screen.getByText('Threshold: [6, 10], color: rgba(10, 20, 30, 1)'),
+  ).toBeInTheDocument();
+  expect(screen.queryByText(/stroke width/)).not.toBeInTheDocument();
+});
+
+test('treats a contour with an upper threshold as an isoband', () => {
+  renderOption({ contour: { ...isoline, upperThreshold: 8 } });
+  expect(screen.getByText(/Threshold: \[4, 8\]/)).toBeInTheDocument();
+});
+
+test('treats a contour without an upper threshold as an isoline', () => {
+  renderOption({
+    contour: { ...isoband, upperThreshold: undefined, strokeWidth: 2 },
+  });
+  expect(screen.getByText(/stroke width: 2/)).toBeInTheDocument();
+});
+
+test('calls onClose with the option index when removed', async () => {
+  const { onClose } = renderOption({ index: 2 });
+
+  await userEvent.click(screen.getByTestId('remove-control-button'));
+
+  expect(onClose).toHaveBeenCalledWith(2);
+});
+
+test('opens the contour popover prefilled from the contour when clicked', 
async () => {
+  renderOption({ contour: isoband });
+
+  await userEvent.click(screen.getByTestId('option-label'));
+
+  expect(await screen.findByRole('tab', { name: 'Isoband' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+  const inputs = screen.getAllByTestId('inline-name') as HTMLInputElement[];
+  expect(inputs.map(input => input.value)).toEqual(['6', '10']);
+});
+
+test('saves an edited contour through saveContour', async () => {
+  const { saveContour } = renderOption({ contour: isoline });
+
+  await userEvent.click(screen.getByTestId('option-label'));
+  await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
+
+  expect(saveContour).toHaveBeenCalledWith(
+    expect.objectContaining({ lowerThreshold: 4, strokeWidth: 3, color }),
+  );
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
new file mode 100644
index 00000000000..dd7c4560849
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
@@ -0,0 +1,277 @@
+/**
+ * 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,
+  sleep,
+  userEvent,
+  waitFor,
+  within,
+} from 'spec/helpers/testing-library';
+import { Constants } from '@superset-ui/core/components';
+import ContourPopoverControl from './ContourPopoverControl';
+import { ContourType } from './types';
+
+const color = { r: 255, g: 0, b: 255, a: 100 };
+
+const isoline: ContourType = {
+  lowerThreshold: 4,
+  color,
+  strokeWidth: 2,
+  zIndex: 10,
+};
+
+const isoband: ContourType = {
+  lowerThreshold: 6,
+  upperThreshold: 10,
+  color,
+  zIndex: 20,
+};
+
+const renderControl = (value?: ContourType) => {
+  const onSave = jest.fn();
+  const onClose = jest.fn();
+  render(
+    <ContourPopoverControl value={value} onSave={onSave} onClose={onClose} />,
+  );
+  return { onSave, onClose };
+};
+
+const saveButton = () => screen.getByRole('button', { name: 'Save' });
+
+// Only the active tab's pane is exposed to the accessibility tree.
+const fields = () =>
+  within(screen.getByRole('tabpanel')).getAllByTestId(
+    'inline-name',
+  ) as HTMLInputElement[];
+
+// The text inputs debounce before updating the contour state, so wait for
+// each edit to land before making the next one.
+const setField = async (index: number, text: string) => {
+  const input = fields()[index];
+  await userEvent.clear(input);
+  if (text) await userEvent.type(input, text);
+  await sleep(Constants.FAST_DEBOUNCE + 100);
+};
+
+test('opens on the Isoline tab for a contour without an upper threshold', () 
=> {
+  renderControl(isoline);
+  expect(screen.getByRole('tab', { name: 'Isoline' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+  expect(screen.getByText('Stroke Width')).toBeInTheDocument();
+});
+
+test('opens on the Isoband tab for a contour with an upper threshold', () => {
+  renderControl(isoband);
+  expect(screen.getByRole('tab', { name: 'Isoband' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+  expect(screen.getByText('Upper Threshold')).toBeInTheDocument();
+});
+
+test('opens on the Isoline tab when creating a new contour', () => {
+  renderControl();
+  expect(screen.getByRole('tab', { name: 'Isoline' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+});
+
+test('prefills the fields from the contour being edited', () => {
+  renderControl(isoline);
+  expect(fields().map(input => input.value)).toEqual(['4', '2']);
+});
+
+// An absent field is coerced with Number(), so the saved value is NaN rather
+// than undefined. Consumers only rely on it being falsy.
+test('saves an isoline without an upper threshold', async () => {
+  const { onSave, onClose } = renderControl(isoline);
+
+  await userEvent.click(saveButton());
+
+  const saved = onSave.mock.calls[0][0];
+  expect(saved).toEqual(
+    expect.objectContaining({ color, lowerThreshold: 4, strokeWidth: 2 }),
+  );
+  expect(saved.upperThreshold).toBeFalsy();
+  expect(onClose).toHaveBeenCalled();
+});
+
+test('saves an isoband without a stroke width', async () => {
+  const { onSave, onClose } = renderControl(isoband);
+
+  await userEvent.click(saveButton());
+
+  const saved = onSave.mock.calls[0][0];
+  expect(saved).toEqual(
+    expect.objectContaining({ color, lowerThreshold: 6, upperThreshold: 10 }),
+  );
+  expect(saved.strokeWidth).toBeFalsy();
+  expect(onClose).toHaveBeenCalled();
+});
+
+test('saves the thresholds as numbers after editing', async () => {
+  const { onSave } = renderControl(isoband);
+
+  await setField(0, '7');
+  await setField(1, '12');
+  await userEvent.click(saveButton());
+
+  expect(onSave).toHaveBeenCalledWith(
+    expect.objectContaining({ lowerThreshold: 7, upperThreshold: 12 }),
+  );
+});
+
+test('saves as an isoband when an isoline is switched to the Isoband tab', 
async () => {
+  const { onSave } = renderControl(isoline);
+
+  await userEvent.click(screen.getByRole('tab', { name: 'Isoband' }));
+  await setField(1, '9');
+  await waitFor(() => expect(saveButton()).toBeEnabled());
+  await userEvent.click(saveButton());
+
+  const saved = onSave.mock.calls[0][0];
+  expect(saved).toEqual(
+    expect.objectContaining({ lowerThreshold: 4, upperThreshold: 9 }),
+  );
+  expect(saved.strokeWidth).toBeFalsy();
+});
+
+test('saves as an isoline when an isoband is switched to the Isoline tab', 
async () => {
+  const { onSave } = renderControl(isoband);
+
+  await userEvent.click(screen.getByRole('tab', { name: 'Isoline' }));
+  await setField(1, '3');
+  await waitFor(() => expect(saveButton()).toBeEnabled());
+  await userEvent.click(saveButton());
+
+  const saved = onSave.mock.calls[0][0];
+  expect(saved).toEqual(
+    expect.objectContaining({ lowerThreshold: 6, strokeWidth: 3 }),
+  );
+  expect(saved.upperThreshold).toBeFalsy();
+});
+
+test('keeps save disabled until every required isoline field is set', () => {
+  renderControl();
+  expect(saveButton()).toBeDisabled();
+});
+
+test('shows an integer error for a fractional isoline threshold and blocks 
save', async () => {
+  const { onSave } = renderControl(isoline);
+
+  await setField(0, '1.5');
+
+  await waitFor(() => expect(saveButton()).toBeDisabled());
+  const badge = await screen.findByTestId('error-tooltip');
+  await userEvent.hover(within(badge).getByRole('img'));
+  expect(
+    await screen.findByText('is expected to be an integer'),
+  ).toBeInTheDocument();
+  await userEvent.click(saveButton());
+  expect(onSave).not.toHaveBeenCalled();
+});
+
+test('shows an integer error for a non-numeric stroke width and blocks save', 
async () => {
+  renderControl(isoline);
+
+  await setField(1, 'wide');
+
+  await waitFor(() => expect(saveButton()).toBeDisabled());
+  expect(await screen.findAllByTestId('error-tooltip')).toHaveLength(1);
+});
+
+test('clears the integer error once the value is corrected', async () => {
+  renderControl(isoline);
+
+  await setField(0, '1.5');
+  await screen.findByTestId('error-tooltip');
+  await setField(0, '2');
+
+  await waitFor(() =>
+    expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument(),
+  );
+  await waitFor(() => expect(saveButton()).toBeEnabled());
+});
+
+test('flags both isoband thresholds when lower is not below upper', async () 
=> {
+  renderControl(isoband);
+
+  await setField(0, '10');
+
+  await waitFor(() =>
+    expect(screen.getAllByTestId('error-tooltip')).toHaveLength(2),
+  );
+  expect(saveButton()).toBeDisabled();
+});
+
+test('explains which isoband threshold must be lower or greater', async () => {
+  renderControl(isoband);
+
+  await setField(0, '10');
+
+  const badges = await screen.findAllByTestId('error-tooltip');
+  await userEvent.hover(within(badges[0]).getByRole('img'));
+  expect(
+    await screen.findByText(
+      'Lower threshold must be lower than upper threshold',
+    ),
+  ).toBeInTheDocument();
+  await userEvent.hover(within(badges[1]).getByRole('img'));
+  expect(
+    await screen.findByText(
+      'Upper threshold must be greater than lower threshold',
+    ),
+  ).toBeInTheDocument();
+});
+
+test('does not allow saving an isoband whose thresholds are equal', async () 
=> {
+  const { onSave } = renderControl(isoband);
+
+  await setField(1, '6');
+
+  await waitFor(() =>
+    expect(screen.getAllByTestId('error-tooltip')).toHaveLength(2),
+  );
+  await userEvent.click(saveButton());
+  expect(onSave).not.toHaveBeenCalled();
+});
+
+test('allows saving an isoband again once lower drops below upper', async () 
=> {
+  renderControl(isoband);
+
+  await setField(0, '10');
+  await waitFor(() => expect(saveButton()).toBeDisabled());
+  await setField(0, '2');
+
+  await waitFor(() => expect(saveButton()).toBeEnabled());
+  expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('closes without saving when Close is clicked', async () => {
+  const { onSave, onClose } = renderControl(isoline);
+
+  await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+  expect(onClose).toHaveBeenCalled();
+  expect(onSave).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
new file mode 100644
index 00000000000..e275f5b4405
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * 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,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import ContourPopoverTrigger from './ContourPopoverTrigger';
+import { ContourPopoverTriggerProps, ContourType } from './types';
+
+const contour: ContourType = {
+  lowerThreshold: 4,
+  color: { r: 10, g: 20, b: 30, a: 100 },
+  strokeWidth: 3,
+  zIndex: 10,
+};
+
+const renderTrigger = (props: Partial<ContourPopoverTriggerProps> = {}) => {
+  const saveContour = jest.fn();
+  render(
+    <ContourPopoverTrigger saveContour={saveContour} value={contour} 
{...props}>
+      <span>Open contour</span>
+    </ContourPopoverTrigger>,
+  );
+  return { saveContour };
+};
+
+const expectClosed = () =>
+  waitFor(() =>
+    expect(
+      screen.queryByRole('tab', { name: 'Isoline' }),
+    ).not.toBeInTheDocument(),
+  );
+
+test('renders its children and keeps the popover closed initially', () => {
+  renderTrigger();
+  expect(screen.getByText('Open contour')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('tab', { name: 'Isoline' }),
+  ).not.toBeInTheDocument();
+});
+
+test('opens the popover on click when uncontrolled', async () => {
+  renderTrigger();
+
+  await userEvent.click(screen.getByText('Open contour'));
+
+  expect(
+    await screen.findByRole('tab', { name: 'Isoline' }),
+  ).toBeInTheDocument();
+});
+
+test('closes the uncontrolled popover after saving and forwards the contour', 
async () => {
+  const { saveContour } = renderTrigger();
+
+  await userEvent.click(screen.getByText('Open contour'));
+  await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
+
+  expect(saveContour).toHaveBeenCalledWith(
+    expect.objectContaining({ lowerThreshold: 4, strokeWidth: 3 }),
+  );
+  await expectClosed();
+});
+
+test('closes the uncontrolled popover with the Close button without saving', 
async () => {
+  const { saveContour } = renderTrigger();
+
+  await userEvent.click(screen.getByText('Open contour'));
+  await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+  expect(saveContour).not.toHaveBeenCalled();
+  await expectClosed();
+});
+
+test('follows the visible prop when controlled', async () => {
+  renderTrigger({
+    isControlled: true,
+    visible: true,
+    toggleVisibility: jest.fn(),
+  });
+
+  expect(
+    await screen.findByRole('tab', { name: 'Isoline' }),
+  ).toBeInTheDocument();
+});
+
+test('delegates visibility changes to the controller instead of its own 
state', async () => {
+  const toggleVisibility = jest.fn();
+  const { saveContour } = renderTrigger({
+    isControlled: true,
+    visible: false,
+    toggleVisibility,
+  });
+
+  await userEvent.click(screen.getByText('Open contour'));
+
+  expect(toggleVisibility).toHaveBeenCalledWith(true);
+  expect(saveContour).not.toHaveBeenCalled();
+});
+
+test('asks the controller to hide the popover on Close', async () => {
+  const toggleVisibility = jest.fn();
+  renderTrigger({ isControlled: true, visible: true, toggleVisibility });
+
+  await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+  expect(toggleVisibility).toHaveBeenCalledWith(false);
+});
+
+test('opens empty when no contour value is provided', async () => {
+  renderTrigger({ value: undefined });
+
+  await userEvent.click(screen.getByText('Open contour'));
+
+  expect(await screen.findByRole('button', { name: 'Save' })).toBeDisabled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
 
b/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
new file mode 100644
index 00000000000..a93eec5bf1d
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * 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,
+  sleep,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { Constants } from '@superset-ui/core/components';
+import ContourControl from '.';
+import { ContourType } from './types';
+
+const magenta = { r: 255, g: 0, b: 255, a: 100 };
+const green = { r: 0, g: 255, b: 0, a: 100 };
+
+const isoline: ContourType = {
+  lowerThreshold: 4,
+  color: magenta,
+  strokeWidth: 1,
+  zIndex: 10,
+};
+
+const isoband: ContourType = {
+  lowerThreshold: 6,
+  upperThreshold: 10,
+  color: green,
+  zIndex: 20,
+};
+
+const isolineLabel =
+  'Threshold: 4, color: rgba(255, 0, 255, 1), stroke width: 1';
+const isobandLabel = 'Threshold: [6, 10], color: rgba(0, 255, 0, 1)';
+
+const renderControl = (value?: ContourType[]) => {
+  const onChange = jest.fn();
+  render(
+    <ContourControl
+      name="contours"
+      label="Contours"
+      // ContourControl is not a member of the ControlType union; the prop is
+      // required by the shared control props but unused by the component.
+      type="BoundsControl"
+      actions={{ setControlValue: jest.fn() }}
+      value={value}
+      onChange={onChange}
+    />,
+    { useDnd: true },
+  );
+  return { onChange };
+};
+
+const lastChange = (onChange: jest.Mock): ContourType[] =>
+  onChange.mock.calls[onChange.mock.calls.length - 1][0];
+
+const fields = () => screen.getAllByTestId('inline-name') as 
HTMLInputElement[];
+
+const pickColor = async () => {
+  const trigger = document.querySelector(
+    '.ant-color-picker-trigger',
+  ) as HTMLElement;
+  await userEvent.click(trigger);
+  const preset = await waitFor(() => {
+    const el = document.querySelector('.ant-color-picker-presets-color');
+    if (!el) throw new Error('no color preset');
+    return el as HTMLElement;
+  });
+  await userEvent.click(preset);
+};
+
+test('renders the default contours when no value is provided', () => {
+  renderControl();
+  expect(screen.getAllByTestId('option-label')).toHaveLength(3);
+});
+
+test('renders one option per contour in the provided value', () => {
+  renderControl([isoline, isoband]);
+  expect(screen.getByText(isolineLabel)).toBeInTheDocument();
+  expect(screen.getByText(isobandLabel)).toBeInTheDocument();
+});
+
+test('reports the contours with a z-index that follows their order', () => {
+  const { onChange } = renderControl([isoband, isoline]);
+  expect(lastChange(onChange)).toEqual([
+    expect.objectContaining({
+      lowerThreshold: 6,
+      upperThreshold: 10,
+      zIndex: 10,
+    }),
+    expect.objectContaining({ lowerThreshold: 4, zIndex: 20 }),
+  ]);
+});
+
+test('opens the add popover from the ghost button', async () => {
+  renderControl([]);
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByText('Click to add a contour'));
+
+  expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+  expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+});
+
+test('adds an isoline and reports it after the existing contours', async () => 
{
+  const { onChange } = renderControl([isoband]);
+
+  await userEvent.click(screen.getByText('Click to add a contour'));
+  await screen.findByRole('tooltip');
+  await userEvent.type(fields()[0], '3');
+  await sleep(Constants.FAST_DEBOUNCE + 100);
+  await userEvent.type(fields()[1], '2');
+  await sleep(Constants.FAST_DEBOUNCE + 100);
+  await pickColor();
+  await waitFor(() =>
+    expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(),
+  );
+  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+  await waitFor(() => expect(lastChange(onChange)).toHaveLength(2));
+  expect(lastChange(onChange)[0]).toEqual(
+    expect.objectContaining({ lowerThreshold: 6, upperThreshold: 10 }),
+  );
+  expect(lastChange(onChange)[1]).toEqual(
+    expect.objectContaining({ lowerThreshold: 3, strokeWidth: 2, zIndex: 20 }),
+  );
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+});
+
+test('edits a contour in place through its popover', async () => {
+  const { onChange } = renderControl([isoline, isoband]);
+
+  await userEvent.click(screen.getByText(isobandLabel));
+  await screen.findByRole('tooltip');
+  const input = fields()[0];
+  await userEvent.clear(input);
+  await userEvent.type(input, '5');
+  await sleep(Constants.FAST_DEBOUNCE + 100);
+  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+  await waitFor(() =>
+    expect(lastChange(onChange)).toEqual([
+      expect.objectContaining({ lowerThreshold: 4, zIndex: 10 }),
+      expect.objectContaining({
+        lowerThreshold: 5,
+        upperThreshold: 10,
+        zIndex: 20,
+      }),
+    ]),
+  );
+  expect(
+    screen.getByText('Threshold: [5, 10], color: rgba(0, 255, 0, 1)'),
+  ).toBeInTheDocument();
+});
+
+test('removes a contour with its remove button', async () => {
+  const { onChange } = renderControl([isoline, isoband]);
+
+  await userEvent.click(screen.getAllByTestId('remove-control-button')[0]);
+
+  await waitFor(() =>
+    expect(lastChange(onChange)).toEqual([
+      expect.objectContaining({
+        lowerThreshold: 6,
+        upperThreshold: 10,
+        zIndex: 10,
+      }),
+    ]),
+  );
+  expect(screen.queryByText(isolineLabel)).not.toBeInTheDocument();
+  expect(screen.getByText(isobandLabel)).toBeInTheDocument();
+});
+
+test('removing the last contour reports an empty list', async () => {
+  const { onChange } = renderControl([isoline]);
+
+  await userEvent.click(screen.getByTestId('remove-control-button'));
+
+  await waitFor(() => expect(lastChange(onChange)).toEqual([]));
+});

Reply via email to