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 0c82479..8086077 100644
--- a/e2e/viewer/fixtures.ts
+++ b/e2e/viewer/fixtures.ts
@@ -15,6 +15,24 @@ 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;
+/** 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/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/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 bca7438..9cd2e23 100644
--- a/e2e/viewer/setup/viewer.setup.ts
+++ b/e2e/viewer/setup/viewer.setup.ts
@@ -11,14 +11,23 @@ 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,
+ 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 +234,22 @@ 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,
+ 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 82a25b2..b71766e 160000
--- a/front/viewer/sitmun-viewer-app
+++ b/front/viewer/sitmun-viewer-app
@@ -1 +1 @@
-Subproject commit 82a25b2999dac9f099385a12856397bac7feb1f0
+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'], {