diff --git a/jest.config.cjs b/jest.config.cjs index b9c687d5e7..a1cf716be6 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -21,7 +21,7 @@ module.exports = { ], transformIgnorePatterns: [ 'node_modules/(?!(ol|@camptocamp/inkmap|@terrestris/*[a-z]*-util|d3-selection|color-*[a-z]*)|(rc-*[a-z]*)|' + - 'filter-obj|query-string|decode-uri-component|split-on-first|shpjs/|rbush|quickselect|geostyler-openlayers-parser|' + + 'filter-obj|query-string|decode-uri-component|split-on-first|shpjs/|rbush|quickselect|geostyler-openlayers-parser|ol-mapbox-style|pbf|' + 'geostyler-style|geotiff|quick-lru|quickselect|jsts)' ], setupFiles: [ @@ -36,7 +36,12 @@ module.exports = { }, collectCoverageFrom: [ 'src/**/*.{ts,tsx}', - '!src/**/*example*.*' + '!src/**/*example*.*', + '!src/**/*.d.ts', + '!src/index.ts', + '!src/Context/MapContext/MapContext.tsx', // only a placeholder + '!src/Hook/useDropTargetMap.ts', // only a placeholder + '!src/Hook/useMap.ts' // only a placeholder ], coverageDirectory: '/coverage', testEnvironment: 'jsdom', diff --git a/src/BackgroundLayerChooser/BackgroundLayerChooser.spec.tsx b/src/BackgroundLayerChooser/BackgroundLayerChooser.spec.tsx new file mode 100644 index 0000000000..9d7351fbf5 --- /dev/null +++ b/src/BackgroundLayerChooser/BackgroundLayerChooser.spec.tsx @@ -0,0 +1,189 @@ +import * as React from 'react'; + +import Map from 'ol/Map'; +import View from 'ol/View'; +import TileLayer from 'ol/layer/Tile'; +import OSM from 'ol/source/OSM' +// import TileWMS from 'ol/source/TileWMS'; + +import { + renderInMapContext +} from '@terrestris/react-util/dist/Util/rtlTestUtils'; + +import BackgroundLayerChooser from './BackgroundLayerChooser'; +import { act, fireEvent, waitFor } from '@testing-library/react'; + +describe('', () => { + + let map: Map; + let layers: TileLayer[]; + + function createLayers() { + return [ + new TileLayer({ + source: new OSM(), + properties: { + name: 'OSM', + isBackgroundLayer: true + } + }), + new TileLayer({ + source: new OSM(), + properties: { + name: 'OSM2', + isBackgroundLayer: true + } + }) + ]; + } + + beforeEach(() => { + layers = createLayers(); + map = new Map({ + view: new View({ + center: [0, 0], + zoom: 1 + }), + layers + }); + }); + + + describe('#Basics', () => { + it('is defined', () => { + expect(BackgroundLayerChooser).not.toBeUndefined(); + }); + + it('can be rendered', () => { + const { container } = renderInMapContext( + map, + + ); + expect(container.querySelector('.bg-layer-chooser')).toBeInTheDocument(); + }); + }); + + it('shows layer cards when button is clicked', async () => { + const { container } = renderInMapContext( + map, + + ); + const btn = container.querySelector('.change-bg-btn'); + await act(async () => { + btn && fireEvent.click(btn); + }); + await waitFor(() => { + expect(container.querySelector('.layer-cards')).toBeInTheDocument(); + expect(container.querySelectorAll('.layer-preview').length).toBeGreaterThan(0); + }); + }); + + it('calls titleRenderer for each layer', async () => { + const titleRenderer = jest.fn(l => Layer: {l.get('name')}); + const { container } = renderInMapContext( + map, + + ); + const btn = container.querySelector('.change-bg-btn'); + await act(async () => { + btn && fireEvent.click(btn); + }); + await waitFor(() => { + expect(titleRenderer).toHaveBeenCalled(); + }); + }); + + it('shows no background option and selects it', async () => { + const { container } = renderInMapContext( + map, + + ); + const btn = container.querySelector('.change-bg-btn'); + await act(async () => { + btn && fireEvent.click(btn); + }); + const noBg = await waitFor(() => container.querySelector('.no-background')); + expect(noBg).toBeInTheDocument(); + await act(async () => { + noBg && fireEvent.click(noBg); + }); + await waitFor(() => { + expect(container.querySelector('.bg-preview .layer-title')?.textContent).toBe('None'); + }); + }); + + it('selects a background layer when preview is clicked', async () => { + const { container } = renderInMapContext( + map, + + ); + const btn = container.querySelector('.change-bg-btn'); + await act(async () => { + btn && fireEvent.click(btn); + }); + const previews = await waitFor(() => container.querySelectorAll('.layer-preview')); + expect(previews.length).toBeGreaterThan(0); + await act(async () => { + previews[0] && fireEvent.click(previews[0]); + }); + await waitFor(() => { + expect(container.querySelector('.bg-preview .layer-title')?.textContent).toBe('OSM'); + }); + }); + + it('respects initiallySelectedLayer prop', async () => { + // Do not set allowEmptyBackground, so 'No Background' is not selected + const { container } = renderInMapContext( + map, + + ); + await waitFor(() => { + expect(container.querySelector('.bg-preview .layer-title')?.textContent).toBe('OSM2'); + }); + }); + + it('filters layers using backgroundLayerFilter', async () => { + const filter = (l: any) => l.get('name') === 'OSM2'; + const { container } = renderInMapContext( + map, + + ); + const btn = container.querySelector('.change-bg-btn'); + await act(async () => { + btn && fireEvent.click(btn); + }); + await waitFor(() => { + // Only OSM2 should be present + const previews = container.querySelectorAll('.layer-preview'); + expect(previews.length).toBe(1); + const title = (previews[0] as Element).querySelector('.layer-title')?.textContent; + expect(title).toBe('OSM2'); + }); + }); + +}); diff --git a/src/BackgroundLayerChooser/BackgroundLayerChooser.tsx b/src/BackgroundLayerChooser/BackgroundLayerChooser.tsx index e3823b7e02..2bea6b7cd8 100644 --- a/src/BackgroundLayerChooser/BackgroundLayerChooser.tsx +++ b/src/BackgroundLayerChooser/BackgroundLayerChooser.tsx @@ -115,9 +115,10 @@ export const BackgroundLayerChooser: React.FC = ({ }, [map, layerOptionsVisible]); useEffect(() => { - const activeLayerCand = layers.find(l => l.getVisible()); - - if (!initiallySelectedLayer) { + if (initiallySelectedLayer) { + setSelectedLayer(initiallySelectedLayer); + } else { + const activeLayerCand = layers.find(l => l.getVisible()); setSelectedLayer(activeLayerCand as OlLayer); } }, [initiallySelectedLayer, layers]); @@ -227,7 +228,7 @@ export const BackgroundLayerChooser: React.FC = ({ layerOptionsVisible && (
{ - layers.map(layer => ( + layers.filter(backgroundLayerFilter).map(layer => ( { + + function createLayer(name = 'Layer', visible = true) { + const layer = new OlLayerTile({ source: new OSM() }); + layer.set('name', name); + layer.setVisible(visible); + return layer; + } + + function createMap(layers: any[]) { + return new Map({ + view: new View({ center: [0, 0], zoom: 1 }), + layers + }); + } + + it('renders without crashing', () => { + const layer = createLayer(); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer]); + renderInMapContext( + map, + + ); + expect(document.querySelector('.layer-preview')).toBeInTheDocument(); + }); + + it('renders the title from layer name', () => { + const layer = createLayer('foo-bar-baz'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer]); + const { getByText } = renderInMapContext( + map, + + ); + expect(getByText('foo-bar-baz')).toBeInTheDocument(); + }); + + it('renders the title using titleRenderer', () => { + const layer = createLayer('foo-bar-baz'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const titleRenderer = (l: any) => Custom: {l.get('name')}; + const map = createMap([layer]); + const { getByText } = renderInMapContext( + map, + + ); + expect(getByText('Custom: foo-bar-baz')).toBeInTheDocument(); + }); + + it('applies selected class if activeLayer matches', () => { + const layer = createLayer('ActiveLayer'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer]); + const { container } = renderInMapContext( + map, + + ); + expect(container.querySelector('.layer-preview.selected')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', () => { + const layer = createLayer('Clickable'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer]); + const { container } = renderInMapContext( + map, + + ); + const preview = container.querySelector('.layer-preview'); + fireEvent.click(preview!); + expect(onClick).toHaveBeenCalled(); + }); + + it('handles mouse over and leave events', () => { + const layer = createLayer('Hoverable'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer]); + const { container } = renderInMapContext( + map, + + ); + const preview = container.querySelector('.layer-preview'); + fireEvent.mouseOver(preview!); + fireEvent.mouseLeave(preview!); + // No error = pass + expect(preview).toBeInTheDocument(); + }); + + it('renders with an Image layer', () => { + // Use a minimal valid Image source for OlLayerImage + const layer = new OlLayerImage({ + source: new ImageStatic({ + url: '', + imageExtent: [0, 0, 1, 1], + projection: 'EPSG:3857' + }) + }); + layer.set('name', 'ImageLayer'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([layer as any]); + const { getByText } = renderInMapContext( + map, + + ); + expect(getByText('ImageLayer')).toBeInTheDocument(); + }); + + it('handles backgroundLayerFilter returning false', () => { + const layer = createLayer('FilteredOut'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => false; + const map = createMap([layer]); + const { getByText } = renderInMapContext( + map, + + ); + expect(getByText('FilteredOut')).toBeInTheDocument(); + }); + + it('handles a layer that is not Tile or Image', () => { + // Use a LayerGroup as a non-Tile/Image layer + const group = new OlLayerGroup(); + group.set('name', 'GroupLayer'); + const onClick = jest.fn(); + const backgroundLayerFilter = () => true; + const map = createMap([group as any]); + const { getByText } = renderInMapContext( + map, + + ); + expect(getByText('GroupLayer')).toBeInTheDocument(); + }); +}); diff --git a/src/Button/DrawCutButton/DrawCutButton.spec.tsx b/src/Button/DrawCutButton/DrawCutButton.spec.tsx new file mode 100644 index 0000000000..139470df7d --- /dev/null +++ b/src/Button/DrawCutButton/DrawCutButton.spec.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { renderInMapContext } from '@terrestris/react-util/dist/Util/rtlTestUtils'; +import { within } from '@testing-library/react'; +import OlMap from 'ol/Map'; +import OlView from 'ol/View'; +import OlVectorLayer from 'ol/layer/Vector'; +import OlVectorSource from 'ol/source/Vector'; +import OlFeature from 'ol/Feature'; +import OlPoint from 'ol/geom/Point'; + +import DrawCutButton from './DrawCutButton'; + +describe('', () => { + let map: OlMap; + let digitizeLayer: OlVectorLayer; + let feature: OlFeature; + const coord = [829729, 6708850]; + + beforeEach(() => { + digitizeLayer = new OlVectorLayer({ + source: new OlVectorSource() + }); + map = new OlMap({ + view: new OlView({ + center: coord, + zoom: 10 + }), + controls: [], + layers: [digitizeLayer] + }); + feature = new OlFeature({ + geometry: new OlPoint(coord), + someProp: 'test' + }); + digitizeLayer.getSource()?.addFeature(feature); + }); + + describe('#Basics', () => { + it('is defined', () => { + expect(DrawCutButton).toBeDefined(); + }); + + it('can be rendered', () => { + const { container } = renderInMapContext(map, ); + const button = within(container).getByRole('button'); + expect(button).toBeVisible(); + }); + + it('applies custom className', () => { + const { container } = renderInMapContext(map, ); + expect(container.querySelector('.my-class')).toBeInTheDocument(); + }); + }); + +}); diff --git a/src/FeatureLabelModal/FeatureLabelModal.spec.tsx b/src/FeatureLabelModal/FeatureLabelModal.spec.tsx new file mode 100644 index 0000000000..f12713a01c --- /dev/null +++ b/src/FeatureLabelModal/FeatureLabelModal.spec.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Feature from 'ol/Feature'; +import Point from 'ol/geom/Point'; +import FeatureLabelModal from './FeatureLabelModal'; + +describe('', () => { + let feature: Feature; + + beforeEach(() => { + feature = new Feature({ + geometry: new Point([0, 0]), + label: 'Initial label' + }); + }); + + it('renders nothing if no feature is given', () => { + // @ts-expect-error purposely passing undefined + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders modal and textarea with feature label', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('Initial label'); + }); + + it('calls onCancel when cancel is clicked', () => { + const onCancel = jest.fn(); + render(); + // AntD Modal renders a button with aria-label 'Close' for cancel + const cancelBtn = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelBtn); + expect(onCancel).toHaveBeenCalled(); + }); + + it('calls onOk and sets label when ok is clicked', () => { + const onOk = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'New label' } }); + const okBtn = screen.getByRole('button', { name: /ok/i }); + fireEvent.click(okBtn); + expect(onOk).toHaveBeenCalled(); + expect(feature.get('label')).toBe('New label'); + }); + + it('splits label into lines if maxLabelLineLength is set', () => { + const onOk = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: '1234567890' } }); + const okBtn = screen.getByRole('button', { name: /ok/i }); + fireEvent.click(okBtn); + // the implementation is a bit surprising, but this is it rght now + expect(feature.get('label')).toBe('123456-\n7890'); + }); +}); diff --git a/src/Field/SearchField/SearchField.spec.tsx b/src/Field/SearchField/SearchField.spec.tsx new file mode 100644 index 0000000000..a1643244e0 --- /dev/null +++ b/src/Field/SearchField/SearchField.spec.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Geometry, Feature } from 'geojson'; +import SearchField from './SearchField'; + +const features: Feature>[] = [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { name: 'A' } + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [3, 4] }, + properties: { name: 'B' } + } +]; + +const mockSearchFunction = jest.fn(async (term: string) => { + if (!term) return { type: "FeatureCollection" as const, features: [] }; + return { + type: "FeatureCollection" as const, + features: features.filter(f => f.properties?.name?.toLowerCase().includes(term.toLowerCase())) + }; +}); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders input and allows typing', () => { + render(); + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + fireEvent.change(input, { target: { value: 'MJMJ' } }); + expect(input).toHaveValue('MJMJ'); + }); + + it('disables autocomplete popup if autoCompleteDisabled is true', async () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'A' } }); + // Wait a bit to ensure popup would have rendered if enabled + await new Promise(res => setTimeout(res, 300)); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + }); +});