/**
 * 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,
  fireEvent,
  render,
  screen,
  selectOption,
  userEvent,
  waitFor,
} from 'spec/helpers/testing-library';
import {
  getChartMetadataRegistry,
  ChartMetadata,
  VizType,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import fetchMock from 'fetch-mock';
import setupColors from 'src/setup/setupColors';
import { ANNOTATION_TYPES_METADATA } from './AnnotationTypes';
import AnnotationLayer from './AnnotationLayer';

const defaultProps = {
  value: '',
  vizType: VizType.Table,
  annotationType: ANNOTATION_TYPES_METADATA.FORMULA.value,
  canReadAnnotation: true,
};

const nativeLayerApiRoute = 'glob:*/api/v1/annotation_layer/*';
const chartApiRoute = /\/api\/v1\/chart\/\?q=.+/;
const chartApiWithIdRoute = /\/api\/v1\/chart\/\w+\?q=.+/;

const chartApiWithIdRouteName = 'chart-with-id';
const nativeLayerRouteName = 'native-layer';

const nativeLayerResult = {
  result: [{ name: 'Chart A', id: 'a' }],
};

const withIdResult = {
  result: {
    slice_name: 'Mocked Slice',
    params: JSON.stringify({ groupby: ['country'] }),
    query_context: JSON.stringify({
      form_data: {
        groupby: ['country'],
      },
    }),
    viz_type: VizType.Line,
  },
};

const setViewportWidth = (value: number) =>
  Object.defineProperty(document.documentElement, 'clientWidth', {
    configurable: true,
    value,
  });

// jsdom serves `clientWidth` from the prototype and `jest.restoreAllMocks` leaves
// `defineProperty` alone, so drop the override or every later test inherits it.
const restoreViewportWidth = () =>
  Reflect.deleteProperty(document.documentElement, 'clientWidth');

const rect = (width: number, right: number) =>
  ({
    width,
    right,
    left: right - width,
    top: 0,
    bottom: 0,
    height: 0,
  }) as DOMRect;

const SECTIONS_TEST_ID = 'annotation-layer-sections';

/**
 * Lays the popover out beside a control panel of the given width. `sectionWidth`
 * is what each section wants, which decides whether the row fits on one line.
 */
const mockLayout = (panelWidth: () => number, sectionWidth = 0) =>
  jest
    .spyOn(Element.prototype, 'getBoundingClientRect')
    .mockImplementation(function (this: Element) {
      if (this.id === 'controlSections')
        return rect(panelWidth(), panelWidth());
      if (this.classList.contains('ant-popover')) return rect(802, 1422);
      if (this.getAttribute('data-test') === SECTIONS_TEST_ID)
        return rect(778, 1410);
      if (this.parentElement?.getAttribute('data-test') === SECTIONS_TEST_ID)
        return rect(sectionWidth, sectionWidth);
      return rect(0, 0);
    });

beforeAll(() => {
  const supportedAnnotationTypes = Object.values(ANNOTATION_TYPES_METADATA).map(
    value => value.value,
  );

  fetchMock.get(nativeLayerApiRoute, nativeLayerResult, {
    name: nativeLayerRouteName,
  });

  fetchMock.get(chartApiRoute, {
    result: [{ id: 'a', slice_name: 'Chart A', viz_type: VizType.Table }],
  });

  fetchMock.get(chartApiWithIdRoute, withIdResult, {
    name: chartApiWithIdRouteName,
  });

  setupColors();

  getChartMetadataRegistry().registerValue(
    'table',
    new ChartMetadata({
      name: 'Table',
      thumbnail: '',
      supportedAnnotationTypes,
      canBeAnnotationTypes: ['EVENT'],
    }),
  );
});

// Call history is shared across tests; without this, call-count assertions
// depend on execution order and fail under `jest --randomize`.
beforeEach(() => {
  fetchMock.clearHistory();
});

const waitForRender = (props?: any) =>
  waitFor(() => render(<AnnotationLayer {...defaultProps} {...props} />));

test('renders with default props', async () => {
  await waitForRender();
  expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
  expect(screen.getByRole('button', { name: 'OK' })).toBeDisabled();
  expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
});

test('renders extra checkboxes when type is time series', async () => {
  await waitForRender();
  expect(
    screen.queryByRole('button', { name: 'Show Markers' }),
  ).not.toBeInTheDocument();
  expect(
    screen.queryByRole('button', { name: 'Hide Line' }),
  ).not.toBeInTheDocument();
  userEvent.click(screen.getAllByText('Formula')[0]);
  userEvent.click(screen.getByText('Time series'));
  expect(
    await screen.findByRole('button', { name: 'Show Markers' }),
  ).toBeInTheDocument();
  expect(screen.getByRole('button', { name: 'Hide Line' })).toBeInTheDocument();
});

test('enables apply and ok buttons', async () => {
  const { container } = render(<AnnotationLayer {...defaultProps} />);

  await waitFor(() => {
    expect(container).toBeInTheDocument();
  });

  const nameInput = screen.getByRole('textbox', { name: 'Name' });
  const formulaInput = screen.getByRole('textbox', { name: 'Formula' });

  expect(nameInput).toBeInTheDocument();
  expect(formulaInput).toBeInTheDocument();

  userEvent.type(nameInput, 'Name');
  userEvent.type(formulaInput, '2x');

  await waitFor(() => {
    expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
    expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled();
  });
});

test('triggers addAnnotationLayer when apply button is clicked', async () => {
  const addAnnotationLayer = jest.fn();
  await waitForRender({ name: 'Test', value: '2x', addAnnotationLayer });
  userEvent.click(screen.getByRole('button', { name: 'Apply' }));
  expect(addAnnotationLayer).toHaveBeenCalled();
});

test('triggers addAnnotationLayer and close when ok button is clicked', async () => {
  const addAnnotationLayer = jest.fn();
  const close = jest.fn();
  await waitForRender({ name: 'Test', value: '2x', addAnnotationLayer, close });
  userEvent.click(screen.getByRole('button', { name: 'OK' }));
  expect(addAnnotationLayer).toHaveBeenCalled();
  expect(close).toHaveBeenCalled();
});

test('triggers close when cancel button is clicked', async () => {
  const close = jest.fn();
  await waitForRender({ close });
  userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
  expect(close).toHaveBeenCalled();
});

test('triggers removeAnnotationLayer and close when remove button is clicked', async () => {
  const removeAnnotationLayer = jest.fn();
  const close = jest.fn();
  await waitForRender({
    name: 'Test',
    value: '2x',
    removeAnnotationLayer,
    close,
  });
  userEvent.click(screen.getByRole('button', { name: 'Remove' }));
  expect(removeAnnotationLayer).toHaveBeenCalled();
  expect(close).toHaveBeenCalled();
});

test('fetches Superset annotation layer options', async () => {
  await waitForRender({
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
  });
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation source type' }),
  );
  userEvent.click(screen.getByText('Superset annotation'));
  expect(await screen.findByText('Annotation layer')).toBeInTheDocument();
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation layer value' }),
  );
  expect(await screen.findByText('Chart A')).toBeInTheDocument();
  expect(fetchMock.callHistory.calls(nativeLayerApiRoute).length).toBe(1);
});

test('fetches chart options', async () => {
  await waitForRender({
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
  });
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation source type' }),
  );
  userEvent.click(screen.getByText('Table'));
  expect(await screen.findByText('Chart')).toBeInTheDocument();
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation layer value' }),
  );
  expect(await screen.findByText('Chart A')).toBeInTheDocument();
  expect(fetchMock.callHistory.calls(chartApiRoute).length).toBe(1);
});

test('fetches chart on mount if value present', async () => {
  await waitForRender({
    name: 'Test',
    value: 'a',
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    sourceType: 'Table',
  });
  expect(fetchMock.callHistory.calls(chartApiWithIdRoute).length).toBe(1);
});

test('hides the Superset annotation source without annotation read access', async () => {
  await waitForRender({
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    canReadAnnotation: false,
  });
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation source type' }),
  );
  expect(await screen.findByText('Table')).toBeInTheDocument();
  expect(screen.queryByText('Superset annotation')).not.toBeInTheDocument();
});

test('keeps formula annotations available without annotation read access', async () => {
  await waitForRender({ canReadAnnotation: false });
  expect(screen.getByRole('textbox', { name: 'Formula' })).toBeInTheDocument();
});

test('keeps a saved native layer intact without annotation read access', async () => {
  const addAnnotationLayer = jest.fn();
  await waitForRender({
    name: 'Test',
    value: 1,
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    sourceType: 'NATIVE',
    canReadAnnotation: false,
    addAnnotationLayer,
  });

  // The saved source stays selected, and the value select is inert with an
  // explanation instead of surfacing a Forbidden error.
  expect(await screen.findByText('Superset annotation')).toBeInTheDocument();
  expect(
    screen.getByRole('combobox', { name: 'Annotation layer value' }),
  ).toBeDisabled();
  expect(
    screen.getByText("You don't have permission to view annotation layers."),
  ).toBeInTheDocument();

  // The saved reference is still valid: re-applying preserves it as is.
  userEvent.click(screen.getByRole('button', { name: 'Apply' }));
  expect(addAnnotationLayer).toHaveBeenCalledWith(
    expect.objectContaining({
      sourceType: 'NATIVE',
      value: 1,
    }),
  );

  // Neither the by-id fetch nor the listing may fire; both are known 403s.
  expect(fetchMock.callHistory.calls(nativeLayerApiRoute).length).toBe(0);
});

test('hydrates the applied native layer name for authorized users', async () => {
  // The show endpoint returns a single object, unlike the list mock.
  fetchMock.modifyRoute(nativeLayerRouteName, {
    response: { result: { id: 1, name: 'My layer' } },
  });

  try {
    await waitForRender({
      name: 'Test',
      value: 1,
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'NATIVE',
    });

    expect(await screen.findByText('My layer')).toBeInTheDocument();
    expect(fetchMock.callHistory.calls(nativeLayerApiRoute).length).toBe(1);
  } finally {
    fetchMock.modifyRoute(nativeLayerRouteName, {
      response: nativeLayerResult,
    });
  }
});

test('lets a saved native layer switch to a permitted source', async () => {
  await waitForRender({
    name: 'Test',
    value: 1,
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    sourceType: 'NATIVE',
    canReadAnnotation: false,
  });

  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation source type' }),
  );
  userEvent.click(await screen.findByText('Table'));

  // The chart selector takes over, enabled.
  expect(await screen.findByText('Chart')).toBeInTheDocument();
  expect(
    screen.getByRole('combobox', { name: 'Annotation layer value' }),
  ).toBeEnabled();

  // Reopen the source dropdown: it re-renders from the new options, and the
  // native option is gone for good.
  userEvent.click(
    screen.getByRole('combobox', { name: 'Annotation source type' }),
  );
  await waitFor(() =>
    expect(screen.queryByText('Superset annotation')).not.toBeInTheDocument(),
  );
});

test('survives a native annotation layer fetch that fails', async () => {
  const logError = jest.spyOn(logging, 'error').mockImplementation(() => {});
  fetchMock.modifyRoute(nativeLayerRouteName, { response: 403 });

  try {
    await waitForRender({
      name: 'Test',
      value: 1,
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'NATIVE',
    });

    expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
    await waitFor(() =>
      expect(logError).toHaveBeenCalledWith(
        expect.stringContaining('Failed to load annotation layer 1'),
        expect.anything(),
      ),
    );
  } finally {
    fetchMock.modifyRoute(nativeLayerRouteName, {
      response: nativeLayerResult,
    });
    logError.mockRestore();
  }
});

test('keeps apply disabled when missing required fields', async () => {
  // With EVENT type and Table source, the component requires selecting a chart
  // and filling in required fields. Without completing these, Apply should be disabled.
  await waitForRender({
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    sourceType: 'Table',
  });

  // Apply button should be disabled initially since required fields are not filled
  expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();

  // Select Chart A from the annotation layer value dropdown
  await selectOption('Chart A', 'Annotation layer value');

  // Wait for the chart data to load
  await screen.findByText(/title column/i);

  // Apply should still be disabled because name is not filled
  expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});

test('renders slice configuration for a chart that has no generated query context', async () => {
  // A chart never opened in Explore has `query_context: null`, so the columns
  // have to come from its saved `params`.
  fetchMock.modifyRoute(chartApiWithIdRouteName, {
    response: {
      result: {
        slice_name: 'Mocked Slice',
        params: JSON.stringify({ groupby: ['country'] }),
        query_context: null,
        viz_type: VizType.Line,
      },
    },
  });

  try {
    await waitForRender({
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'Table',
    });

    await selectOption('Chart A', 'Annotation layer value');

    expect(await screen.findByText(/title column/i)).toBeInTheDocument();

    // The column options come from the saved `params` form data.
    userEvent.click(
      screen.getByRole('combobox', { name: 'Annotation layer time column' }),
    );
    expect(await screen.findByTitle('country')).toBeInTheDocument();
  } finally {
    fetchMock.modifyRoute(chartApiWithIdRouteName, { response: withIdResult });
  }
});

test('renders slice configuration on mount for a chart with no generated query context', async () => {
  fetchMock.modifyRoute(chartApiWithIdRouteName, {
    response: {
      result: {
        slice_name: 'Mocked Slice',
        params: JSON.stringify({ groupby: ['country'] }),
        query_context: null,
        viz_type: VizType.Table,
      },
    },
  });

  try {
    await waitForRender({
      name: 'Test',
      value: 'a',
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'Table',
    });

    expect(await screen.findByText(/title column/i)).toBeInTheDocument();
  } finally {
    fetchMock.modifyRoute(chartApiWithIdRouteName, { response: withIdResult });
  }
});

test('survives a chart endpoint that fails', async () => {
  // The bug this replaces was an unhandled rejection that left the popover in a
  // half-populated state, so a failure has to stay contained and reported.
  const logError = jest.spyOn(logging, 'error').mockImplementation(() => {});
  fetchMock.modifyRoute(chartApiWithIdRouteName, { response: 500 });

  try {
    await waitForRender({
      name: 'Test',
      value: 'a',
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'Table',
    });

    expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
    await waitFor(() =>
      expect(logError).toHaveBeenCalledWith(
        expect.stringContaining('Failed to load annotation source chart a'),
        expect.anything(),
      ),
    );
    expect(screen.queryByText(/title column/i)).not.toBeInTheDocument();
  } finally {
    fetchMock.modifyRoute(chartApiWithIdRouteName, { response: withIdResult });
    logError.mockRestore();
  }
});

test('reports a chart that carries no form data at all', async () => {
  // Neither column is guaranteed: `query_context` is backfilled lazily and
  // `params` can be empty, and then there is nothing to build the fields from.
  const logWarn = jest.spyOn(logging, 'warn').mockImplementation(() => {});
  fetchMock.modifyRoute(chartApiWithIdRouteName, {
    response: {
      // `VizType.Table` is the registered one, so the chart clears the
      // annotation-type check and reaches the form data.
      result: {
        ...withIdResult.result,
        params: null,
        query_context: null,
        viz_type: VizType.Table,
      },
    },
  });

  try {
    await waitForRender({
      name: 'Test',
      value: 'a',
      annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
      sourceType: 'Table',
    });

    await waitFor(() =>
      expect(logWarn).toHaveBeenCalledWith(
        expect.stringContaining('has no usable form data'),
      ),
    );
    expect(screen.queryByText(/title column/i)).not.toBeInTheDocument();
  } finally {
    fetchMock.modifyRoute(chartApiWithIdRouteName, { response: withIdResult });
    logWarn.mockRestore();
  }
});

test('bounds the section row to the viewport so the footer stays reachable', async () => {
  // Adding the slice configuration section must not push the display
  // configuration or the Apply/OK buttons past the viewport.
  await waitForRender({
    annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
    sourceType: 'Table',
  });

  const sections = screen.getByTestId('annotation-layer-sections');
  expect(sections).toHaveStyle('flex-wrap: wrap');
  expect(sections).toHaveStyle('max-width: calc(100vw - 64px)');
  // The footer is a sibling of the sections, never inside the row.
  expect(sections).not.toContainElement(
    screen.getByRole('button', { name: 'Apply' }),
  );
});

test('caps the section row to the room left beside the control panel', async () => {
  // The cap is measured from the panel's edge, not the popover's own: the
  // popover moves as the row narrows, so measuring it feeds each cap into the
  // next one and converges on a column too narrow to lay the sections out in.
  setViewportWidth(1160);
  // Two sections at 238 need 508 to stay on one line, which is what fits beside
  // the panel here.
  mockLayout(() => 620, 238);

  try {
    await waitFor(() =>
      render(
        <>
          <div id="controlSections" />
          <div className="ant-popover">
            <AnnotationLayer
              {...defaultProps}
              annotationType={ANNOTATION_TYPES_METADATA.EVENT.value}
              sourceType="Table"
            />
          </div>
        </>,
      ),
    );

    // 1160 viewport - 620 panel - 24 popover padding - 8 inset
    expect(screen.getByTestId('annotation-layer-sections')).toHaveStyle(
      'max-width: 508px',
    );

    // A cap measured once would go stale across a resize. Here the room beside
    // the panel drops to 348px, too little to keep both sections on one line, so
    // the viewport becomes the bound: 1000 - 24 padding - 2 x 8 inset.
    setViewportWidth(1000);
    fireEvent(window, new Event('resize'));

    await waitFor(() =>
      expect(screen.getByTestId('annotation-layer-sections')).toHaveStyle(
        'max-width: 960px',
      ),
    );
  } finally {
    restoreViewportWidth();
    jest.restoreAllMocks();
  }
});

test('caps the section row again when the control panel is dragged wider', async () => {
  // Dragging the panel's resizer moves the edge the cap comes from without
  // resizing the window, and the jsdom ResizeObserver never calls back, so stand
  // in for it here and check that the panel is what gets watched.
  const observed: Element[] = [];
  let notifyResize = () => {};
  const NativeResizeObserver = window.ResizeObserver;
  window.ResizeObserver = class {
    constructor(callback: ResizeObserverCallback) {
      notifyResize = () => callback([], this as unknown as ResizeObserver);
    }

    observe(target: Element) {
      observed.push(target);
    }

    unobserve() {}

    disconnect() {}
  };

  setViewportWidth(1160);
  let panelWidth = 620;
  mockLayout(() => panelWidth, 238);

  try {
    render(
      <>
        <div id="controlSections" />
        <div className="ant-popover">
          <AnnotationLayer
            {...defaultProps}
            annotationType={ANNOTATION_TYPES_METADATA.EVENT.value}
            sourceType="Table"
          />
        </div>
      </>,
    );

    await waitFor(() =>
      expect(screen.getByTestId('annotation-layer-sections')).toHaveStyle(
        'max-width: 508px',
      ),
    );
    expect(observed).toContain(document.getElementById('controlSections'));

    // 228px is left beside the panel, too little to keep both sections on one
    // line, so the bound falls back to the viewport: 1160 - 24 - 2 x 8 inset.
    panelWidth = 900;
    act(() => notifyResize());

    await waitFor(() =>
      expect(screen.getByTestId('annotation-layer-sections')).toHaveStyle(
        'max-width: 1120px',
      ),
    );
  } finally {
    window.ResizeObserver = NativeResizeObserver;
    restoreViewportWidth();
    jest.restoreAllMocks();
  }
});

test('leaves the slice configuration section on one line rather than wrapping', async () => {
  // Wrapping the third section roughly doubles the popover's height - measured at
  // 620px of sections against 256px unwrapped - and a popover taller than the
  // viewport cannot be shifted back in, so the footer ends up below the fold.
  // Room beside the panel is preferred only while the row still fits on one line.
  setViewportWidth(1160);
  mockLayout(() => 620, 238);
  fetchMock.modifyRoute(chartApiWithIdRouteName, {
    response: {
      result: { ...withIdResult.result, viz_type: VizType.Table },
    },
  });

  try {
    render(
      <>
        <div id="controlSections" />
        <div className="ant-popover">
          <AnnotationLayer
            {...defaultProps}
            name="Test"
            value="a"
            annotationType={ANNOTATION_TYPES_METADATA.EVENT.value}
            sourceType="Table"
          />
        </div>
      </>,
    );

    // Three sections need 778px for one line and only 508px is free beside the
    // panel, so the viewport bounds it instead: 1160 - 24 padding - 2 x 8 inset.
    expect(await screen.findByText(/title column/i)).toBeInTheDocument();
    await waitFor(() =>
      expect(screen.getByTestId('annotation-layer-sections')).toHaveStyle(
        'max-width: 1120px',
      ),
    );
  } finally {
    fetchMock.modifyRoute(chartApiWithIdRouteName, { response: withIdResult });
    restoreViewportWidth();
    jest.restoreAllMocks();
  }
});

test('Disable apply button if formula is incorrect', async () => {
  await waitForRender({ name: 'test' });

  const formulaInput = screen.getByRole('textbox', { name: 'Formula' });
  const applyButton = screen.getByRole('button', { name: 'Apply' });
  const okButton = screen.getByRole('button', { name: 'OK' });

  userEvent.type(formulaInput, 'x+1');
  expect(formulaInput).toHaveValue('x+1');
  await waitFor(() => {
    expect(okButton).toBeEnabled();
    expect(applyButton).toBeEnabled();
  });

  userEvent.clear(formulaInput);
  await waitFor(() => {
    expect(formulaInput).toHaveValue('');
  });
  userEvent.type(formulaInput, 'y = x*2+1');
  expect(formulaInput).toHaveValue('y = x*2+1');
  await waitFor(() => {
    expect(okButton).toBeEnabled();
    expect(applyButton).toBeEnabled();
  });

  userEvent.clear(formulaInput);
  await waitFor(() => {
    expect(formulaInput).toHaveValue('');
  });
  userEvent.type(formulaInput, 'y+1');
  expect(formulaInput).toHaveValue('y+1');
  await waitFor(() => {
    expect(okButton).toBeDisabled();
    expect(applyButton).toBeDisabled();
  });

  userEvent.clear(formulaInput);
  await waitFor(() => {
    expect(formulaInput).toHaveValue('');
  });
  userEvent.type(formulaInput, 'x+');
  expect(formulaInput).toHaveValue('x+');
  await waitFor(() => {
    expect(okButton).toBeDisabled();
    expect(applyButton).toBeDisabled();
  });

  userEvent.clear(formulaInput);
  await waitFor(() => {
    expect(formulaInput).toHaveValue('');
  });
  userEvent.type(formulaInput, 'y = z+1');
  expect(formulaInput).toHaveValue('y = z+1');
  await waitFor(() => {
    expect(okButton).toBeDisabled();
    expect(applyButton).toBeDisabled();
  });
});
