Files
tilbudgivern/tests/degree-text-position.spec.js
T
f37adae2cb
CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* feat: move login credentials to a DB-backed users table with an admin management page

Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new
auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds
admin-only /api/users CRUD routes and a "Brugere" admin page in the
frontend for managing logins without redeploying. Removes the unused,
unmounted duplicate login route in src/routes/auth.js.

* docs: add architecture codemaps with diagrams for the whole system

Adds codemaps/architecture.md, backend.md, frontend.md, and data.md —
Mermaid-diagrammed design documentation verified against the live
codebase and database rather than assumed from CLAUDE.md. Covers the
unified-server.js request flow (mounted routers + ~183 inline routes),
68 backend services grouped by domain, the frontend's state-driven
view-switch (no React Router in practice despite BrowserRouter being
present), and the full 122-table DB schema with the auth_accounts vs
unrelated users table naming trap flagged explicitly. Links added from
the root README.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat: ship canonical roof quote workflow

* fix: keep migration dry-run idempotent

* [verified] feat: complete Smart Pakker management

* [verified] fix: ignore blank task dependencies

* [verified] fix: align package duplication with schema

* [verified] fix: enforce Discord status limits

* [verified] fix: link Smart Pakke materials safely

* [verified] fix: harden material link review

* [verified] feat: improve material matching

* fix: scope pitch validation to roof packages

* fix: support canonical snapshots on production schema

* [verified] fix: hide internal package metadata from PDF

* [verified] feat: deliver sales-ready customer PDF

* [verified] feat: ship sales-ready PDF with AI overview

* [verified] fix: authenticate project list requests

* [verified] fix: refresh project-list authentication

* [verified] fix: open existing project details

* [verified] fix: keep roof components searchable in builder

* [verified] fix: expose all Smart Package categories

* [verified] fix: authenticate project creation

* [verified] feat: make Smart Pakker the universal project flow

* [verified] feat: preview Smart Package contents

* [verified] test: keep generic release isolated from downpipe work

* feat: add first-class Smart Pakke rentals

* [verified] feat: add gutter and downpipe smart packages

* [verified] fix: prepare six-house gutter quote flow

* [verified] fix: open generic quotes without roof geometry

* [verified] fix: review generic quotes with authenticated APIs

* [verified] fix: calculate generic Smart Package quotes

* [verified] fix: return generic calculation breakdown

* feat: checkpoint generic signed snapshot validation with red-green tests

* feat: complete fail-closed generic quote approval and customer PDF flow

* feat: use generic signed snapshot in final review

* feat: redesign generic quote final review

* fix: harden generic review summaries

* feat: add auditable six-house package basis

* [verified] feat: finish auditable Smart Pakke UI

* [verified] fix: bind auditable quantity and price bases

* [verified] fix: keep six-house basis across package versions

* [verified] fix: complete smart package discovery management

* [verified] fix: simplify composition and generic scope

* [verified] test: keep explicit roof contracts fail closed

* [verified] fix: harden generic quote snapshots

* fix: make generic quote delivery customer safe

* [verified] fix: secure package catalog reads

* [verified] fix: close workspace provenance blockers

* fix: harden customer document language boundary

* [verified] fix: secure smart package internal reads

* fix: version package child mutations atomically

* feat: add generic customer quote text flow

* [verified] fix: allow manual customer numbers

* [verified] fix: expose optional roof geometry

* [verified] fix: rebase hydrated packages after geometry edits

* [verified] feat: add free editable site area map

* [verified] fix: harden map recovery and geocoding gate

* fix: bind map quantities to authoritative geometry

* fix: release geocoder lock before dispatch

* fix: separate roof and site geometry provenance

* fix: revoke stale admin authorization

* fix: migrate task geometry basis

* fix: make backend CI dependency-complete

* ci: seed isolated e2e login account

* fix: allow clean database bootstrap

* fix: skip indexes for optional tables

* test: use canonical mansard geometry in e2e

* [verified] fix(auth): enforce live operator boundary

* fix: fail close Ordrestyring offer transport

* fix(frontend): authenticate customer project requests

* fix: align canonical roof type contract

* [verified] fix: reconcile legacy package labor safely

* [verified] fix: audit site geometry deletion

* docs: add PR 31 reviewer guide

* docs: synchronize Obsidian vault

* docs: sync integrated reviewer guide to Obsidian

* ci: seed isolated auth account explicitly

* fix: close offer bootstrap and service readiness gaps

* fix: authenticate protected package callers

* fix: provision initial admin and disable generic send

* [verified] fix: close final quote release blockers

* [verified] fix: seed gutter packages before deployment

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-09-26 22:39:18 +02:00

426 lines
17 KiB
JavaScript

// @ts-check
const { test, expect } = require('@playwright/test');
/**
* E2E Test: Verify the red degree text in the roof geometry SVG
* is positioned to the LEFT of the red arc line, not overlapping it.
*
* For a sadeltag with bredde=8, laengde=10, taghaeldning=25:
* - Red arc starts at x = leftX + 40 (where leftX = 50 for sadeltag)
* - Red text is at x = leftX + 5
* - Therefore text x (55) < arc start x (90), confirming LEFT placement.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://tilbudsgiveren.alw.dk';
async function loginIfNeeded(page) {
const loginHeading = page.getByRole('heading', { name: /Log ind/i });
const isLogin = await loginHeading.isVisible().catch(() => false);
if (!isLogin) {
console.log('Already logged in');
return;
}
const username = process.env.PLAYWRIGHT_USERNAME || 'toemrer';
const password = process.env.PLAYWRIGHT_PASSWORD || 'tilbud2024';
console.log('Logging in as:', username);
const userField = page.locator('input[type="text"]').first();
const passField = page.locator('input[type="password"]').first();
const loginButton = page.getByRole('button', { name: /Log ind/i });
await userField.fill(username);
await passField.fill(password);
await loginButton.click();
await page.waitForTimeout(3000);
const stillOnLogin = await loginHeading.isVisible().catch(() => false);
if (stillOnLogin) {
throw new Error('Login failed - check server JWT configuration');
}
console.log('Login successful');
}
async function ensureProjectSelected(page) {
// First try to select an existing project from the "Eksisterende" tab
const eksisterendeTab = page.locator('button:has-text("Eksisterende")').first();
const tabVisible = await eksisterendeTab.isVisible().catch(() => false);
if (tabVisible) {
console.log('Found Eksisterende tab, clicking it...');
await eksisterendeTab.click();
await page.waitForTimeout(1500);
// Look for a "Fortsaet projekt" button on any existing project card
const continueButton = page.locator('button:has-text("Fortsæt projekt"), .continue-btn').first();
const hasContinue = await continueButton.isVisible().catch(() => false);
if (hasContinue) {
console.log('Selecting existing project via Fortsaet projekt...');
await continueButton.click();
await page.waitForTimeout(2000);
return true;
}
// Or try clicking on a project card body
const projectCard = page.locator('.project-card-body').first();
const hasCard = await projectCard.isVisible().catch(() => false);
if (hasCard) {
console.log('Clicking on project card...');
await projectCard.click();
await page.waitForTimeout(2000);
return true;
}
}
// No existing projects. Switch to new project tab and create one.
console.log('No existing projects found. Creating a new one...');
// Click on the "Nyt Projekt" or new project tab
const newProjectTab = page.locator('button:has-text("Nyt Projekt"), button:has-text("Opret")').first();
const newTabVisible = await newProjectTab.isVisible().catch(() => false);
if (newTabVisible) {
await newProjectTab.click();
await page.waitForTimeout(1000);
}
// Fill in required fields using name attribute
const projectNameInput = page.locator('input[name="projectName"]');
const customerNameInput = page.locator('input[name="customerName"]');
if (await projectNameInput.isVisible().catch(() => false)) {
await projectNameInput.fill('Test Geometri SVG');
}
if (await customerNameInput.isVisible().catch(() => false)) {
await customerNameInput.fill('Test Kunde');
}
// Click "Gem Projekt" button to save
const saveButton = page.locator('button:has-text("Gem Projekt")');
const saveVisible = await saveButton.isVisible().catch(() => false);
if (saveVisible) {
console.log('Saving new project...');
await saveButton.click();
await page.waitForTimeout(3000);
return true;
}
return false;
}
test.describe('Degree Text Position in Roof SVG', () => {
test.setTimeout(60000);
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL + '/');
await page.waitForLoadState('networkidle');
await loginIfNeeded(page);
await page.waitForTimeout(1000);
});
test('Red degree text is positioned to the LEFT of the red arc in sadeltag SVG', async ({ page }) => {
// Step 1: Ensure a project is selected so we can navigate to Geometri
console.log('Step 1: Ensuring a project is selected...');
const projectSelected = await ensureProjectSelected(page);
console.log('Project selection result:', projectSelected);
// Wait for the step navigation to stabilize
await page.waitForTimeout(2000);
// Take a debug screenshot to see current state
await page.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-step1-debug.png',
fullPage: true
});
// Step 2: Navigate to the Geometri step (step 2 in the stepper)
console.log('Step 2: Navigating to Geometri step...');
// Look for the step navigation. Steps have class "step" with text content.
// The Geometri step should be step 2 with icon and text.
const geometriStep = page.locator('.step').filter({ hasText: 'Geometri' }).first();
const geometriVisible = await geometriStep.isVisible().catch(() => false);
if (geometriVisible) {
console.log('Found Geometri step, clicking...');
await geometriStep.click();
} else {
// Try clicking step 2 by index
console.log('Trying to click step 2 by index...');
const allSteps = page.locator('.step');
const count = await allSteps.count();
console.log('Total steps found:', count);
if (count >= 2) {
await allSteps.nth(1).click();
}
}
await page.waitForTimeout(3000);
// Take debug screenshot after clicking Geometri
await page.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-step2-debug.png',
fullPage: true
});
// Step 3: Fill in roof dimensions
console.log('Step 3: Filling in roof dimensions...');
// Wait for the geometry form to appear - look for "Tagtype:" label or the select
// The form might need some time to load
const tagtypeLabel = page.locator('label:has-text("Tagtype")');
const tagtypeVisible = await tagtypeLabel.isVisible({ timeout: 5000 }).catch(() => false);
if (!tagtypeVisible) {
// Maybe we are still on the project form. Try finding via broader locators.
console.log('Tagtype label not found. Checking current page state...');
const bodyText = await page.locator('body').innerText();
console.log('Page content snippet:', bodyText.substring(0, 500));
// Attempt to save the project form first if still on it
const gemProjekt = page.locator('button:has-text("Gem Projekt")');
if (await gemProjekt.isVisible().catch(() => false)) {
console.log('Still on project form, filling required fields and saving...');
const projectNameInput = page.locator('input[name="projectName"]');
const customerNameInput = page.locator('input[name="customerName"]');
if (await projectNameInput.isVisible().catch(() => false)) {
await projectNameInput.fill('Test Geometri SVG');
}
if (await customerNameInput.isVisible().catch(() => false)) {
await customerNameInput.fill('Test Kunde');
}
await gemProjekt.click();
await page.waitForTimeout(3000);
// Now try clicking Geometri again
const geometriStepRetry = page.locator('.step').filter({ hasText: 'Geometri' }).first();
if (await geometriStepRetry.isVisible().catch(() => false)) {
await geometriStepRetry.click();
await page.waitForTimeout(3000);
}
}
}
// Now verify we are on the geometry page (use .first() since both labels may be visible)
await expect(page.locator('label:has-text("Tagtype")').first()).toBeVisible({ timeout: 15000 });
console.log('Successfully on the Geometri page');
// Set roof type to sadeltag (should be default, but be explicit)
const roofTypeSelect = page.locator('select').first();
await roofTypeSelect.selectOption('gable');
await page.waitForTimeout(500);
// Fill in bredde = 8
// The number inputs in order are: bredde, laengde, taghaeldning, ridgeHeight, wallHeight, roofCovering
// But we need to target by the label context
const breddeLabel = page.locator('label:has-text("Bredde")');
const breddeInput = breddeLabel.locator('..').locator('input[type="number"]');
// Fallback: use nth index within the geometry form
const numberInputs = page.locator('.geometry-form input[type="number"], .form-group input[type="number"]');
const inputCount = await numberInputs.count();
console.log('Found', inputCount, 'number inputs');
// Fill bredde (first number input)
await numberInputs.nth(0).fill('8');
await page.waitForTimeout(300);
// Fill laengde (second number input)
await numberInputs.nth(1).fill('10');
await page.waitForTimeout(300);
// Fill taghaeldning (third number input)
await numberInputs.nth(2).fill('25');
await page.waitForTimeout(2000);
console.log('Dimensions entered: bredde=8, laengde=10, taghaeldning=25');
// Wait for SVG to render
await page.waitForTimeout(2000);
// Take full page screenshot
await page.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-position-full.png',
fullPage: true
});
console.log('Full page screenshot saved');
// Step 4: Find and screenshot the SVG visualization
console.log('Step 4: Analyzing SVG visualization...');
const svgContainer = page.locator('[role="img"][aria-label*="Roof visualization"]');
const svgContainerVisible = await svgContainer.isVisible().catch(() => false);
if (svgContainerVisible) {
await svgContainer.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-position-svg.png'
});
console.log('SVG container screenshot saved');
} else {
// Try to find any SVG on the page
const svgEl = page.locator('svg').first();
if (await svgEl.isVisible().catch(() => false)) {
await svgEl.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-position-svg.png'
});
console.log('SVG element screenshot saved (fallback)');
}
}
// Step 5: Analyze SVG for degree text and red arc positioning
// Debug: extract the full SVG source to see what code generated it
const svgSource = await page.evaluate(() => {
const container = document.querySelector('[role="img"][aria-label*="Roof visualization"]');
return container ? container.innerHTML.substring(0, 500) : 'NOT FOUND';
});
console.log('SVG source (first 500 chars):', svgSource);
console.log('Step 5: Analyzing degree text vs red arc position...');
const svgAnalysis = await page.evaluate(() => {
const svgs = document.querySelectorAll('svg');
const results = [];
for (const svg of svgs) {
// Find red text elements with degree symbol
const textElements = svg.querySelectorAll('text');
const redTexts = [];
for (const t of textElements) {
const fill = (t.getAttribute('fill') || '').toLowerCase();
if (fill === '#ff0000' || fill === 'red') {
const textContent = (t.textContent || '').trim();
if (textContent.includes('\u00B0')) {
const textX = parseFloat(t.getAttribute('x') || '0');
const textY = parseFloat(t.getAttribute('y') || '0');
const textAnchor = t.getAttribute('text-anchor') || 'start';
let bbox = null;
try {
const b = t.getBBox();
bbox = { x: b.x, y: b.y, width: b.width, height: b.height, right: b.x + b.width };
} catch (e) { /* ignore */ }
redTexts.push({ content: textContent, x: textX, y: textY, textAnchor, bbox });
}
}
}
// Find red arc/path elements
const pathElements = svg.querySelectorAll('path');
const redArcs = [];
for (const p of pathElements) {
const stroke = (p.getAttribute('stroke') || '').toLowerCase();
if (stroke === '#ff0000' || stroke === 'red') {
const d = p.getAttribute('d') || '';
const mMatch = d.match(/M\s+([\d.]+)\s+([\d.]+)/);
const startX = mMatch ? parseFloat(mMatch[1]) : null;
const startY = mMatch ? parseFloat(mMatch[2]) : null;
let bbox = null;
try {
const b = p.getBBox();
bbox = { x: b.x, y: b.y, width: b.width, height: b.height, right: b.x + b.width };
} catch (e) { /* ignore */ }
redArcs.push({ d: d.substring(0, 120), startX, startY, bbox });
}
}
if (redTexts.length > 0 || redArcs.length > 0) {
results.push({
viewBox: svg.getAttribute('viewBox'),
redTexts,
redArcs
});
}
}
return results;
});
console.log('SVG Analysis:', JSON.stringify(svgAnalysis, null, 2));
// Assertions
expect(svgAnalysis.length).toBeGreaterThan(0);
const roofSvg = svgAnalysis[0];
expect(roofSvg.redTexts.length).toBeGreaterThan(0);
expect(roofSvg.redArcs.length).toBeGreaterThan(0);
const degreeText = roofSvg.redTexts[0];
const redArc = roofSvg.redArcs[0];
console.log('Degree text element:', JSON.stringify(degreeText));
console.log('Red arc element:', JSON.stringify(redArc));
// Verify degree text shows "25"
expect(degreeText.content).toContain('25');
console.log('VERIFIED: Degree text contains "25"');
// CORE ASSERTION: The degree text must be positioned to the LEFT of the red arc,
// not overlapping it. "To the left" means the text's bounding box right edge
// should not extend into the arc's bounding box.
//
// From the sadeltag source code:
// text x = leftX + 5 (text-anchor="start", so text extends rightward)
// arc path M x = leftX + 40 (arc starts here and curves upward-right)
//
// The text's right edge (x + width) should ideally be less than or equal to
// the arc's left edge (arc bbox x) for a clean "left of" positioning.
const textXPosition = degreeText.x;
const arcStartX = redArc.startX;
console.log(`Text X position (start): ${textXPosition}`);
console.log(`Arc start X (path M): ${arcStartX}`);
if (degreeText.bbox && redArc.bbox) {
const textRightEdge = degreeText.bbox.right;
const arcLeftEdge = redArc.bbox.x;
console.log(`Text bbox: x=${degreeText.bbox.x}, right=${textRightEdge}, width=${degreeText.bbox.width}`);
console.log(`Arc bbox: x=${arcLeftEdge}, right=${redArc.bbox.right}, width=${redArc.bbox.width}`);
// Check 1: The text should start at or before the arc start
// (text-anchor is "start" so text x is the left edge of the text)
const textStartsBeforeArc = textXPosition <= arcStartX;
console.log(`Check 1 - Text starts before arc: ${textStartsBeforeArc} (text x=${textXPosition}, arc start=${arcStartX})`);
// Check 2: The text's right edge should not extend past the arc's left boundary
// This is the key "not overlapping" check
const textDoesNotOverlapArc = textRightEdge <= arcLeftEdge;
console.log(`Check 2 - Text does not overlap arc: ${textDoesNotOverlapArc} (text right=${textRightEdge.toFixed(1)}, arc left=${arcLeftEdge.toFixed(1)})`);
// Check 3: Even if there is slight x-overlap, check if text is positioned
// primarily to the left (text center x < arc center x)
const textCenterX = degreeText.bbox.x + degreeText.bbox.width / 2;
const arcCenterX = redArc.bbox.x + redArc.bbox.width / 2;
const textCenterIsLeft = textCenterX < arcCenterX;
console.log(`Check 3 - Text center left of arc center: ${textCenterIsLeft} (text center=${textCenterX.toFixed(1)}, arc center=${arcCenterX.toFixed(1)})`);
// PASS if: text starts before or at arc start AND text right edge does not
// extend past the arc's midpoint (allowing small overlap at boundary)
const arcMidX = arcLeftEdge + (redArc.bbox.width / 2);
const noMajorOverlap = textRightEdge <= arcMidX;
console.log(`Check 4 - No major overlap (text right <= arc midpoint): ${noMajorOverlap} (text right=${textRightEdge.toFixed(1)}, arc mid=${arcMidX.toFixed(1)})`);
// Assert the text is to the left of the arc (text right edge does not extend
// past the arc's left edge, meaning no overlap)
expect(textRightEdge).toBeLessThanOrEqual(arcLeftEdge);
console.log('VERIFIED: Text is cleanly to the LEFT of the arc with no overlap');
} else {
// Fallback: just check x positions if no bbox available
expect(textXPosition).toBeLessThan(arcStartX);
console.log('VERIFIED (fallback): Text x-position is to the LEFT of arc start');
}
// Final verification screenshot
await page.screenshot({
path: '/mnt/HC_Volume_103713257/tilbudgivern/tests/test-results/degree-text-position-verified.png',
fullPage: false
});
console.log('TEST PASSED: Red degree text is correctly positioned to the LEFT of the red arc');
});
});