Files
tilbudgivern/tests/ui-layout-regression.spec.js
2026-08-10 12:38:52 +02:00

231 lines
10 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const views = ['projects', 'planning', 'mobile', 'materials', 'smart-packages', 'openai-usage'];
async function openAuthenticatedView(page, view, viewport) {
await page.setViewportSize(viewport);
await page.addInitScript((selectedView) => {
window.localStorage.setItem('accessToken', 'ui-layout-test-token');
window.localStorage.setItem('user', JSON.stringify({ username: 'ui-layout-test' }));
window.localStorage.setItem('tilbudgivern_current_view', selectedView);
}, view);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('.App')).toBeVisible();
await page.waitForTimeout(view === 'planning' ? 2500 : 750);
}
async function expectLayoutInsideViewport(page, viewport) {
const geometry = await page.evaluate(() => {
const pageWidth = document.documentElement.scrollWidth;
const viewportWidth = window.innerWidth;
const header = document.querySelector('.header')?.getBoundingClientRect();
const statusElement = document.querySelector('.system-status-button');
const status = statusElement?.getBoundingClientRect();
const overlapCandidates = Array.from(document.querySelectorAll(
'.main-content .card, .main-content .panel, .main-content section, .usage-panel, .stat-card'
)).filter((element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' &&
style.position !== 'absolute' && style.position !== 'fixed' &&
rect.width > 20 && rect.height > 20;
});
const overlaps = [];
for (let index = 0; index < overlapCandidates.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < overlapCandidates.length; otherIndex += 1) {
const first = overlapCandidates[index];
const second = overlapCandidates[otherIndex];
if (first.parentElement !== second.parentElement) continue;
const a = first.getBoundingClientRect();
const b = second.getBoundingClientRect();
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (overlapX > 2 && overlapY > 2) {
overlaps.push(`${first.className || first.tagName} <> ${second.className || second.tagName}`);
}
}
}
return {
pageWidth,
viewportWidth,
statusInsideHeader: Boolean(statusElement?.closest('.header') && header && status &&
status.left >= header.left && status.right <= header.right &&
status.top >= header.top && status.bottom <= header.bottom),
overlaps
};
});
expect(geometry.pageWidth, `page width at ${viewport.width}px`).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.statusInsideHeader).toBe(true);
expect(geometry.overlaps).toEqual([]);
}
test.describe('professional responsive shell', () => {
test('login remains polished and contained at 360px', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Tilbudgivern' })).toBeVisible();
await expect(page.getByLabel('Brugernavn')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
const form = await page.locator('.login-form').boundingBox();
expect(form).not.toBeNull();
expect(form.x).toBeGreaterThanOrEqual(0);
expect(form.x + form.width).toBeLessThanOrEqual(360);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360);
});
for (const viewport of [
{ name: 'desktop', width: 1440, height: 1000 },
{ name: 'mobile', width: 360, height: 800 }
]) {
test(`${viewport.name} keeps every primary view inside the viewport`, async ({ page }) => {
for (const view of views) {
await openAuthenticatedView(page, view, viewport);
await expectLayoutInsideViewport(page, viewport);
if (view === 'smart-packages') {
await expect(page.getByRole('button', { name: 'Ny smart pakke', exact: true })).toBeVisible();
await expect(page.locator('.MuiFab-root')).toHaveCount(0);
}
}
});
}
test('mobile navigation and support dialog remain usable at 360px', async ({ page }) => {
const viewport = { width: 360, height: 800 };
await openAuthenticatedView(page, 'projects', viewport);
const menuButton = page.getByRole('button', { name: 'Menu' });
await expect(menuButton).toHaveAttribute('aria-expanded', 'false');
await menuButton.click();
await expect(menuButton).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('navigation', { name: 'Hovednavigation' })).toBeVisible();
await page.getByRole('button', { name: 'Åbn support' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const box = await dialog.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
});
test('AI usage table becomes labelled cards on mobile', async ({ page }) => {
await openAuthenticatedView(page, 'openai-usage', { width: 360, height: 800 });
const firstCell = page.locator('.usage-category-table td[data-label]').first();
await expect(firstCell).toBeVisible();
await expect(firstCell).toHaveCSS('display', 'grid');
await expect(firstCell).not.toHaveAttribute('data-label', '');
});
test('Smart Packages keeps desktop columns separate and shows every mobile import step', async ({ page }) => {
await openAuthenticatedView(page, 'smart-packages', { width: 1440, height: 1000 });
const geometry = await page.evaluate(() => {
const title = Array.from(document.querySelectorAll('h1, h2'))
.find((element) => element.textContent.trim() === 'Smart Pakker')
?.getBoundingClientRect();
const sidebar = Array.from(document.querySelectorAll('.MuiPaper-root'))
.find((element) => element.innerText.includes('Smart Pakker &'))
?.getBoundingClientRect();
return {
titleLeft: title?.left,
sidebarRight: sidebar?.right
};
});
expect(geometry.titleLeft).toBeGreaterThanOrEqual(geometry.sidebarRight);
await openAuthenticatedView(page, 'smart-packages', { width: 360, height: 800 });
const stepLabels = page.locator('.MuiStepLabel-label');
await expect(stepLabels).toHaveCount(6);
for (let index = 0; index < 6; index += 1) {
await expect(stepLabels.nth(index)).toBeVisible();
}
await expect(page.locator('.MuiStepper-root')).toHaveCSS('flex-direction', 'column');
});
test('planning loading copy stays horizontal while only the spinner rotates', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
window.localStorage.setItem('accessToken', 'ui-layout-test-token');
window.localStorage.setItem('user', JSON.stringify({ username: 'ui-layout-test' }));
window.localStorage.setItem('tilbudgivern_current_view', 'planning');
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
const loadingCopy = page.locator('.planning-loading-content');
await expect(loadingCopy).toBeVisible();
await expect(loadingCopy).toHaveCSS('animation-name', 'none');
await expect(loadingCopy).toHaveCSS('transform', 'none');
await expect(loadingCopy.locator('.spinner')).not.toHaveCSS('animation-name', 'none');
});
test('mobile filters are labelled and the order list has a bounded height', async ({ page }) => {
await openAuthenticatedView(page, 'mobile', { width: 360, height: 800 });
await expect(page.getByLabel('Filtrer efter ordretype')).toBeVisible();
const orderList = page.locator('.mobile-order-list');
await expect(orderList).toHaveCSS('max-height', '320px');
await expect(orderList).toHaveCSS('overflow-y', 'auto');
await openAuthenticatedView(page, 'materials', { width: 360, height: 800 });
await expect(page.getByLabel('Antal materialer per side')).toBeAttached();
});
test('continue in Project Flow renders Smart Package without a white screen', async ({ page }) => {
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.route('**/api/customer-projects/**', async (route) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(route.request().method())) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true })
});
return;
}
await route.continue();
});
await openAuthenticatedView(page, 'projects', { width: 1440, height: 1000 });
await page.getByRole('button', { name: 'Fortsæt', exact: true }).click();
await expect(page.getByRole('heading', { name: /Fortsæt til Smart Pakke/i })).toBeVisible();
await expect(page.locator('body')).not.toBeEmpty();
expect(pageErrors).toEqual([]);
});
test('customer search accepts a street address and shows the full address', async ({ page }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.route('**/api/customers/search**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
count: 1,
customers: [{
customerNumber: '2003',
name: 'Alice',
address: 'Hornumvej 10',
postalCode: '4600',
city: 'Køge'
}]
})
});
});
await openAuthenticatedView(page, 'projects', { width: 1440, height: 1000 });
await page.evaluate(() => {
Array.from(document.querySelectorAll('button'))
.find((button) => button.textContent.includes('Nyt Projekt'))
?.click();
});
const customerSearch = page.getByLabel('Søg efter kunde på navn, kundenummer eller adresse');
await expect(customerSearch).toBeVisible();
await customerSearch.fill('Hornumvej 10');
await expect(page.getByText('📍 Hornumvej 10, 4600 Køge')).toBeVisible();
});
});