From fcd5f6f8addbcbde72b417179fe3edccc9919139 Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Wed, 22 Jul 2026 16:03:35 +0200 Subject: [PATCH 1/2] test(e2e): cover map chrome at partner viewports Provision nav/fullscreen/streetView/overview/search tasks and assert Capas/overview/left chrome at 480/768/1024; bump sitmun-viewer-app. Refs: sitmun/sitmun-viewer-app#135 --- e2e/viewer/fixtures.ts | 12 + e2e/viewer/layer-catalog.spec.ts | 800 ++++++++++++++++++++++++++++--- e2e/viewer/setup/viewer.setup.ts | 15 +- front/viewer/sitmun-viewer-app | 2 +- 4 files changed, 754 insertions(+), 75 deletions(-) diff --git a/e2e/viewer/fixtures.ts b/e2e/viewer/fixtures.ts index 0c82479..7122c7b 100644 --- a/e2e/viewer/fixtures.ts +++ b/e2e/viewer/fixtures.ts @@ -15,6 +15,18 @@ export const LEGEND_TASK_ID = 12; export const WORK_LAYER_MANAGER_TASK_ID = 31; /** Seed STM_TASK id for sitna.basemapSelector (STM_TSK_UI TUI_ID 2). */ export const BASEMAP_SELECTOR_TASK_ID = 2; +/** + * Map chrome tasks for #135 left-toolbar stack (missing from STM_AVAIL_TSK). + * Mirrors partner Menorca profile 12/4: navBar, fullScreen, streetView, threed. + */ +export const FULL_SCREEN_TASK_ID = 9; +export const NAV_BAR_TASK_ID = 16; +/** Bottom-right overview (folded map) tab for #135 right-chrome checks. */ +export const OVERVIEW_MAP_TASK_ID = 18; +/** Search control — must stay clear of the left icon column (#135). */ +export const SEARCH_TASK_ID = 24; +export const STREET_VIEW_TASK_ID = 26; +export const THREE_D_TASK_ID = 27; /** * Catalog E2E uses the visible Topográficos branch (Ortofotos is filtered out of the diff --git a/e2e/viewer/layer-catalog.spec.ts b/e2e/viewer/layer-catalog.spec.ts index 6c13900..59bcc0d 100644 --- a/e2e/viewer/layer-catalog.spec.ts +++ b/e2e/viewer/layer-catalog.spec.ts @@ -1,4 +1,6 @@ -import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test, expect, type APIRequestContext, type Page, type TestInfo } from '@playwright/test'; import { APP_ID, CHECKBOX_LOAD_FOLDER_NODE_ID, @@ -810,7 +812,639 @@ test.describe('Capas WLM contrast and layout (#92 / #142)', () => { }); }); +type ChromeBox = { + x: number; + y: number; + width: number; + height: number; + sel: string; +}; + +type MapChromeLayout = { + candidates: ChromeBox[]; + leftStack: ChromeBox[]; + capasGlyphCount: number; + capasTransform: string; + capasPanel: ChromeBox | null; + capasTab: ChromeBox | null; + overviewTab: ChromeBox | null; + search: ChromeBox | null; + slider: ChromeBox | null; + mapBox: ChromeBox | null; + opacities: Record; + wlmActionBoxes: ChromeBox[]; + svDisplay: string; + threedDisplay: string; +}; + +const ISSUE135_SHOT_DIR = path.join('test-results', 'issue135'); + +const LEFT_STACK_ORDER = [ + '#tools-tab', + '#legend-tab', + '.tc-ctl-fscreen', + 'button.tc-ctl-fscreen-btn', + '.tc-ctl-sv', + '.tc-ctl-nav-home-btn', + '.tc-ctl-nav .tc-ctl-nav-btn-zoomin', + '.tc-ctl-nav .tc-ctl-nav-btn-zoomout', + '.tc-ctl-3d', +] as const; + +async function captureIssue135Shot( + page: Page, + testInfo: TestInfo, + name: string, + clip?: { x: number; y: number; width: number; height: number }, +): Promise { + fs.mkdirSync(ISSUE135_SHOT_DIR, { recursive: true }); + const filePath = path.join(ISSUE135_SHOT_DIR, name); + await page.screenshot({ path: filePath, fullPage: false, ...(clip ? { clip } : {}) }); + await testInfo.attach(name, { path: filePath, contentType: 'image/png' }); +} + +async function readMapChromeLayout(page: Page): Promise { + return page.evaluate(() => { + const pick = (sel: string): ChromeBox | null => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) { + return null; + } + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') { + return null; + } + const b = el.getBoundingClientRect(); + if (b.width < 2 || b.height < 2) { + return null; + } + return { x: b.x, y: b.y, width: b.width, height: b.height, sel }; + }; + + const leftSelectors = [ + '#tools-tab', + '#legend-tab', + '.tc-ctl-fscreen', + 'button.tc-ctl-fscreen-btn', + '.tc-ctl-sv', + '.tc-ctl-nav-home-btn', + '.tc-ctl-nav .tc-ctl-nav-btn-zoomin', + '.tc-ctl-nav .tc-ctl-nav-btn-zoomout', + '.tc-ctl-3d', + ]; + const leftStack = leftSelectors + .map((sel) => pick(sel)) + .filter(Boolean) as ChromeBox[]; + + // Prefer host .tc-ctl-fscreen over nested button when both match. + const fscreenHost = leftStack.find((b) => b.sel === '.tc-ctl-fscreen'); + const leftDeduped = fscreenHost + ? leftStack.filter((b) => b.sel !== 'button.tc-ctl-fscreen-btn') + : leftStack; + + const capasH1 = pick('.tc-tools-panel > h1'); + const overviewTab = + pick('#ovmap-tab') ?? pick('.tc-ovmap-panel > h1'); + const search = pick('.tc-ctl-search-content'); + const slider = + pick('.tc-ctl-nav-slider') ?? pick('.ol-zoomslider') ?? pick('.tc-ctl-nav-bar'); + const candidates = [...leftDeduped, capasH1, overviewTab].filter(Boolean) as ChromeBox[]; + + const capasEl = document.querySelector('.tc-tools-panel > h1') as HTMLElement | null; + let capasGlyphCount = 0; + let capasTransform = 'none'; + if (capasEl) { + capasTransform = getComputedStyle(capasEl).transform; + const bg = getComputedStyle(capasEl).backgroundImage; + if (bg && bg !== 'none') { + capasGlyphCount += 1; + } + const before = getComputedStyle(capasEl, '::before'); + const after = getComputedStyle(capasEl, '::after'); + if (before.content && before.content !== 'none' && before.content !== '""') { + capasGlyphCount += 1; + } + if (after.content && after.content !== 'none' && after.content !== '""') { + capasGlyphCount += 1; + } + } + + const toolsPanel = document.querySelector('.tc-tools-panel') as HTMLElement | null; + let capasPanel: ChromeBox | null = null; + if (toolsPanel && !toolsPanel.classList.contains('tc-collapsed-right')) { + const content = + (toolsPanel.querySelector('.tc-panel-content') as HTMLElement | null) ?? toolsPanel; + const style = getComputedStyle(content); + if (style.display !== 'none' && style.visibility !== 'hidden') { + const b = content.getBoundingClientRect(); + if (b.width >= 2 && b.height >= 2) { + capasPanel = { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + sel: '.tc-tools-panel .tc-panel-content', + }; + } + } + } + + const opacityOf = (sel: string): number => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) { + return 0; + } + return Number.parseFloat(getComputedStyle(el).opacity || '0'); + }; + + const wlmActionBoxes = [ + pick('#tc-slot-wlm .tc-ctl-wlm-btn-info'), + pick('#tc-slot-wlm .tc-ctl-wlm-btn-visibility'), + pick('#tc-slot-wlm .tc-ctl-wlm-btn-zoom'), + pick('#tc-slot-wlm .tc-ctl-wlm-btn-del'), + pick('#tc-slot-wlm input[type="range"]'), + ].filter(Boolean) as ChromeBox[]; + + const sv = document.querySelector('.tc-ctl-sv'); + const threed = document.querySelector('.tc-ctl-3d'); + const mapBox = + pick('.tc-map') ?? pick('#mapa') ?? pick('sitmun-map') ?? null; + return { + candidates, + leftStack: leftDeduped, + capasGlyphCount, + capasTransform, + capasPanel, + capasTab: capasH1, + overviewTab, + search, + slider, + mapBox, + opacities: { + '#tools-tab': opacityOf('#tools-tab'), + '#legend-tab': opacityOf('#legend-tab'), + '.tc-ctl-fscreen': opacityOf('.tc-ctl-fscreen'), + 'button.tc-ctl-fscreen-btn': opacityOf('button.tc-ctl-fscreen-btn'), + }, + wlmActionBoxes, + svDisplay: sv ? getComputedStyle(sv).display : 'none', + threedDisplay: threed ? getComputedStyle(threed).display : 'none', + }; + }); +} + +function assertNoOverlap( + boxes: ChromeBox[], + viewportLabel: string, + label: string, +): void { + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]!; + const b = boxes[j]!; + expect( + boxesOverlap(a, b), + `${label}: ${a.sel} overlaps ${b.sel} at ${viewportLabel}`, + ).toBe(false); + } + } +} + +function assertLeftStackOrder( + leftStack: ChromeBox[], + viewportLabel: string, + slider: ChromeBox | null = null, +): void { + const orderIndex = (sel: string) => LEFT_STACK_ORDER.indexOf(sel as (typeof LEFT_STACK_ORDER)[number]); + const ordered = [...leftStack].sort((a, b) => orderIndex(a.sel) - orderIndex(b.sel)); + for (let i = 0; i < ordered.length - 1; i++) { + const a = ordered[i]!; + const b = ordered[i + 1]!; + expect( + a.y < b.y, + `left stack order at ${viewportLabel}: ${a.sel} (y=${a.y}) should be above ${b.sel} (y=${b.y})`, + ).toBe(true); + } + + const home = leftStack.find((b) => b.sel === '.tc-ctl-nav-home-btn'); + const zoomIn = leftStack.find((b) => b.sel === '.tc-ctl-nav .tc-ctl-nav-btn-zoomin'); + const zoomOut = leftStack.find((b) => b.sel === '.tc-ctl-nav .tc-ctl-nav-btn-zoomout'); + const threed = leftStack.find((b) => b.sel === '.tc-ctl-3d'); + if (home && zoomIn) { + expect( + boxesOverlap(home, zoomIn), + `BAD stack: home overlaps zoom+ at ${viewportLabel}`, + ).toBe(false); + } + const fscreen = + leftStack.find((b) => b.sel === '.tc-ctl-fscreen') ?? + leftStack.find((b) => b.sel === 'button.tc-ctl-fscreen-btn'); + const sv = leftStack.find((b) => b.sel === '.tc-ctl-sv'); + if (fscreen && sv) { + expect( + boxesOverlap(fscreen, sv), + `BAD stack: fscreen overlaps Street View at ${viewportLabel}`, + ).toBe(false); + } + if (zoomIn && zoomOut && threed) { + expect( + zoomIn.y < zoomOut.y && zoomOut.y < threed.y, + `zoom order at ${viewportLabel}: + (${zoomIn.y}) < − (${zoomOut.y}) < 3D (${threed.y})`, + ).toBe(true); + } + if (slider && zoomIn && zoomOut) { + expect( + zoomIn.y < slider.y && slider.y < zoomOut.y, + `slider between +/− at ${viewportLabel}: + (${zoomIn.y}) < slider (${slider.y}) < − (${zoomOut.y})`, + ).toBe(true); + } +} + +function assertSearchRightOfTools( + layout: MapChromeLayout, + viewportLabel: string, +): void { + const tools = layout.leftStack.find((b) => b.sel === '#tools-tab'); + expect(layout.search, `search must be visible at ${viewportLabel}`).toBeTruthy(); + expect(tools, `tools tab must be visible at ${viewportLabel}`).toBeTruthy(); + if (!layout.search || !tools) { + return; + } + expect( + layout.search.x + 0.5 >= tools.x + tools.width, + `search must sit right of tools at ${viewportLabel}: search.x=${layout.search.x} tools.right=${tools.x + tools.width}`, + ).toBe(true); + for (const tool of layout.leftStack) { + expect( + boxesOverlap(layout.search, tool), + `search overlaps ${tool.sel} at ${viewportLabel}`, + ).toBe(false); + } +} + +function assertOpaqueLeftChrome(layout: MapChromeLayout, viewportLabel: string): void { + for (const sel of ['#tools-tab', '#legend-tab'] as const) { + expect( + layout.opacities[sel]! >= 0.99, + `${sel} opacity at ${viewportLabel}: ${layout.opacities[sel]}`, + ).toBe(true); + } + const fscreenOpacity = Math.max( + layout.opacities['.tc-ctl-fscreen'] ?? 0, + layout.opacities['button.tc-ctl-fscreen-btn'] ?? 0, + ); + if (layout.leftStack.some((b) => b.sel.includes('fscreen'))) { + expect( + fscreenOpacity >= 0.99, + `fscreen opacity at ${viewportLabel}: ${fscreenOpacity}`, + ).toBe(true); + } +} + +/** Overview hides only on very short heights; must stay visible at 768×576. */ +const OVERVIEW_MIN_VIEWPORT_HEIGHT = 401; + +function assertRightChrome( + layout: MapChromeLayout, + viewport: { width: number; height: number }, + viewportLabel: string, +): void { + expect(layout.capasTab, `Capas tab must be visible at ${viewportLabel}`).toBeTruthy(); + const capas = layout.capasTab!; + const tools = layout.leftStack.find((b) => b.sel === '#tools-tab'); + const mapRight = layout.mapBox + ? layout.mapBox.x + layout.mapBox.width + : viewport.width; + const mapBottom = layout.mapBox + ? layout.mapBox.y + layout.mapBox.height + : viewport.height; + const mapMidX = layout.mapBox + ? layout.mapBox.x + layout.mapBox.width / 2 + : viewport.width / 2; + expect( + Math.abs(capas.width - 40) <= 4 && Math.abs(capas.height - 40) <= 4, + `Capas size at ${viewportLabel}: ${capas.width}x${capas.height} (want ~40x40)`, + ).toBe(true); + expect( + capas.x > mapMidX, + `Capas must be on the right at ${viewportLabel}: x=${capas.x}`, + ).toBe(true); + if (tools) { + expect( + Math.abs(capas.y - tools.y) <= 4, + `Capas must align with tools at ${viewportLabel}: capas.y=${capas.y} tools.y=${tools.y}`, + ).toBe(true); + } + const capasRightGap = mapRight - (capas.x + capas.width); + expect( + Math.abs(capasRightGap - 20) <= 8, + `Capas right inset at ${viewportLabel}: ${capasRightGap} (want ~20)`, + ).toBe(true); + + if (viewport.height < OVERVIEW_MIN_VIEWPORT_HEIGHT) { + expect( + layout.overviewTab, + `overview must hide before clipping/Capas overlap at ${viewportLabel}`, + ).toBeNull(); + return; + } + + expect(layout.overviewTab, `overview tab must be visible at ${viewportLabel}`).toBeTruthy(); + const overview = layout.overviewTab!; + expect( + Math.abs(overview.width - 40) <= 4 && Math.abs(overview.height - 40) <= 4, + `overview size at ${viewportLabel}: ${overview.width}x${overview.height} (want ~40x40; clipped/broken if smaller)`, + ).toBe(true); + // Fully inside the map — rejects the corner-clipped "broken" glyph. + expect( + overview.x >= (layout.mapBox?.x ?? 0) - 1 && + overview.y >= (layout.mapBox?.y ?? 0) - 1 && + overview.x + overview.width <= mapRight + 1 && + overview.y + overview.height <= mapBottom + 1, + `overview must stay inside map at ${viewportLabel} (not clipped)`, + ).toBe(true); + const overviewRightGap = mapRight - (overview.x + overview.width); + const overviewBottomGap = mapBottom - (overview.y + overview.height); + expect( + Math.abs(overviewRightGap - 20) <= 8, + `overview right inset at ${viewportLabel}: ${overviewRightGap} (want ~20)`, + ).toBe(true); + expect( + Math.abs(overviewBottomGap - 80) <= 10, + `overview bottom inset at ${viewportLabel}: ${overviewBottomGap} (want ~80)`, + ).toBe(true); + expect( + boxesOverlap(capas, overview), + `Capas must not cover overview at ${viewportLabel}`, + ).toBe(false); +} + +function leftChromeClip( + leftStack: ChromeBox[], +): { x: number; y: number; width: number; height: number } | undefined { + if (leftStack.length === 0) { + return undefined; + } + const minX = Math.min(...leftStack.map((b) => b.x)); + const minY = Math.min(...leftStack.map((b) => b.y)); + const maxX = Math.max(...leftStack.map((b) => b.x + b.width)); + const maxY = Math.max(...leftStack.map((b) => b.y + b.height)); + const pad = 8; + return { + x: Math.max(0, minX - pad), + y: Math.max(0, minY - pad), + width: Math.max(2, maxX - minX + pad * 2), + height: Math.max(2, maxY - minY + pad * 2), + }; +} + +async function readTabChrome(page: Page, sel: '#tools-tab' | '#legend-tab') { + return page.locator(sel).evaluate((el) => { + const s = getComputedStyle(el); + const b = el.getBoundingClientRect(); + return { + backgroundColor: s.backgroundColor, + backgroundImage: s.backgroundImage, + top: s.top, + y: b.y, + visible: s.display !== 'none' && s.visibility !== 'hidden' && b.width > 0, + }; + }); +} + test.describe('Map chrome responsive (#135)', () => { + test('capture status shots for GitHub progress update', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await loginAndOpenMap(page); + + // Partner-reported viewport matrix from #135. + for (const viewport of [ + { width: 480, height: 360 }, + { width: 768, height: 576 }, + { width: 1024, height: 768 }, + ] as const) { + const tag = `${viewport.width}x${viewport.height}`; + await page.setViewportSize(viewport); + + await page.evaluate(() => { + document.querySelector('.tc-tools-panel')?.classList.add('tc-collapsed-right'); + document.querySelector('.tc-ovmap-panel')?.classList.add('tc-collapsed-right'); + document.querySelector('.tc-left-panel')?.classList.add('tc-collapsed-left'); + }); + await page.waitForTimeout(350); + await captureIssue135Shot(page, testInfo, `status-${tag}-01-default-COLLAPSED.png`); + + await page.locator('.tc-tools-panel > h1').click(); + await page.waitForTimeout(350); + if (viewport.width === 480) { + const capas = await page.evaluate(() => { + const panel = document.querySelector('.tc-tools-panel') as HTMLElement; + const h1 = document.querySelector('.tc-tools-panel > h1') as HTMLElement; + const pb = panel.getBoundingClientRect(); + const hb = h1.getBoundingClientRect(); + const hs = getComputedStyle(h1); + return { + panelW: pb.width, + closeTop: hs.top, + closeX: hb.x, + panelX: pb.x, + gap: pb.x - (hb.x + hb.width), + }; + }); + expect(capas.panelW, 'Capas drawer ≤160px at 480').toBeLessThanOrEqual(161); + expect(capas.closeTop).toBe('56px'); + expect(Math.abs(capas.gap - 20), `Capas close gap ${capas.gap}`).toBeLessThanOrEqual(2); + } + if (viewport.width === 480 || viewport.width === 768) { + const contentTop = await page.evaluate(() => { + const panel = document.querySelector('.tc-tools-panel') as HTMLElement; + const content = document.querySelector( + '.tc-tools-panel > .tc-panel-content', + ) as HTMLElement; + return { + panelY: panel.getBoundingClientRect().y, + contentY: content.getBoundingClientRect().y, + marginTop: getComputedStyle(content).marginTop, + }; + }); + expect( + contentTop.marginTop, + `Capas open must not reserve empty SITNA header slot at ${tag}`, + ).toBe('0px'); + expect( + Math.abs(contentTop.contentY - contentTop.panelY) <= 1, + `Capas content must start at panel top (no seam gap) at ${tag}: contentY=${contentTop.contentY} panelY=${contentTop.panelY}`, + ).toBe(true); + } + await captureIssue135Shot(page, testInfo, `status-${tag}-02-capas-OPEN-close.png`); + + await page.locator('.tc-tools-panel > h1').click(); + await page.waitForTimeout(350); + // Overview may be CSS-hidden on very short heights; still attempt open shot. + const overviewTab = page.locator('.tc-ovmap-panel > h1'); + if (await overviewTab.isVisible()) { + await overviewTab.click(); + await page.waitForTimeout(400); + await captureIssue135Shot(page, testInfo, `status-${tag}-03-overview-OPEN-close.png`); + await overviewTab.click(); + await page.waitForTimeout(350); + } else { + await captureIssue135Shot(page, testInfo, `status-${tag}-03-overview-HIDDEN.png`); + } + + const toolsTab = page.locator('#tools-tab'); + if (await toolsTab.isVisible()) { + await toolsTab.click(); + await page.waitForTimeout(350); + if (viewport.width === 480) { + const left = await page.evaluate(() => { + const panel = document.querySelector('.tc-left-panel') as HTMLElement; + const tools = document.querySelector('#tools-tab') as HTMLElement; + const pb = panel.getBoundingClientRect(); + const tb = tools.getBoundingClientRect(); + const ts = getComputedStyle(tools); + return { + panelW: pb.width, + closeTop: ts.top, + closeRight: ts.right, + closeX: tb.x, + panelRight: pb.x + pb.width, + gap: tb.x - (pb.x + pb.width), + closeY: tb.y, + contentY: ( + document.querySelector( + '.tc-left-panel > .tc-panel-content', + ) as HTMLElement + ).getBoundingClientRect().y, + }; + }); + expect(left.panelW, 'left drawer ≤160px at 480').toBeLessThanOrEqual(161); + expect(left.closeTop, 'left close at drawer top').toBe('8px'); + expect(left.closeRight).toBe('-60px'); + expect(Math.abs(left.gap - 20), `left close gap ${left.gap}`).toBeLessThanOrEqual(2); + expect( + Math.abs(left.closeY - left.contentY - 8) <= 2, + `left close y=${left.closeY} vs content y=${left.contentY}`, + ).toBe(true); + } + await captureIssue135Shot(page, testInfo, `status-${tag}-04-left-tools-OPEN-close.png`); + await toolsTab.click(); + await page.waitForTimeout(200); + } + } + }); + + test('left panel close control is red arrow at opener height for tools and legend', async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await loginAndOpenMap(page); + + const assertCloseAtOpenerHeight = async ( + sel: '#tools-tab' | '#legend-tab', + expectedTopPx: number, + ) => { + const before = await readTabChrome(page, sel); + expect(before.visible, `${sel} opener must be visible`).toBe(true); + + await page.locator(sel).click(); + await expect(page.locator('.tc-left-panel')).not.toHaveClass(/tc-collapsed-left/); + + await expect + .poll(async () => { + const chrome = await readTabChrome(page, sel); + return ( + chrome.visible && + /closeIcon/.test(chrome.backgroundImage) && + /rgba?\(181,\s*24,\s*24/.test(chrome.backgroundColor) + ); + }, { message: `${sel} close must become red closeIcon` }) + .toBe(true); + + const open = await readTabChrome(page, sel); + expect( + Math.abs(Number.parseFloat(open.top) - expectedTopPx) <= 1, + `${sel} close top ${open.top} must match opener ladder ${expectedTopPx}px`, + ).toBe(true); + expect( + Math.abs(open.y - before.y) <= 2, + `${sel} close y=${open.y} must match opener y=${before.y}`, + ).toBe(true); + + await page.locator(sel).click(); + await expect(page.locator('.tc-left-panel')).toHaveClass(/tc-collapsed-left/); + }; + + await assertCloseAtOpenerHeight('#tools-tab', 64); + await assertCloseAtOpenerHeight('#legend-tab', 108); + }); + + test('Capas and overview close stay at opener height', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await loginAndOpenMap(page); + + const assertRightCloseAtOpenerHeight = async ( + panelSel: '.tc-tools-panel' | '.tc-ovmap-panel', + tabSel: string, + ) => { + await page.evaluate((sel) => { + document.querySelector(sel)?.classList.add('tc-collapsed-right'); + }, panelSel); + + const before = await page.locator(tabSel).evaluate((el) => { + const b = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return { y: b.y, visible: b.width > 0 && s.visibility !== 'hidden' }; + }); + expect(before.visible, `${tabSel} opener must be visible`).toBe(true); + + await page.locator(tabSel).click(); + await expect(page.locator(panelSel)).not.toHaveClass(/tc-collapsed-right/); + + const open = await page.locator(`${panelSel} > h1`).evaluate((el) => { + const b = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return { + y: b.y, + backgroundColor: s.backgroundColor, + backgroundImage: s.backgroundImage, + }; + }); + expect( + open.backgroundImage, + `${panelSel} close must use right-facing mapaCerrarReverse`, + ).toMatch(/mapaCerrarReverse/); + expect( + open.backgroundColor, + `${panelSel} close must be red (#b51818)`, + ).toMatch(/rgba?\(181,\s*24,\s*24/); + expect( + Math.abs(open.y - before.y) <= 3, + `${panelSel} close y=${open.y} must match opener y=${before.y}`, + ).toBe(true); + + if (panelSel === '.tc-ovmap-panel') { + const contentShadow = await page + .locator(`${panelSel} > #tc-slot-ovmap`) + .evaluate((el) => getComputedStyle(el).boxShadow); + expect( + contentShadow, + 'open overview must keep a Capas-like content edge', + ).toMatch(/rgba?\(0,\s*0,\s*0/); + expect(contentShadow, 'open overview edge must not be none').not.toBe('none'); + } + + await page.locator(`${panelSel} > h1`).click(); + await expect(page.locator(panelSel)).toHaveClass(/tc-collapsed-right/); + }; + + await assertRightCloseAtOpenerHeight('.tc-tools-panel', '.tc-tools-panel > h1'); + await assertRightCloseAtOpenerHeight( + '.tc-ovmap-panel', + '.tc-ovmap-panel > h1', + ); + }); + const viewports = [ { width: 480, height: 360 }, { width: 768, height: 576 }, @@ -818,91 +1452,111 @@ test.describe('Map chrome responsive (#135)', () => { ] as const; for (const viewport of viewports) { - test(`chrome does not overlap at ${viewport.width}x${viewport.height}`, async ({ page }) => { + test(`chrome does not overlap at ${viewport.width}x${viewport.height}`, async ({ + page, + }, testInfo) => { + const tag = `${viewport.width}x${viewport.height}`; + // Login at a desktop size so the login button stays in-viewport. await page.setViewportSize({ width: 1280, height: 800 }); await loginAndOpenMap(page); await page.setViewportSize(viewport); - // Capas drawer collapsed for map-chrome layout (tab visible on the edge). - await page.locator('.tc-tools-panel').evaluate((panel) => { - panel.classList.add('tc-collapsed-right'); - }); - const layout = await page.evaluate(() => { - const pick = (sel: string) => { - const el = document.querySelector(sel) as HTMLElement | null; - if (!el) { - return null; - } - const style = getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') { - return null; - } - const b = el.getBoundingClientRect(); - if (b.width < 2 || b.height < 2) { - return null; - } - return { x: b.x, y: b.y, width: b.width, height: b.height, sel }; - }; - const candidates = [ - pick('.tc-ctl-nav .tc-ctl-nav-btn-zoomin'), - pick('.tc-ctl-nav .tc-ctl-nav-btn-zoomout'), - pick('.tc-ctl-nav-home-btn'), - pick('.tc-tools-panel > h1'), - pick('.tc-ctl-sv'), - pick('.tc-ctl-3d'), - ].filter(Boolean) as Array<{ - x: number; - y: number; - width: number; - height: number; - sel: string; - }>; - - const capasH1 = document.querySelector('.tc-tools-panel > h1') as HTMLElement | null; - let capasGlyphCount = 0; - if (capasH1) { - const bg = getComputedStyle(capasH1).backgroundImage; - if (bg && bg !== 'none') { - capasGlyphCount += 1; - } - const before = getComputedStyle(capasH1, '::before'); - const after = getComputedStyle(capasH1, '::after'); - if (before.content && before.content !== 'none' && before.content !== '""') { - capasGlyphCount += 1; - } - if (after.content && after.content !== 'none' && after.content !== '""') { - capasGlyphCount += 1; - } - } - - const sv = document.querySelector('.tc-ctl-sv'); - const threed = document.querySelector('.tc-ctl-3d'); - return { - candidates, - capasGlyphCount, - svDisplay: sv ? getComputedStyle(sv).display : 'none', - threedDisplay: threed ? getComputedStyle(threed).display : 'none', - }; - }); + const collapseRightChrome = async () => { + await page.evaluate(() => { + const tools = document.querySelector('.tc-tools-panel'); + tools?.classList.add('tc-collapsed-right'); + const ov = document.querySelector('.tc-ovmap-panel'); + ov?.classList.add('tc-collapsed-right'); + ov?.classList.remove('tc-disabled'); + }); + }; + // Default chrome: Capas + overview panels collapsed; overview tab visible. + await collapseRightChrome(); + await page.waitForTimeout(350); + await captureIssue135Shot(page, testInfo, `e2e-like-${tag}-capas-COLLAPSED.png`); + + const collapsedLayout = await readMapChromeLayout(page); + expect( + collapsedLayout.capasPanel, + `Capas panel must not be expanded at ${tag}`, + ).toBeNull(); + await expect( + page.locator('.tc-ovmap-panel'), + `overview panel must stay collapsed at ${tag}`, + ).toHaveClass(/tc-collapsed-right/); + // Overview body rides off-screen with translateX(100%); tab stays visible. + await expect + .poll(async () => { + const box = await page.locator('#tc-slot-ovmap').boundingBox(); + if (!box) { + return true; + } + return box.x >= viewport.width - 2; + }, `overview mini-map must stay off-screen when collapsed at ${tag}`) + .toBe(true); + expect( + collapsedLayout.leftStack.some((b) => b.sel === '#tools-tab'), + `tools tab must stay visible/upright at ${tag}`, + ).toBe(true); + expect( + await page.locator('#tools-tab').evaluate((el) => getComputedStyle(el).transform), + `tools tab must not mirror/rotate at ${tag}`, + ).toMatch(/none|matrix\(1,\s*0,\s*0,\s*1/); if (viewport.width <= 480) { - expect(layout.svDisplay).toBe('none'); - expect(layout.threedDisplay).toBe('none'); + expect(collapsedLayout.svDisplay).toBe('none'); + expect(collapsedLayout.threedDisplay).toBe('none'); } + expect(collapsedLayout.capasGlyphCount).toBeLessThanOrEqual(1); + assertNoOverlap(collapsedLayout.candidates, tag, 'Capas COLLAPSED chrome'); + assertNoOverlap(collapsedLayout.leftStack, tag, 'Capas COLLAPSED left stack'); + assertLeftStackOrder(collapsedLayout.leftStack, tag, collapsedLayout.slider); + assertSearchRightOfTools(collapsedLayout, tag); + assertOpaqueLeftChrome(collapsedLayout, tag); + assertRightChrome(collapsedLayout, viewport, tag); + + // Capas OPEN (partner-equivalent collision guards). + await page.locator('.tc-tools-panel').evaluate((panel) => { + panel.classList.remove('tc-collapsed-right'); + }); + await captureIssue135Shot(page, testInfo, `partner-like-${tag}-capas-OPEN.png`); + + const openLayout = await readMapChromeLayout(page); + await captureIssue135Shot( + page, + testInfo, + `left-chrome-${tag}.png`, + leftChromeClip(openLayout.leftStack), + ); - expect(layout.capasGlyphCount).toBeLessThanOrEqual(1); - - for (let i = 0; i < layout.candidates.length; i++) { - for (let j = i + 1; j < layout.candidates.length; j++) { - const a = layout.candidates[i]!; - const b = layout.candidates[j]!; + expect( + openLayout.leftStack.some((b) => b.sel === '.tc-ctl-nav-home-btn'), + `home control must be visible at ${tag} (enable sitna.navBar in setup)`, + ).toBe(true); + expect( + openLayout.leftStack.some((b) => b.sel === '.tc-ctl-nav .tc-ctl-nav-btn-zoomin'), + `zoom+ must be visible at ${tag}`, + ).toBe(true); + + assertNoOverlap(openLayout.leftStack, tag, 'Capas OPEN left stack'); + assertLeftStackOrder(openLayout.leftStack, tag, openLayout.slider); + assertSearchRightOfTools(openLayout, tag); + assertOpaqueLeftChrome(openLayout, tag); + + if (openLayout.capasPanel) { + for (const tool of openLayout.leftStack) { expect( - boxesOverlap(a, b), - `${a.sel} overlaps ${b.sel} at ${viewport.width}x${viewport.height}`, + boxesOverlap(tool, openLayout.capasPanel), + `worst-case: ${tool.sel} overlaps Capas panel at ${tag}`, ).toBe(false); } } + if (openLayout.wlmActionBoxes.length >= 2) { + assertNoOverlap(openLayout.wlmActionBoxes, tag, 'Capas OPEN WLM actions'); + } + + await collapseRightChrome(); }); } }); diff --git a/e2e/viewer/setup/viewer.setup.ts b/e2e/viewer/setup/viewer.setup.ts index bca7438..2b409df 100644 --- a/e2e/viewer/setup/viewer.setup.ts +++ b/e2e/viewer/setup/viewer.setup.ts @@ -11,14 +11,20 @@ import { CONTACT_EMAIL, CONTACT_INSTITUTION, BASEMAP_SELECTOR_TASK_ID, + FULL_SCREEN_TASK_ID, generateViewerPassword, LAYER_CATALOG_TASK_ID, LEGEND_TASK_ID, + NAV_BAR_TASK_ID, + OVERVIEW_MAP_TASK_ID, QUERYABLE_LEAF_CARTOGRAPHY_ID, + SEARCH_TASK_ID, QUERYABLE_LEAF_MAX_SCALE_DENOMINATOR, QUERYABLE_LEAF_TREE_NODE_DB_ID, ROLE_ID, SERVICE_ID, + STREET_VIEW_TASK_ID, + THREE_D_TASK_ID, TERRITORY_ID, uniqueViewerUsername, UPSTREAM_PASSWORD, @@ -225,12 +231,19 @@ setup('provision viewer user and secured WMS service', async ({ request }) => { } // Profile tasks require territory availability. Seed STM_AVAIL_TSK omits - // sitna.layerCatalog / sitna.legend / workLayerManager / sitna.basemapSelector. + // sitna.layerCatalog / sitna.legend / workLayerManager / sitna.basemapSelector + // and map-chrome nav/fullscreen/streetView/overview needed for #135 checks. for (const taskId of [ LAYER_CATALOG_TASK_ID, LEGEND_TASK_ID, WORK_LAYER_MANAGER_TASK_ID, BASEMAP_SELECTOR_TASK_ID, + FULL_SCREEN_TASK_ID, + NAV_BAR_TASK_ID, + OVERVIEW_MAP_TASK_ID, + SEARCH_TASK_ID, + STREET_VIEW_TASK_ID, + THREE_D_TASK_ID, ]) { const createAvailability = await request.post('/backend/api/task-availabilities', { headers: adminHeaders, diff --git a/front/viewer/sitmun-viewer-app b/front/viewer/sitmun-viewer-app index 82a25b2..77c6584 160000 --- a/front/viewer/sitmun-viewer-app +++ b/front/viewer/sitmun-viewer-app @@ -1 +1 @@ -Subproject commit 82a25b2999dac9f099385a12856397bac7feb1f0 +Subproject commit 77c658405e0f362acc8ff3a766e5d53e112d4b78 From a3ca7eb863d5eed9be6e983b45ce0a23a8afe67f Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Thu, 23 Jul 2026 17:50:24 +0200 Subject: [PATCH 2/2] feat(e2e): viewer-mia coverage and multilang submodule bumps Bump viewer/backend/admin to merged multilang/MIA tips; add viewer-mia Playwright project and proxy wait-for-backend. --- back/backend/sitmun-backend-core | 2 +- e2e/README.md | 4 + e2e/admin/fixtures.ts | 2 +- .../forms/template-execute-child.spec.ts | 79 +++++++ e2e/viewer/fixtures.ts | 6 + e2e/viewer/mia-render.spec.ts | 197 ++++++++++++++++++ e2e/viewer/setup/viewer.setup.ts | 6 + front/admin/sitmun-admin-app | 2 +- front/viewer/sitmun-viewer-app | 2 +- playwright.viewer.config.ts | 9 +- scripts/e2e-proxy.mjs | 23 ++ 11 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 e2e/admin/forms/template-execute-child.spec.ts create mode 100644 e2e/viewer/mia-render.spec.ts diff --git a/back/backend/sitmun-backend-core b/back/backend/sitmun-backend-core index 6c6def1..af955ce 160000 --- a/back/backend/sitmun-backend-core +++ b/back/backend/sitmun-backend-core @@ -1 +1 @@ -Subproject commit 6c6def1f481f400f5bcea756d0292cbf28709c96 +Subproject commit af955ce78ebd99475dd3324e93b4bb6583a65c7f diff --git a/e2e/README.md b/e2e/README.md index 20b683d..1d05495 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -11,6 +11,7 @@ Browser E2E against backend-core on in-memory H2. No Docker Compose. - Role form: validation, create, edit, reload persistence - User form: validation, create, edit, reload persistence - Territory form: validation, create (with type), edit, reload persistence +- Plantilla dry-run: ADMIN `POST /api/tasks/template/preview` and `/execute-child` without required `appId`/`terId` (`e2e/admin/forms/template-execute-child.spec.ts`, project `admin-forms`) ### Viewer (`npm run e2e:viewer`) @@ -21,6 +22,7 @@ Browser E2E against backend-core on in-memory H2. No Docker Compose. - Layer catalog (`viewer-catalog`): radio folder children render native radios; `loadData` folders get a visible load control (checkbox, or radio when the folder is radio; title expand-only); non-radio cartography leaves get `sitmun-lcat-leaf-load` checkboxes (toggle work layer); child radios still work when `loadData` is off; queryable leaves show `.sitmun-lcat-gfi` after select when setup enables `queryableActive` + layer `queryableFeatureEnabled` (meta stamp asserted when SITNA renders info); row geometry asserts fixed 18px select/GFI controls when present (no empty spacers), level-stamped inset (`data-sitmun-lcat-level` 0/1/2…), nest step = type-icon width (16px, parent pad cancelled on nested `ul`), vertical centers, and meta ≥18×18 hit box; visible Capas disponibles rows stamp alternating `data-sitmun-lcat-zebra`; folder titles stay roman under `tc-checked`. Capas trash-then-clear after partial remove is asserted in viewer Jest (`layer-catalog-control.handler`). Playwright covers Capas row after radio load, out-of-scale `#777777` path color (#92), WLM/LCAT non-overlap and runtime tools-panel splitters (#142), and map-chrome stacking at 480/768/1024 (#135); stub serves GetMap PNG and GetMap OnlineResource (rewritten by proxy) so Capas rows are not cleared by TILELOADERROR. - Map legend (`viewer-legend`): after loading a stubbed catalog leaf, Capas shows capabilities `LegendURL` imagery and the Legend task shows symbology when the stub denies `DescribeLayer` and fails `/wms` GetLegendGraphic (DiBa/ArcGIS-style #164); setup enables `sitna.legend` task-availability - No base map (`viewer-basemap`): basemap selector option `sitmun-no-base-map` clears raster basemap to a white viewport while a catalog leaf stays visible (#167); setup enables `sitna.basemapSelector` task-availability +- More Info Advanced (`viewer-mia`): profile includes `sitna.moreInfoAdvanced` + type-16 parent on Toponímia; FeatureInfo `responseCallback` opens `.sitmun-mia-popup-overlay` and `POST /api/tasks/template/more-info-advanced/render` carries `appId`/`terId` body plus `lang` query ([sitmun-viewer-app#162](https://github.com/sitmun/sitmun-viewer-app/pull/162)); setup enables MIA + featureInfo task-availability - Local Basic-auth upstream stub (plus unauthenticated `/legend` PNG for #164); production Liquibase is not modified ### Application contact (`npx playwright test --config=playwright.application-contact.config.ts`) @@ -97,6 +99,8 @@ npm run e2e:viewer -- --project=viewer-public npm run e2e:viewer -- --project=viewer-password npm run e2e:viewer -- --project=viewer-catalog npm run e2e:viewer -- --project=viewer-legend +npm run e2e:viewer -- --project=viewer-basemap +npm run e2e:viewer -- --project=viewer-mia # admin → viewer responsible institution (shared backend) npx playwright test --config=playwright.application-contact.config.ts diff --git a/e2e/admin/fixtures.ts b/e2e/admin/fixtures.ts index ca21ecb..cee0972 100644 --- a/e2e/admin/fixtures.ts +++ b/e2e/admin/fixtures.ts @@ -1,7 +1,7 @@ import { test as base, expect, APIRequestContext } from '@playwright/test'; export type CreatedResource = { - collection: 'roles' | 'users' | 'territories'; + collection: 'roles' | 'users' | 'territories' | 'tasks'; id: number; }; diff --git a/e2e/admin/forms/template-execute-child.spec.ts b/e2e/admin/forms/template-execute-child.spec.ts new file mode 100644 index 0000000..8f22565 --- /dev/null +++ b/e2e/admin/forms/template-execute-child.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../fixtures'; + +/** + * Minimal Plantilla dry-run: ADMIN template APIs without required appId/terId (god mode). + * TipTap/CSV stay in Jest; map MIA render stays in viewer E2E. + * + * Seed note: H2 has no typeId 15 Plantilla rows, and POST /api/tasks often drops + * `properties` (JsonView), so creating a disposable child is unreliable. Use a seeded + * Query task with scope `web-api-query-no-proxy` (resolveDirect → 200, no JDBC). + */ +test.describe('Plantilla template dry-run', () => { + test('preview accepts ADMIN body without appId/terId', async ({ request }) => { + const body = { + templateTaskId: null, + templateHtml: '

e2e-preview

', + context: {}, + knownTaskReferences: [], + }; + + const response = await request.post('/backend/api/tasks/template/preview', { + headers: { + 'X-SITMUN-Client': 'admin', + 'Content-Type': 'application/json', + }, + data: body, + }); + + expect( + response.status(), + `preview failed: ${response.status()} ${await response.text()}`, + ).toBe(200); + + const json = await response.json(); + expect(json).toHaveProperty('html'); + expect(JSON.stringify(body)).not.toMatch(/"appId"|"terId"/); + }); + + test('execute-child accepts ADMIN body without appId/terId', async ({ request }) => { + const headers = { + 'X-SITMUN-Client': 'admin', + 'Content-Type': 'application/json', + }; + + // Seeded STM_TASK id 38: web-api-query-no-proxy (resolveDirect; no JDBC). + const seeded = await request.get('/backend/api/tasks/38?projection=view', { + headers: { 'X-SITMUN-Client': 'admin' }, + }); + expect( + seeded.ok(), + `seeded query task 38 missing: ${seeded.status()} ${await seeded.text()}`, + ).toBeTruthy(); + const seededTask = await seeded.json(); + expect(String(seededTask.properties?.scope ?? '').toLowerCase()).toBe( + 'web-api-query-no-proxy', + ); + const linkedTaskId = 38; + const body = { + templateTaskId: null, + linkedTaskId, + parameters: { codigo: 'e2e' }, + childTaskParameters: null, + }; + + const response = await request.post('/backend/api/tasks/template/execute-child', { + headers, + data: body, + }); + + expect( + response.status(), + `execute-child failed: ${response.status()} ${await response.text()}`, + ).toBe(200); + + const json = await response.json(); + expect(json).toHaveProperty('taskId', linkedTaskId); + expect(json).toHaveProperty('status'); + expect(JSON.stringify(body)).not.toMatch(/"appId"|"terId"/); + }); +}); diff --git a/e2e/viewer/fixtures.ts b/e2e/viewer/fixtures.ts index 7122c7b..8086077 100644 --- a/e2e/viewer/fixtures.ts +++ b/e2e/viewer/fixtures.ts @@ -27,6 +27,12 @@ export const OVERVIEW_MAP_TASK_ID = 18; export const SEARCH_TASK_ID = 24; export const STREET_VIEW_TASK_ID = 26; export const THREE_D_TASK_ID = 27; +/** Seed UI control sitna.moreInfoAdvanced (STM_TASK 41 / STM_TSK_UI 32). */ +export const MIA_CONTROL_TASK_ID = 41; +/** Seed MIA parent on Toponímia cartography GEO_ID 6 (STM_TASK 42, typeId 16). */ +export const MIA_PARENT_TASK_ID = 42; +/** Seed Feature Information control — needed for identify → MIA callback path. */ +export const FEATURE_INFO_TASK_ID = 8; /** * Catalog E2E uses the visible Topográficos branch (Ortofotos is filtered out of the diff --git a/e2e/viewer/mia-render.spec.ts b/e2e/viewer/mia-render.spec.ts new file mode 100644 index 0000000..a74fc05 --- /dev/null +++ b/e2e/viewer/mia-render.spec.ts @@ -0,0 +1,197 @@ +import { test, expect, type Page } from '@playwright/test'; +import { + APP_ID, + isBackendRequest, + MIA_PARENT_TASK_ID, + NON_RADIO_ROOT_FOLDER_TITLE, + QUERYABLE_LEAF_NODE_ID, + RADIO_FOLDER_TITLE, + readViewerCredentials, + TERRITORY_ID, +} from './fixtures'; + +/** + * More Info Advanced map render contract (#162): + * after FeatureInfo-shaped identify results for Toponímia, viewer opens the MIA + * overlay and POSTs /more-info-advanced/render with appId/terId + lang. + * + * Identify is driven through the same FeatureInfo.responseCallback path the + * map-click handler uses (WMS GetFeatureInfo stub coverage remains a follow-up). + */ + +async function loginAndOpenMap(page: Page): Promise { + const credentials = await readViewerCredentials(); + + await page.addInitScript(() => { + localStorage.setItem('language', 'ca'); + }); + + await page.goto('/auth/login'); + await expect(page.locator('h1')).toBeVisible(); + + await page.locator('input[name="username"]').fill(credentials.username); + await page.locator('input[name="password"]').fill(credentials.password); + + const authenticate = page.waitForResponse( + (response) => isBackendRequest(response, '/authenticate', 'POST') && response.ok(), + ); + const account = page.waitForResponse( + (response) => isBackendRequest(response, '/account', 'GET') && response.ok(), + ); + + await page.locator('form .login-button button').click(); + await Promise.all([authenticate, account]); + await expect(page).toHaveURL(/\/user\/dashboard/); + + const profile = page.waitForResponse( + (response) => + isBackendRequest( + response, + `/config/client/profile/${APP_ID}/${TERRITORY_ID}`, + 'GET', + ) && response.ok(), + ); + + await page.goto(`/user/map/${APP_ID}/${TERRITORY_ID}`, { + waitUntil: 'domcontentloaded', + }); + const profileResponse = await profile; + const profileBody = (await profileResponse.json()) as { + tasks?: Array<{ 'ui-control'?: string; typeId?: number; cartographyId?: string }>; + }; + expect( + profileBody.tasks?.some((task) => task['ui-control'] === 'sitna.moreInfoAdvanced'), + 'profile must include sitna.moreInfoAdvanced (setup task-availability)', + ).toBeTruthy(); + expect( + profileBody.tasks?.some( + (task) => task.typeId === 16 && String(task.cartographyId) === '6', + ), + 'profile must include MIA parent on cartography 6', + ).toBeTruthy(); + + await page.locator('#tc-slot-toc').waitFor({ state: 'attached', timeout: 90_000 }); + await page.locator('.tc-tools-panel').evaluate((panel) => { + panel.classList.remove('tc-collapsed-right'); + }); +} + +async function expandNodeByTitle(page: Page, title: string): Promise { + const item = page.locator('#tc-slot-toc li').filter({ + has: page.locator(':scope > span, :scope > .tc-ctl-lcat-node-title').filter({ + hasText: new RegExp(`^${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + }), + }); + await expect(item).toBeVisible({ timeout: 30_000 }); + const branch = item.locator(':scope > ul').first(); + if ((await branch.count()) > 0 && (await branch.isVisible())) { + return; + } + await item.getByRole('button', { name: /Expandir|Expand|Desplegar/i }).click(); + await expect(branch).toBeVisible({ timeout: 15_000 }); +} + +async function loadQueryableLeafIntoCapas(page: Page): Promise { + const wlm = page.locator('#tc-slot-wlm'); + await wlm.waitFor({ state: 'attached', timeout: 30_000 }); + await wlm.evaluate((el) => el.classList.remove('tc-collapsed')); + await expandNodeByTitle(page, NON_RADIO_ROOT_FOLDER_TITLE); + await expandNodeByTitle(page, RADIO_FOLDER_TITLE); + const radio = page.locator( + `#tc-slot-toc input.sitmun-lcat-radio[data-layer-name="${QUERYABLE_LEAF_NODE_ID}"]`, + ); + await expect(radio).toBeVisible({ timeout: 30_000 }); + await radio.click(); + await expect(page.locator('#tc-slot-wlm li.tc-ctl-wlm-elm[data-layer-id]')).toBeVisible({ + timeout: 90_000, + }); +} + +async function triggerMiaViaFeatureInfoCallback(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as { + TC?: { + Map?: { get: (el: Element) => { controls?: Array<{ responseCallback?: (o: unknown) => void }> } }; + control?: { FeatureInfo?: new () => unknown }; + }; + }; + const mapEl = document.querySelector('.tc-map'); + if (!w.TC?.Map?.get || !mapEl) { + throw new Error('TC.Map not available'); + } + const map = w.TC.Map.get(mapEl); + const FeatureInfo = w.TC.control?.FeatureInfo; + const fi = (map.controls || []).find( + (ctl) => FeatureInfo && ctl instanceof (FeatureInfo as unknown as Function), + ); + if (!fi?.responseCallback) { + throw new Error('FeatureInfo control with responseCallback not found'); + } + fi.responseCallback({ + services: [ + { + layers: [ + { + name: '34_TOPO_TX', + features: [ + { + getData: () => ({ id: 1, name: 'e2e-mia' }), + }, + ], + }, + ], + }, + ], + }); + }); +} + +test.describe('Viewer MIA render', () => { + test('opens MIA overlay and POSTs render with appId terId and lang', async ({ page }) => { + await page.route('**/api/tasks/template/more-info-advanced/render**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tasks: [ + { + taskId: MIA_PARENT_TASK_ID, + title: 'E2E MIA', + html: '

rendered

', + }, + ], + }), + }); + }); + + await loginAndOpenMap(page); + await loadQueryableLeafIntoCapas(page); + + const render = page.waitForRequest( + (request) => + request.method() === 'POST' && + request.url().includes('/api/tasks/template/more-info-advanced/render'), + ); + + await triggerMiaViaFeatureInfoCallback(page); + + const renderRequest = await render; + const url = new URL(renderRequest.url()); + expect(url.searchParams.get('lang')).toBe('ca'); + const body = renderRequest.postDataJSON() as { + appId?: number; + terId?: number; + miaTaskIds?: number[]; + }; + expect(body).toMatchObject({ + appId: APP_ID, + terId: TERRITORY_ID, + miaTaskIds: [MIA_PARENT_TASK_ID], + }); + + await expect(page.locator('.sitmun-mia-popup-overlay.sitmun-mia-popup-visible')).toBeVisible({ + timeout: 15_000, + }); + await expect(page.locator('[data-e2e-mia="1"]')).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/e2e/viewer/setup/viewer.setup.ts b/e2e/viewer/setup/viewer.setup.ts index 2b409df..9cd2e23 100644 --- a/e2e/viewer/setup/viewer.setup.ts +++ b/e2e/viewer/setup/viewer.setup.ts @@ -11,10 +11,13 @@ import { CONTACT_EMAIL, CONTACT_INSTITUTION, BASEMAP_SELECTOR_TASK_ID, + FEATURE_INFO_TASK_ID, FULL_SCREEN_TASK_ID, generateViewerPassword, LAYER_CATALOG_TASK_ID, LEGEND_TASK_ID, + MIA_CONTROL_TASK_ID, + MIA_PARENT_TASK_ID, NAV_BAR_TASK_ID, OVERVIEW_MAP_TASK_ID, QUERYABLE_LEAF_CARTOGRAPHY_ID, @@ -244,6 +247,9 @@ setup('provision viewer user and secured WMS service', async ({ request }) => { SEARCH_TASK_ID, STREET_VIEW_TASK_ID, THREE_D_TASK_ID, + FEATURE_INFO_TASK_ID, + MIA_CONTROL_TASK_ID, + MIA_PARENT_TASK_ID, ]) { const createAvailability = await request.post('/backend/api/task-availabilities', { headers: adminHeaders, diff --git a/front/admin/sitmun-admin-app b/front/admin/sitmun-admin-app index f53e329..de9a046 160000 --- a/front/admin/sitmun-admin-app +++ b/front/admin/sitmun-admin-app @@ -1 +1 @@ -Subproject commit f53e329739a2c6abad20af58bb48145ae6b6cf88 +Subproject commit de9a0467e73e5f5b88e3232b4d1e5ce0863632b6 diff --git a/front/viewer/sitmun-viewer-app b/front/viewer/sitmun-viewer-app index 77c6584..b71766e 160000 --- a/front/viewer/sitmun-viewer-app +++ b/front/viewer/sitmun-viewer-app @@ -1 +1 @@ -Subproject commit 77c658405e0f362acc8ff3a766e5d53e112d4b78 +Subproject commit b71766ee057c90247ec747f8d283011b122e19ac diff --git a/playwright.viewer.config.ts b/playwright.viewer.config.ts index e04b93c..8a6f481 100644 --- a/playwright.viewer.config.ts +++ b/playwright.viewer.config.ts @@ -35,9 +35,10 @@ export default defineConfig({ gracefulShutdown: { signal: 'SIGTERM', timeout: 5_000 }, }, { + // e2e-proxy waits for backend health before bootRun (parallel webServers). command: 'npm run e2e:proxy', url: 'http://localhost:18082/actuator/health', - timeout: 120_000, + timeout: 300_000, reuseExistingServer: false, gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 }, }, @@ -85,5 +86,11 @@ export default defineConfig({ dependencies: ['viewer-setup'], use: { ...devices['Desktop Chrome'] }, }, + { + name: 'viewer-mia', + testMatch: /mia-render\.spec\.ts/, + dependencies: ['viewer-setup'], + use: { ...devices['Desktop Chrome'] }, + }, ], }); diff --git a/scripts/e2e-proxy.mjs b/scripts/e2e-proxy.mjs index 6cb7da1..344c5c4 100644 --- a/scripts/e2e-proxy.mjs +++ b/scripts/e2e-proxy.mjs @@ -9,12 +9,32 @@ const stackRoot = resolve(__dirname, '..'); const proxyRoot = join(stackRoot, 'back', 'proxy', 'sitmun-proxy-middleware'); const isWindows = process.platform === 'win32'; const gradlew = join(proxyRoot, isWindows ? 'gradlew.bat' : 'gradlew'); +const BACKEND_HEALTH_URL = 'http://localhost:18080/api/dashboard/health'; +const BACKEND_WAIT_MS = 180_000; function fail(message) { console.error(`[e2e-proxy] ${message}`); process.exit(1); } +async function waitForBackend(url, timeoutMs) { + const start = Date.now(); + console.error(`[e2e-proxy] Waiting for backend at ${url}...`); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) { + console.error('[e2e-proxy] Backend is ready'); + return; + } + } catch { + // retry until timeout + } + await new Promise((r) => setTimeout(r, 2000)); + } + fail(`Timed out waiting for backend at ${url} (${timeoutMs}ms)`); +} + if (!existsSync(proxyRoot)) { fail(`Proxy submodule missing at ${proxyRoot}. Run: git submodule update --init --recursive`); } @@ -28,6 +48,9 @@ try { fail('Java is not available on PATH. Install Java 17 (or a JDK that Gradle can use for the Java 17 toolchain).'); } +// Playwright starts webServers in parallel; proxy config fetch needs backend first. +await waitForBackend(BACKEND_HEALTH_URL, BACKEND_WAIT_MS); + console.error('[e2e-proxy] Starting proxy middleware on port 18082...'); const child = spawn(gradlew, ['bootRun', '--no-daemon', '--args=--server.port=18082'], {