diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdb37e867..55be96646 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -470,8 +470,17 @@ jobs: - if: ${{ matrix.target == 'Web' && matrix.browser != 'webkit' }} run: 'yarn workspace @getodk/web-forms test-browser:${{ matrix.browser }}' + # xvfb-run is used for visual tests to compare snapshots more accurately. - if: ${{ matrix.target == 'Web' }} - run: 'yarn workspace @getodk/web-forms test:e2e:${{ matrix.browser }}' + run: 'xvfb-run --auto-servernum yarn workspace @getodk/web-forms test:e2e:${{ matrix.browser }}' + + - name: 'Upload Playwright results' + if: always() && matrix.target == 'Web' + uses: actions/upload-artifact@v4 + with: + name: playwright-results-${{ matrix.browser }} + path: packages/web-forms/test-results + retention-days: 10 - if: ${{ matrix.node-version == '22.12.0' && matrix.target == 'Node' }} uses: actions/upload-artifact@v4 diff --git a/packages/common/src/fixtures/preview-service/xforms/cities.geojson b/packages/common/src/fixtures/preview-service/xforms/cities.geojson index e06d1e4eb..8516610c2 100644 --- a/packages/common/src/fixtures/preview-service/xforms/cities.geojson +++ b/packages/common/src/fixtures/preview-service/xforms/cities.geojson @@ -4,60 +4,48 @@ { "type": "Feature", "properties": { - "title": "Warsaw", - "info": "Capital city of Poland", + "title": "Last time red kangaroo was spotted", + "info": "A a large red kangaroo was observed at 10:45 AM", "id": "1" }, "geometry": { "type": "Point", - "coordinates": [ - 21.0122, - 52.2297 - ] + "coordinates": [134.795, -28.978] } }, { "type": "Feature", "properties": { - "title": "Berlin", - "info": "Capital city of Germany", + "title": "Red kangaroo feeding trail", + "info": "The trail that red kangaroos follow to reach their favorite spot food", "id": "2" }, "geometry": { - "type": "Point", + "type": "LineString", "coordinates": [ - 13.4050, - 52.5200 + [134.75, -28.981], + [134.76, -28.981], + [134.77, -28.981] ] } }, { "type": "Feature", "properties": { - "title": "Paris", - "info": "Capital city of France", + "title": "Red kangaroo resting area", + "info": "The area where kangaroos are typically found when resting", "id": "3" }, "geometry": { - "type": "Point", - "coordinates": [ - 2.3522, - 48.8566 - ] - } - }, - { - "type": "Feature", - "properties": { - "title": "Kyiv", - "info": "Capital city of Ukraine", - "id": "4" - }, - "geometry": { - "type": "Point", + "type": "Polygon", "coordinates": [ - 30.5234, - 50.4501 + [ + [134.77, -28.957], + [134.79, -28.957], + [134.79, -28.945], + [134.77, -28.945], + [134.77, -28.957] + ] ] } } diff --git a/packages/web-forms/e2e/page-objects/controls/MapControl.ts b/packages/web-forms/e2e/page-objects/controls/MapControl.ts new file mode 100644 index 000000000..0a805cc35 --- /dev/null +++ b/packages/web-forms/e2e/page-objects/controls/MapControl.ts @@ -0,0 +1,183 @@ +import { expect, Locator, Page } from '@playwright/test'; + +export class MapControl { + private readonly MAP_COMPONENT_SELECTOR = '.map-block-component'; + private readonly MAP_CONTAINER_SELECTOR = '.map-container'; + private readonly MAP_SELECTOR = '.map-block'; + private readonly ANIMATION_TIME = 1000; // Map has a default of 1s rendering and animation time + + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + getMapComponentLocator(label: string) { + const question = this.page + .locator('.question-container') + .filter({ has: this.page.locator(`.control-text label span:text-is("${label}")`) }); + return question.locator(this.MAP_COMPONENT_SELECTOR); + } + + async expectMapVisible(mapComponent: Locator) { + const map = mapComponent.locator(this.MAP_SELECTOR); + await expect(map, `Map not found`).toBeVisible(); + } + + /** + * Playwright's scrollIntoViewIfNeeded() scrolls minimally and doesn't guarantee to center + * the element, so JavaScript scroll is used to ensure the map is centered and fully visible. + */ + async scrollMapIntoView(mapComponent: Locator) { + const handle = await mapComponent.elementHandle(); + if (handle) { + await handle.evaluate((el) => el.scrollIntoView({ block: 'center' })); + } + } + + /** + * Pans the map starting from the center of the component. + * @param moveX Pixels. Relative to the map component center. + * @param moveY Pixels. Relative to the map component center. + */ + async panMap(mapComponent: Locator, moveX: number, moveY: number) { + await mapComponent.scrollIntoViewIfNeeded(); + const mapContainer = mapComponent.locator(this.MAP_CONTAINER_SELECTOR); + const box = await mapContainer.boundingBox(); + if (!box) { + return; + } + + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + await this.page.mouse.move(centerX, centerY); + await this.page.mouse.down(); + await this.page.mouse.move(centerX + moveX, centerY + moveY, { steps: 40 }); + + await this.page.mouse.up(); + } + + async zoomIn(mapComponent: Locator, times = 1) { + const button = mapComponent.locator('.ol-zoom').getByText('+', { exact: true }); + await expect(button).toBeVisible(); + for (let i = 0; i < times; i++) { + await button.click(); + // Allows reaching the expected zoom level before zooming again for consistent results. + await this.page.waitForTimeout(this.ANIMATION_TIME); + } + } + + async zoomOut(mapComponent: Locator, times = 1) { + const button = mapComponent.locator('.ol-zoom').getByText('–', { exact: true }); + await expect(button).toBeVisible(); + for (let i = 0; i < times; i++) { + await button.click(); + // Allows reaching the expected zoom level before zooming again for consistent results. + await this.page.waitForTimeout(this.ANIMATION_TIME); + } + } + + async zoomToFitAll(mapComponent: Locator) { + const button = mapComponent.locator('button[title="Zoom to fit all options"]'); + await expect(button).toBeVisible(); + await button.click(); + } + + async centerCurrentLocation(mapComponent: Locator) { + const button = mapComponent.locator('button[title="Zoom to current location"]'); + await expect(button).toBeVisible(); + await button.click(); + } + + async toggleFullScreen(mapComponent: Locator) { + const button = mapComponent.locator('button[title="Full Screen"]'); + await expect(button).toBeVisible(); + await button.click(); + } + + async expectFullScreenActive(mapComponent: Locator) { + await expect(mapComponent).toHaveAttribute('class', expect.stringContaining('map-full-screen')); + } + + async exitFullScreen(mapComponent: Locator) { + await this.page.keyboard.press('Escape'); + await expect(mapComponent.locator('.map-full-screen')).not.toBeVisible(); + } + + /** + * Selects feature on map + * @param positionX Pixels. Relative to the left of the browser's viewport. + * @param positionY Pixels. Relative to the top of the browser's viewport. + */ + async selectFeature(positionX: number, positionY: number) { + await this.page.mouse.move(positionX, positionY); + await this.page.mouse.down(); + await this.page.mouse.up(); + } + + async saveSelection(mapComponent: Locator) { + const button = mapComponent.locator('.map-properties').getByText('Save selected'); + await expect(button).toBeVisible(); + await button.click(); + } + + async removeSavedFeature(mapComponent: Locator) { + const button = mapComponent.locator('.map-properties').getByText('Remove selection'); + await expect(button).toBeVisible(); + await button.click(); + } + + async viewDetailsOfSavedFeature(mapComponent: Locator) { + const button = mapComponent.locator('.map-status-saved').getByText('View details'); + await expect(button).toBeVisible(); + await button.click(); + } + + async closeProperties(mapComponent: Locator) { + const button = mapComponent.locator('.map-properties .close-icon'); + await expect(button).toBeVisible(); + await button.click(); + await expect(mapComponent.locator('.map-properties')).not.toBeVisible(); + } + + async expectErrorMessage(mapComponent: Locator, expectedTitle: string, expectedMessage: string) { + const error = mapComponent.locator('.map-block-error'); + await expect(error.locator('strong')).toHaveText(expectedTitle); + await expect(error.locator('span')).toHaveText(expectedMessage); + } + + async expectMapScreenshot(mapComponent: Locator, snapshotName: string, isFullScreen = false) { + // It cannot disable map's JS animations. So setting timeout. + await this.page.waitForTimeout(this.ANIMATION_TIME); + + const map = mapComponent.locator(this.MAP_CONTAINER_SELECTOR); + const browserName = this.page.context().browser()?.browserType().name(); + const isChromiumLinux = process.platform === 'linux' && browserName === 'chromium'; + + if (isChromiumLinux) { + // Chrome for Linux has an issue when taking the snapshot (ref. https://github.com/microsoft/playwright/issues/18827) + const width = isFullScreen ? 1280 : 802; + const heigth = isFullScreen ? 720 : 507; + await this.page.addStyleTag({ + content: ` + .map-container { + width: ${width}px !important; + height: ${heigth}px !important; + max-width: ${width}px !important; + max-height: ${heigth + 1}px !important; + } + body, html { + overflow: hidden !important; + } + `, + }); + + await this.page.waitForTimeout(this.ANIMATION_TIME); + } + + await expect(map).toHaveScreenshot(snapshotName, { + maxDiffPixels: 5000, + }); + } +} diff --git a/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts b/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts index 50f0aafe2..5df403a21 100644 --- a/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts +++ b/packages/web-forms/e2e/page-objects/pages/FillFormPage.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test'; import { GeopointControl } from '../controls/GeopointControl.js'; import { InputControl } from '../controls/InputControl.js'; +import { MapControl } from '../controls/MapControl.js'; import { RepeatControl } from '../controls/RepeatControl.js'; import { TextControl } from '../controls/TextControl.js'; @@ -8,17 +9,19 @@ export class FillFormPage { private readonly page: Page; public readonly geopoint: GeopointControl; + public readonly input: InputControl; + public readonly map: MapControl; public readonly repeat: RepeatControl; public readonly text: TextControl; - public readonly input: InputControl; constructor(page: Page) { this.page = page; this.geopoint = new GeopointControl(page); + this.input = new InputControl(page); + this.map = new MapControl(page); this.repeat = new RepeatControl(page); this.text = new TextControl(page); - this.input = new InputControl(page); } async copyToClipboard(valueToCopy: string) { @@ -26,4 +29,8 @@ export class FillFormPage { return navigator.clipboard.writeText(value); }, valueToCopy); } + + async waitForNetworkIdle() { + return this.page.waitForLoadState('networkidle'); + } } diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts b/packages/web-forms/e2e/test-cases/all-question-types.test.ts index f092a5822..8ec83856d 100644 --- a/packages/web-forms/e2e/test-cases/all-question-types.test.ts +++ b/packages/web-forms/e2e/test-cases/all-question-types.test.ts @@ -1,4 +1,4 @@ -import { test } from '@playwright/test'; +import { Locator, test } from '@playwright/test'; import { BrowserContext } from 'playwright-core'; import { FillFormPage } from '../page-objects/pages/FillFormPage.ts'; import { PreviewPage } from '../page-objects/pages/PreviewPage.ts'; @@ -15,7 +15,15 @@ test.describe('All Question Types', () => { * Opens the form once and runs all test cases to optimize suite execution speed. */ test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); + const permissions = ['geolocation']; + if (browser.browserType().name() === 'chromium') { + permissions.push('clipboard-write'); + } + + context = await browser.newContext({ + geolocation: { latitude: -28.996, longitude: 134.762 }, // South Australia, + permissions: permissions, + }); const page = await context.newPage(); const previewPage = new PreviewPage(page); await previewPage.goToDevPage(); @@ -159,4 +167,110 @@ test.describe('All Question Types', () => { }); }); }); + + /** + * This is a slow suite; it waits for map animations and rendering to finish. + * If additional tests are included, we might run them only on one browser, on merge, or on request. + */ + test.describe('Select one from map', () => { + let mapComponent: Locator; + + test.beforeAll(async () => { + await formPage.waitForNetworkIdle(); + await formPage.text.expectHint( + 'select_one type with map appearance. Choices are loaded from a GeoJSON attachment' + ); + mapComponent = formPage.map.getMapComponentLocator('Map'); + }); + + test.beforeEach(async () => { + await formPage.waitForNetworkIdle(); + await formPage.map.expectMapVisible(mapComponent); + await formPage.map.scrollMapIntoView(mapComponent); + }); + + test('renders map, selects feature, and saves selection', async () => { + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-initial-state.png'); + + await formPage.map.selectFeature(700, 222); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-point-selected.png'); + + await formPage.map.saveSelection(mapComponent); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-point-saved.png'); + await formPage.map.closeProperties(mapComponent); + }); + + test('toggles full screen and verifies more surface map is visible', async () => { + await formPage.map.toggleFullScreen(mapComponent); + await formPage.map.expectFullScreenActive(mapComponent); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-full-screen.png', true); + await formPage.map.exitFullScreen(mapComponent); + }); + + test('zooms in and out, pans the map and zooms to fit all features', async () => { + await formPage.map.zoomOut(mapComponent, 2); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-zoom-out.png'); + await formPage.map.zoomIn(mapComponent, 3); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-zoom-in.png'); + await formPage.map.panMap(mapComponent, 300, -200); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-panning.png'); + await formPage.map.zoomToFitAll(mapComponent); + await formPage.map.expectMapScreenshot( + mapComponent, + 'select-map-zoom-to-fit-all-features.png' + ); + }); + + test('opens details of saved feature and remove saved feature', async () => { + await formPage.map.viewDetailsOfSavedFeature(mapComponent); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-view-details.png'); + await formPage.map.removeSavedFeature(mapComponent); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-removed-saved-feature.png'); + await formPage.map.closeProperties(mapComponent); + }); + + test('zooms to current location', async () => { + await formPage.waitForNetworkIdle(); + await formPage.map.scrollMapIntoView(mapComponent); + await formPage.map.centerCurrentLocation(mapComponent); + await formPage.map.expectMapScreenshot(mapComponent, 'select-map-zoom-current-location.png'); + }); + }); +}); + +test.describe('All Question Types - Geolocation permission denied', () => { + let formPage: FillFormPage; + let context: BrowserContext; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext({ + permissions: [], + }); + + const page = await context.newPage(); + const previewPage = new PreviewPage(page); + await previewPage.goToDevPage(); + + const newPage = await previewPage.openPublicDemoForm( + 'All question types', + 'All question types' + ); + formPage = new FillFormPage(newPage); + await formPage.waitForNetworkIdle(); + }); + + test.afterAll(async () => { + await context?.close(); + }); + + test('select from map displays error when zooming to current location', async () => { + const mapComponent = formPage.map.getMapComponentLocator('Map'); + await formPage.map.scrollMapIntoView(mapComponent); + await formPage.map.centerCurrentLocation(mapComponent); + await formPage.map.expectErrorMessage( + mapComponent, + 'Cannot access location', + 'Grant location permission in the browser settings and make sure location is turned on.' + ); + }); }); diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-chromium.png new file mode 100644 index 000000000..3ff50645e Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-firefox.png new file mode 100644 index 000000000..333350250 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-webkit.png new file mode 100644 index 000000000..26f8b9827 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-full-screen-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-chromium.png new file mode 100644 index 000000000..c1d75a2b4 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-firefox.png new file mode 100644 index 000000000..114a29a94 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-webkit.png new file mode 100644 index 000000000..5888ac324 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-initial-state-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-chromium.png new file mode 100644 index 000000000..2b9bf9dfa Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-firefox.png new file mode 100644 index 000000000..918cbeb1e Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-webkit.png new file mode 100644 index 000000000..0df8b9550 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-panning-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-chromium.png new file mode 100644 index 000000000..020c819da Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-firefox.png new file mode 100644 index 000000000..9b6c3d52d Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-webkit.png new file mode 100644 index 000000000..0b68b7b57 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-saved-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-chromium.png new file mode 100644 index 000000000..731a7da40 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-firefox.png new file mode 100644 index 000000000..f5f3ef9b7 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-webkit.png new file mode 100644 index 000000000..c08abfc5f Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-point-selected-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-chromium.png new file mode 100644 index 000000000..714af5a43 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-firefox.png new file mode 100644 index 000000000..0bf6fb864 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-webkit.png new file mode 100644 index 000000000..af1279c46 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-removed-saved-feature-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-chromium.png new file mode 100644 index 000000000..5cf8a3389 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-firefox.png new file mode 100644 index 000000000..6caa44e57 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-webkit.png new file mode 100644 index 000000000..8d8eb8d05 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-view-details-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-chromium.png new file mode 100644 index 000000000..3c577fc09 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-firefox.png new file mode 100644 index 000000000..a5219e951 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-webkit.png new file mode 100644 index 000000000..f63b3c50f Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-current-location-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-chromium.png new file mode 100644 index 000000000..55c3789c7 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-firefox.png new file mode 100644 index 000000000..fd9f00cbf Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-webkit.png new file mode 100644 index 000000000..52f9738c7 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-in-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-chromium.png new file mode 100644 index 000000000..bdc816635 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-firefox.png new file mode 100644 index 000000000..8522d95fe Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-webkit.png new file mode 100644 index 000000000..88ecf9abc Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-out-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-chromium.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-chromium.png new file mode 100644 index 000000000..a1317ef25 Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-firefox.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-firefox.png new file mode 100644 index 000000000..67294c59d Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-webkit.png b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-webkit.png new file mode 100644 index 000000000..25ccb1c0b Binary files /dev/null and b/packages/web-forms/e2e/test-cases/all-question-types.test.ts-snapshots/select-map-zoom-to-fit-all-features-webkit.png differ diff --git a/packages/web-forms/playwright.config.ts b/packages/web-forms/playwright.config.ts index b8e9314a8..9946d966b 100644 --- a/packages/web-forms/playwright.config.ts +++ b/packages/web-forms/playwright.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', + snapshotPathTemplate: + '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ @@ -39,8 +41,8 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - /* Only on CI systems run the tests headless */ - headless: !!process.env.CI, + // Turning off headless for visual tests (to compare snapshots) in CI + headless: false, }, /* Configure projects for major browsers */ @@ -53,6 +55,9 @@ export default defineConfig({ //Chrome-specific permissions for test cases requiring copy/paste actions permissions: ['clipboard-read', 'clipboard-write'], }, + launchOptions: { + args: ['--ignore-gpu-blocklist'], // This argument is needed for visual tests on CI's Linux Chrome. + }, }, }, { diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue index b49689d27..cac6e5e87 100644 --- a/packages/web-forms/src/components/common/map/AsyncMap.vue +++ b/packages/web-forms/src/components/common/map/AsyncMap.vue @@ -6,20 +6,11 @@ */ import type { SelectItem } from '@getodk/xforms-engine'; import ProgressSpinner from 'primevue/progressspinner'; -import { computed, type DefineComponent, onMounted, shallowRef, ref } from 'vue'; - -type Coordinates = [longitude: number, latitude: number]; - -interface Geometry { - type: 'LineString' | 'Point' | 'Polygon'; - coordinates: Coordinates | Coordinates[] | Coordinates[][]; -} - -interface Feature { - type: 'Feature'; - geometry: Geometry; - properties: Record; -} +import { computed, type DefineComponent, onMounted, shallowRef } from 'vue'; +import { + createFeatureCollectionAndProps, + type Feature, +} from '@/components/common/map/createFeatureCollectionAndProps.ts'; type MapBlockComponent = DefineComponent<{ featureCollection: { type: string; features: Feature[] }; @@ -44,101 +35,9 @@ const STATES = { ERROR: 'error', } as const; -const PROPERTY_PREFIX = 'odk_'; // Avoids conflicts with OpenLayers (for example, geometry). -const RESERVED_MAP_PROPERTIES = [ - 'itextId', - 'geometry', - 'marker-color', - 'marker-symbol', - 'stroke', - 'stroke-width', - 'fill', -]; - const mapComponent = shallowRef(null); const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING); -const orderedExtraPropsMap = ref>>(new Map()); - -const featureCollection = computed(() => { - orderedExtraPropsMap.value.clear(); - const features: Feature[] = []; - - props.features?.forEach((option) => { - const orderedProps: Array<[string, string]> = []; - const reservedProps: Record = { - [PROPERTY_PREFIX + 'label']: option.label?.asString, - [PROPERTY_PREFIX + 'value']: option.value, - }; - - option.properties.forEach(([key, value]) => { - if (RESERVED_MAP_PROPERTIES.includes(key)) { - reservedProps[PROPERTY_PREFIX + key] = value.trim(); - } else { - orderedProps.push([key, value.trim()]); - } - }); - - orderedExtraPropsMap.value.set(option.value, orderedProps); - - const geometry = reservedProps[PROPERTY_PREFIX + 'geometry']; - if (!geometry?.length) { - // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. - console.warn(`Missing or empty geometry for option: ${option.value}`); - return; - } - - const geoJSONCoords = getGeoJSONCoordinates(geometry); - if (!geoJSONCoords?.length) { - // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. - console.warn(`Missing geo points for option: ${option.value}`); - return; - } - - features.push({ - type: 'Feature', - geometry: getGeoJSONGeometry(geoJSONCoords), - properties: reservedProps, - }); - }); - - return { type: 'FeatureCollection', features }; -}); - -const getGeoJSONCoordinates = (geometry: string) => { - const coordinates: Coordinates[] = []; - for (const coord of geometry.split(';')) { - const [lat, lon] = coord.trim().split(/\s+/).map(Number); - - const isNullLocation = lat === 0 && lon === 0; - const isValidLatitude = lat != null && !Number.isNaN(lat) && Math.abs(lat) <= 90; - const isValidLongitude = lon != null && !Number.isNaN(lon) && Math.abs(lon) <= 180; - - if (isNullLocation || !isValidLatitude || !isValidLongitude) { - // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. - console.warn(`Invalid geo point coordinates: ${geometry}`); - return; - } - - coordinates.push([lon, lat]); - } - - return coordinates; -}; - -const getGeoJSONGeometry = (coords: Coordinates[]): Geometry => { - if (coords.length === 1) { - return { type: 'Point', coordinates: coords[0] }; - } - - const [firstLongitude, firstLatitude] = coords[0]; - const [lastLongitude, lastLatitude] = coords[coords.length - 1]; - - if (firstLongitude === lastLongitude && firstLatitude === lastLatitude) { - return { type: 'Polygon', coordinates: [coords] }; - } - - return { type: 'LineString', coordinates: coords }; -}; +const featureCollectionAndProps = computed(() => createFeatureCollectionAndProps(props.features)); const loadMap = async () => { currentState.value = STATES.LOADING; @@ -174,8 +73,8 @@ onMounted(loadMap); ; +} + +const getGeoJSONCoordinates = (geometry: string) => { + const coordinates: Coordinates[] = []; + for (const coord of geometry.split(';')) { + const [lat, lon] = coord.trim().split(/\s+/).map(Number); + + const isNullLocation = lat === 0 && lon === 0; + const isValidLatitude = lat != null && !Number.isNaN(lat) && Math.abs(lat) <= 90; + const isValidLongitude = lon != null && !Number.isNaN(lon) && Math.abs(lon) <= 180; + + if (isNullLocation || !isValidLatitude || !isValidLongitude) { + // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. + console.warn(`Invalid geo point coordinates: ${geometry}`); + return; + } + + coordinates.push([lon, lat]); + } + + return coordinates; +}; + +const getGeoJSONGeometry = (coords: Coordinates[]): Geometry => { + if (coords.length === 1) { + return { type: 'Point', coordinates: coords[0] }; + } + + const [firstLongitude, firstLatitude] = coords[0]; + const [lastLongitude, lastLatitude] = coords[coords.length - 1]; + + if (firstLongitude === lastLongitude && firstLatitude === lastLatitude) { + return { type: 'Polygon', coordinates: [coords] }; + } + + return { type: 'LineString', coordinates: coords }; +}; + +export const createFeatureCollectionAndProps = (odkFeatures: readonly SelectItem[] | undefined) => { + const orderedExtraPropsMap = new Map>(); + const features: Feature[] = []; + + odkFeatures?.forEach((option) => { + const orderedProps: Array<[string, string]> = []; + const reservedProps: Record = { + [PROPERTY_PREFIX + 'label']: option.label?.asString, + [PROPERTY_PREFIX + 'value']: option.value, + }; + + option.properties.forEach(([key, value]) => { + if (RESERVED_MAP_PROPERTIES.includes(key)) { + reservedProps[PROPERTY_PREFIX + key] = value.trim(); + } else { + orderedProps.push([key, value.trim()]); + } + }); + + orderedExtraPropsMap.set(option.value, orderedProps); + + const geometry = reservedProps[PROPERTY_PREFIX + 'geometry']; + if (!geometry?.length) { + // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. + console.warn(`Missing or empty geometry for option: ${option.value}`); + return; + } + + const geoJSONCoords = getGeoJSONCoordinates(geometry); + if (!geoJSONCoords?.length) { + // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. + console.warn(`Missing geo points for option: ${option.value}`); + return; + } + + features.push({ + type: 'Feature', + geometry: getGeoJSONGeometry(geoJSONCoords), + properties: reservedProps, + }); + }); + + return { + featureCollection: { type: 'FeatureCollection', features }, + orderedExtraPropsMap, + }; +}; diff --git a/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts b/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts new file mode 100644 index 000000000..cecae6264 --- /dev/null +++ b/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts @@ -0,0 +1,504 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createFeatureCollectionAndProps } from '@/components/common/map/createFeatureCollectionAndProps'; +import type { SelectItem } from '@getodk/xforms-engine'; + +describe('createFeatureCollectionAndProps', () => { + const createSelectItem = ( + value: string, + label: string | null, + geometry: string, + properties: Array<[string, string]> = [] + ): SelectItem => { + return { + value, + // @ts-expect-error light typing for test purposes + label: { asString: label ?? '' }, + properties: [['geometry', geometry], ...properties], + }; + }; + + it('converts a single ODK point to a GeoJSON Point feature', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', [ + ['marker-color', '#ff0000'], + ]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + 'odk_marker-color': '#ff0000', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]])); + }); + + it('converts an ODK trace to a GeoJSON LineString feature', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('trace1', 'Trace 1', '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5', [ + ['stroke', '#0000ff'], + ]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-74.006, 40.7128], + [-74.0061, 40.7129], + ], + }, + properties: { + odk_label: 'Trace 1', + odk_value: 'trace1', + odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5', + odk_stroke: '#0000ff', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['trace1', []]])); + }); + + it('converts an ODK shape to a GeoJSON Polygon feature', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem( + 'shape1', + 'Shape 1', + '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5', + [['fill', '#00ff00']] + ), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-74.006, 40.7128], + [-74.0061, 40.7129], + [-74.006, 40.7128], + ], + ], + }, + properties: { + odk_label: 'Shape 1', + odk_value: 'shape1', + odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5', + odk_fill: '#00ff00', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['shape1', []]])); + }); + + it('skips features with invalid ODK geometry and logs warning', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const odkFeatures: SelectItem[] = [ + createSelectItem('invalid1', 'Invalid 1', 'invalid-geometry', []), + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', []), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + }, + }, + ], + }); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid geo point coordinates: invalid-geometry') + ); + expect(orderedExtraPropsMap).toEqual( + new Map([ + ['invalid1', []], + ['point1', []], + ]) + ); + }); + + it('skips features with empty ODK geometry and logs warning', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const odkFeatures: SelectItem[] = [ + createSelectItem('empty1', 'Empty 1', '', [['marker-color', '#ff0000']]), + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', []), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + }, + }, + ], + }); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Missing or empty geometry for option: empty1') + ); + expect(orderedExtraPropsMap).toEqual( + new Map([ + ['empty1', []], + ['point1', []], + ]) + ); + }); + + it('handles undefined odkFeatures input', () => { + const { featureCollection, orderedExtraPropsMap } = createFeatureCollectionAndProps(undefined); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [], + }); + expect(orderedExtraPropsMap).toEqual(new Map()); + }); + + it('handles empty odkFeatures array', () => { + const odkFeatures: SelectItem[] = []; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [], + }); + expect(orderedExtraPropsMap).toEqual(new Map()); + }); + + it('handles non-reserved properties in orderedExtraPropsMap', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', [ + ['marker-color', '#ff0000'], + ['custom-prop', 'value1'], + ['another-prop', 'value2'], + ]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + 'odk_marker-color': '#ff0000', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual( + new Map([ + [ + 'point1', + [ + ['custom-prop', 'value1'], + ['another-prop', 'value2'], + ], + ], + ]) + ); + }); + + it('handles null label in ODK features', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', null, '40.7128 -74.0060 100 5', [['marker-color', '#ff0000']]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: '', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + 'odk_marker-color': '#ff0000', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]])); + }); + + it('handles invalid ODK coordinates (out of range)', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const odkFeatures: SelectItem[] = [ + createSelectItem('invalid1', 'Invalid 1', '100 -200 100 5', []), + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', []), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + }, + }, + ], + }); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid geo point coordinates: 100 -200 100 5') + ); + expect(orderedExtraPropsMap).toEqual( + new Map([ + ['invalid1', []], + ['point1', []], + ]) + ); + }); + + it('handles partial ODK point format (missing altitude/accuracy)', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', 'Point 1', '40.7128 -74.0060', [['marker-color', '#ff0000']]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060', + 'odk_marker-color': '#ff0000', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]])); + }); + + it('handles ODK point with extra whitespace', () => { + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', 'Point 1', ' 40.7128 -74.0060 100 5 ', [ + ['marker-color', '#ff0000'], + ]), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + 'odk_marker-color': '#ff0000', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]])); + }); + + it('converts multiple ODK features (Point, LineString, Polygon, invalid) to a GeoJSON FeatureCollection', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const odkFeatures: SelectItem[] = [ + createSelectItem('point1', 'Point 1', '40.7128 -74.0060 100 5', [ + ['marker-color', '#ff0000'], + ['custom-prop', 'value1'], + ]), + createSelectItem('trace1', 'Trace 1', '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5', [ + ['stroke', '#0000ff'], + ]), + createSelectItem( + 'shape1', + 'Shape 1', + '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5', + [ + ['fill', '#00ff00'], + ['another-prop', 'value2'], + ] + ), + createSelectItem('invalid1', 'Invalid 1', 'invalid-geometry', []), + ]; + + const { featureCollection, orderedExtraPropsMap } = + createFeatureCollectionAndProps(odkFeatures); + + expect(featureCollection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128], + }, + properties: { + odk_label: 'Point 1', + odk_value: 'point1', + odk_geometry: '40.7128 -74.0060 100 5', + 'odk_marker-color': '#ff0000', + }, + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-74.006, 40.7128], + [-74.0061, 40.7129], + ], + }, + properties: { + odk_label: 'Trace 1', + odk_value: 'trace1', + odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5', + odk_stroke: '#0000ff', + }, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-74.006, 40.7128], + [-74.0061, 40.7129], + [-74.006, 40.7128], + ], + ], + }, + properties: { + odk_label: 'Shape 1', + odk_value: 'shape1', + odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5', + odk_fill: '#00ff00', + }, + }, + ], + }); + + expect(orderedExtraPropsMap).toEqual( + new Map([ + ['point1', [['custom-prop', 'value1']]], + ['trace1', []], + ['shape1', [['another-prop', 'value2']]], + ['invalid1', []], + ]) + ); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid geo point coordinates: invalid-geometry') + ); + }); +});