// @ts-check // Tests: post form "More location details" — search-by-city lookup + draggable // map pin preview for setting an entry's coordinates without live GPS. // Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route() // so this suite is hermetic (no live third-party call, no rate-limit flakiness). const { test, expect } = require('@playwright/test'); const path = require('path'); const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers'); const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**'; const created = []; test.afterAll(() => { created.forEach(cleanupEntry); }); // Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com). const KYOTO_RESULTS = { results: [ { name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' } ] }; // Mirrors the design doc's verified live Paris query: Île-de-France (France) // first from the API, then five US states — Texas among them, in admin1 (the // API's `country` field is "United States" for all of the US matches, so the // ranking must also check admin1 to disambiguate on a US state name). const PARIS_RESULTS = { results: [ { name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' }, { name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' }, { name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' }, { name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' }, { name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' } ] }; function mockGeocode(page, body) { return page.route(GEOCODE_URL, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })); } async function openLocationDetails(page) { await page.locator('.location-details__summary').click(); await expect(page.locator('.location-details')).toHaveJSProperty('open', true); } // ── Panel closed by default (R1) ──────────────────────────────────────────── test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => { await page.goto('/post'); const details = page.locator('.location-details'); await expect(details).toBeAttached(); await expect(details).toHaveJSProperty('open', false); await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached(); await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached(); }); // ── R6: empty City + Country sends no request ─────────────────────────────── test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => { await page.goto('/post'); let requested = false; await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); }); await openLocationDetails(page); await page.click('#lookup-coords'); await expect(page.locator('#location-search-hint')).toContainText(/city or country/i); expect(requested).toBe(false); }); // ── R7: a search result sets lat/lng only, never City/Country ────────────── test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => { await page.goto('/post'); await mockGeocode(page, KYOTO_RESULTS); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); const results = page.locator('.location-search-results li button'); await expect(results).toHaveCount(1); await results.first().click(); await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600'); await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100'); await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto'); await expect(page.locator('input[name="data[location_country]"]')).toHaveValue(''); // R7: the list hides again until the next lookup. await expect(page.locator('.location-search-results li')).toHaveCount(0); }); // ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ───────── test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => { await page.goto('/post'); let requestedUrl = null; await page.route(GEOCODE_URL, (route) => { requestedUrl = route.request().url(); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) }); }); await page.fill('input[name="data[location_city]"]', 'Paris'); await page.fill('input[name="data[location_country]"]', 'Texas'); await openLocationDetails(page); await page.click('#lookup-coords'); const results = page.locator('.location-search-results li button'); await expect(results).toHaveCount(5); await expect(results.first()).toContainText('Texas'); // R4: Country is never concatenated into the query string. expect(requestedUrl).toContain('name=Paris'); expect(requestedUrl).not.toContain('Texas'); }); // ── R8: no matches shows the inline hint, fields untouched ───────────────── test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => { await page.goto('/post'); await mockGeocode(page, { results: [] }); await page.fill('input[name="data[location_city]"]', 'Nowheresville'); await openLocationDetails(page); await page.click('#lookup-coords'); await expect(page.locator('#location-search-hint')).toContainText(/no matches/i); await expect(page.locator('input[name="data[lat]"]')).toHaveValue(''); await expect(page.locator('input[name="data[lng]"]')).toHaveValue(''); }); // ── R5: in-flight state shows "Searching…" and always re-enables ─────────── test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => { await page.goto('/post'); await page.route(GEOCODE_URL, async (route) => { await new Promise((r) => setTimeout(r, 400)); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) }); }); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); const btn = page.locator('#lookup-coords'); await expect(btn).toBeDisabled(); await expect(btn).toHaveText('Searching…'); await expect(btn).toBeEnabled({ timeout: 5_000 }); await expect(btn).toContainText('Look up coordinates'); }); // ── R8: a network failure degrades silently and re-enables the button ────── test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => { await page.goto('/post'); await page.route(GEOCODE_URL, (route) => route.abort('failed')); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); await expect(page.locator('#lookup-coords')).toBeEnabled(); await expect(page.locator('input[name="data[lat]"]')).toHaveValue(''); await expect(page.locator('input[name="data[lng]"]')).toHaveValue(''); }); // ── XSS safety: an API-sourced name containing markup renders as literal text ── test('a result name containing markup renders as literal text, not executed', async ({ page }) => { await page.goto('/post'); await mockGeocode(page, { results: [{ name: '', latitude: 1, longitude: 2, country: 'Nowhere' }] }); await page.fill('input[name="data[location_city]"]', 'Test'); await openLocationDetails(page); await page.click('#lookup-coords'); const btn = page.locator('.location-search-results li button').first(); await expect(btn).toContainText(''); expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull(); expect(await page.evaluate(() => window.__xss)).toBeUndefined(); }); // ── U4: map renders exactly one canvas, no pin until a coordinate is set ─── test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => { await page.goto('/post'); await openLocationDetails(page); await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 }); await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0); }); // ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ── test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => { await page.goto('/post'); await openLocationDetails(page); await page.locator('.location-details__summary').click(); // close await expect(page.locator('.location-details')).toHaveJSProperty('open', false); await openLocationDetails(page); // reopen const canvases = page.locator('#location-map canvas.maplibregl-canvas'); await expect(canvases).toHaveCount(1, { timeout: 10_000 }); const box = await canvases.first().boundingBox(); expect(box && box.width).toBeGreaterThan(0); expect(box && box.height).toBeGreaterThan(0); }); // ── R11: a search pick shows a pin on the map ─────────────────────────────── test('a search-result pick renders a pin on the map', async ({ page }) => { await page.goto('/post'); await mockGeocode(page, KYOTO_RESULTS); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); await page.locator('.location-search-results li button').first().click(); await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 }); }); // ── R11: dragging the marker updates lat/lng (rounded to 6dp) ────────────── test('dragging the pin updates lat/lng to the drop location', async ({ page }) => { await page.goto('/post'); await mockGeocode(page, KYOTO_RESULTS); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); await page.locator('.location-search-results li button').first().click(); const marker = page.locator('#location-map .maplibregl-marker'); await expect(marker).toHaveCount(1, { timeout: 10_000 }); const before = await page.locator('input[name="data[lat]"]').inputValue(); // setPin()'s map.panTo() animates the marker into view — wait for it to // settle so the bounding box grabbed below matches where the marker will // actually be when the mouse events land. await page.waitForTimeout(800); const box = await marker.boundingBox(); if (!box) throw new Error('marker has no bounding box'); const startX = box.x + box.width / 2; const startY = box.y + box.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX + 40, startY + 30, { steps: 5 }); await page.mouse.up(); await expect(async () => { const after = await page.locator('input[name="data[lat]"]').inputValue(); expect(after).not.toBe(before); expect(after).toMatch(/^-?\d+\.\d{6}$/); }).toPass({ timeout: 5_000 }); }); // ── R11/R13: typing an invalid value flags the field without crashing ────── test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => { await page.goto('/post'); await openLocationDetails(page); const latEl = page.locator('input[name="data[lat]"]'); const lngEl = page.locator('input[name="data[lng]"]'); await latEl.fill('not-a-number'); await lngEl.fill('135.7681'); await lngEl.blur(); await expect(latEl).toHaveClass(/location-field--mismatch/); await expect(latEl).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0); await latEl.fill('35.0116'); await latEl.blur(); await expect(latEl).not.toHaveClass(/location-field--mismatch/); await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1); }); // ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must // not build two Map instances against the same container (code-review fix) ── test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => { await page.route('**/*maplibre-gl*.js', async (route) => { await new Promise((resolve) => setTimeout(resolve, 500)); await route.continue(); }); await page.goto('/post'); // Open, then immediately close and reopen — both toggles land while the // delayed chunk request above is still pending. await page.locator('.location-details__summary').click(); await page.locator('.location-details__summary').click(); await page.locator('.location-details__summary').click(); await expect(page.locator('.location-details')).toHaveJSProperty('open', true); await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 }); }); // ── U5: blanking both fields after a mismatch was flagged clears the flag ── test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => { await page.goto('/post'); await openLocationDetails(page); const latEl = page.locator('input[name="data[lat]"]'); const lngEl = page.locator('input[name="data[lng]"]'); await latEl.fill('not-a-number'); await lngEl.blur(); await expect(latEl).toHaveClass(/location-field--mismatch/); await latEl.fill(''); await lngEl.fill(''); await lngEl.blur(); await expect(latEl).not.toHaveClass(/location-field--mismatch/); await expect(lngEl).not.toHaveClass(/location-field--mismatch/); }); // ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ── test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => { const tag = `loc-mismatch-${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.'); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); await waitForPhotoUpload(page); await openLocationDetails(page); const latEl = page.locator('input[name="data[lat]"]'); const lngEl = page.locator('input[name="data[lng]"]'); await latEl.fill('999'); await lngEl.fill('999'); await lngEl.blur(); await expect(latEl).toHaveClass(/location-field--mismatch/); // Register for cleanup BEFORE the click: if the gate ever regresses, the // entry lands on disk and the afterAll hook must still see the tag. created.push(tag); await page.locator('.btn-post').evaluate((el) => el.click()); // `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and // both also hold for a SUCCESSFUL submit (the form posts to /post and only // renders its notice after the round trip), so neither can distinguish a // working gate from a regressed one. Prove the negative on disk instead, // after giving a regressed submit time to actually write. await page.waitForTimeout(2000); expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy(); // And prove the block was the gate's doing: still flagged, value untouched. await expect(latEl).toHaveClass(/location-field--mismatch/); await expect(latEl).toHaveValue('999'); }); // ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ── test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => { const chunkRequests = []; page.on('request', (req) => { if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url()); }); const tag = `loc-nomap-${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.'); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); await waitForPhotoUpload(page); await page.locator('.btn-post').evaluate((el) => el.click()); await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 }); created.push(tag); expect(chunkRequests, 'maplibre-gl must not be fetched when the panel is never opened').toHaveLength(0); }); // ── Full submit: a search-picked location round-trips into the frontmatter ── test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => { const tag = `loc-submit-${Date.now()}`; await page.goto('/post'); await mockGeocode(page, KYOTO_RESULTS); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await fillEditor(page, 'Location-override submit test. Safe to delete.'); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await openLocationDetails(page); await page.click('#lookup-coords'); await page.locator('.location-search-results li button').first().click(); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); await waitForPhotoUpload(page); await page.locator('.btn-post').evaluate((el) => el.click()); await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 }); created.push(tag); const entryDir = findEntry(tag); expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); const md = readEntryMd(entryDir); expect(md).toContain('35.0116'); expect(md).toContain('135.7681'); });