import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; const require = createRequire(process.env.BROWSER_PACKAGE || import.meta.url); const { chromium } = require('playwright'); const browserOptions = { headless: true }; if (process.env.BROWSER_EXECUTABLE) { browserOptions.executablePath = process.env.BROWSER_EXECUTABLE; const loaded = require('@sparticuz/chromium'); const standalone = loaded.default || loaded; browserOptions.args = standalone.args.filter(arg => ![ '--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials', '--no-sandbox', '--disable-setuid-sandbox' ].includes(arg) && !arg.startsWith('--disable-features=')); } else if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { browserOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; } const stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(); const state = { checkedAt: stamp(0), controlledTrialAuthorized: true, fresh: true, settings: { revision: 2, family: '3', trainingCadence: 'daily' }, families: [{ key: '3', label: 'Testfamilie' }], plan: { planId: 'test-plan', sourceFamily: '3', configRevision: 2, status: 'optimal', runMode: 'shadow', generatedAt: stamp(0), validUntil: stamp(60), forecastUntil: stamp(120), pricesKnownUntil: stamp(60), inputQuality: { loadBasis: 'base_load' }, forecastPoints: Array.from({ length: 24 }, (_, index) => ({ time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, externalW: index === 4 ? null : 200 })), points: Array.from({ length: 12 }, (_, index) => ({ time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, externalW: index === 4 ? null : 200, gridTargetW: index * -50 + 400, baselineGridW: 1400 - index * 150, batteryTargetW: index * 100 - 800, socEndPercent: { battery: 50 + index }, importPriceChfKwh: .2512, exportPriceChfKwh: .0801, cumulativeCashCostChf: index * .15, cumulativeBaselineCashCostChf: index * .25 })) } }; test('embedded planner: responsive charts, keyboard/pointer inspection, toggles and lifecycle', async () => { const errors = [], requests = []; const html = '
Legacy
'; const server = createServer(async (request, response) => { try { if (request.url === '/') { response.setHeader('Content-Type', 'text/html'); response.end(html); return; } if (!['/netplan-v4.js', '/forecast-chart.js', '/netplan-v4.css'].includes(request.url)) { response.writeHead(404); response.end(); return; } response.setHeader('Content-Type', request.url.endsWith('.css') ? 'text/css' : 'text/javascript'); response.end(await readFile(new URL('.' + request.url, import.meta.url))); } catch { response.writeHead(500); response.end(); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); let browser; try { browser = await chromium.launch(browserOptions); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); page.on('pageerror', error => errors.push(error.message)); await page.route('**/api/**', async route => { const request = route.request(); requests.push({ url: request.url(), method: request.method(), csrf: request.headers()['x-csrf-token'] }); if (request.url().includes('/slow/')) await new Promise(resolve => setTimeout(resolve, 150)); const result = structuredClone(state); if (request.url().includes('/legacy/')) delete result.plan.forecastPoints; if (request.url().includes('/house-total/')) { result.plan.inputQuality.loadBasis = 'house_total'; result.plan.forecastPoints.forEach(point => { point.externalW = null; }); } const scenario = new URL(request.url()).pathname.split('/')[3]; if (['disabled', 'collecting', 'learning', 'ready', 'active', 'interrupted', 'expired', 'contradictory', 'no-plan', 'hostile-reason'].includes(scenario)) { const workflowState = scenario === 'expired' || scenario === 'contradictory' ? 'active' : scenario === 'no-plan' ? 'collecting' : scenario === 'hostile-reason' ? 'interrupted' : scenario; const now = Date.now(); result.workflow = { state: workflowState, learningEnabled: scenario !== 'disabled', controlRequested: workflowState === 'active', controlActive: workflowState === 'active' && scenario !== 'contradictory', planningReady: ['ready', 'active'].includes(workflowState), reportFresh: true, checkedAt: new Date(now).toISOString(), sourceReportAt: new Date(now - 1000).toISOString(), sourceFreshUntil: new Date(now + (scenario === 'expired' ? -1 : 119000)).toISOString(), reason: scenario === 'hostile-reason' ? '' : scenario === 'collecting' ? 'collecting_measurements' : '' }; if (scenario === 'no-plan') result.plan = null; } await route.fulfill({ contentType: 'application/json', body: JSON.stringify(request.method() === 'PUT' ? { revision: 3 } : result) }); }); await page.goto(`http://127.0.0.1:${server.address().port}/`); await page.waitForSelector('.forecast-chart svg'); assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6); assert.equal(await page.locator('#legacy-chart').evaluate(node => getComputedStyle(node).height), '320px'); assert.equal(await page.locator('#legacy-legend').evaluate(node => getComputedStyle(node).borderBottomWidth), '1px'); await page.locator('#legacy-chart, #legacy-legend').evaluateAll(nodes => { for (const node of nodes) node.style.display = 'none'; }); assert.equal(await page.locator('select#plants').count(), 0); assert.equal(requests.length, 1); assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Betriebsstatus nicht bestaetigt'); assert.match(await page.locator('#host').innerText(), /berechnete Plaene, keine nachgewiesene Ausfuehrung/); assert.equal(await page.locator('.forecast-planner details[open]').count(), 0); assert.deepEqual(await page.locator('.forecast-planner .forecast-chart h3').allTextContents(), ['PV- und Hausprognose', 'Strompreis Bezug und Einspeisung', 'Netzfahrplan', 'Batterieleistung', 'Ladezustand battery', 'Kumulierte Kosten inklusive vollem Peakpreis']); const chart = page.locator('.forecast-planner .forecast-chart').first(); const slider = chart.locator('input[type=range]'); assert.equal(await slider.getAttribute('max'), '23'); assert.equal(await page.locator('.forecast-planner .forecast-chart').nth(1).locator('input[type=range]').getAttribute('max'), '11'); assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Vollstaendige/); await slider.focus(); await slider.press('ArrowRight'); assert.equal(await slider.inputValue(), '1'); assert.match(await slider.getAttribute('aria-valuetext'), /1.350 W/); await chart.getByLabel('PV-Prognose', { exact: true }).uncheck(); assert.equal(await chart.locator('dt').count(), 2); await chart.getByLabel('PV-Prognose', { exact: true }).check(); assert.equal(requests.length, 1, 'chart inspection and visibility never change planner settings or controls'); await chart.locator('svg').scrollIntoViewIfNeeded(); const bounds = await chart.locator('svg').boundingBox(); await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70); assert.equal(await slider.inputValue(), '23'); for (const width of [1280, 390, 320]) { await page.setViewportSize({ width, height: 900 }); await chart.scrollIntoViewIfNeeded(); await page.waitForTimeout(60); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `no horizontal overflow at ${width}`); assert.ok((await chart.boundingBox()).height > 320, 'legacy SVG height must not constrain new chart sections'); assert.ok(await chart.locator('polyline').count() >= 3); if (process.env.FORECAST_SCREENSHOT_DIR) { await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) }); await page.locator('.forecast-workflow').screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-workflow-${width}.png`) }); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-viewport-${width}.png`) }); await page.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-full-${width}.png`), fullPage: true }); } } await page.getByText('Erweiterte Planungseinstellungen', { exact: true }).click(); await page.getByRole('button', { name: 'Speichern und neu berechnen', exact: true }).click(); await page.getByRole('status').filter({ hasText: 'Revision 3 gespeichert' }).waitFor(); assert.equal(requests.at(-1).csrf, 'test-only'); assert.equal(requests.some(request => /trial|arm|live/.test(request.url)), false); const showPlant = async plantId => page.evaluate(async id => { window.planner.dispose(); window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: id, session: {} }); await window.planner.ready; }, plantId); for (const [scenario, label] of Object.entries({ disabled: 'Ausgeschaltet', collecting: 'Sammelt Daten', learning: 'Lernt', ready: 'Bereit', active: 'Aktiv', interrupted: 'Unterbrochen', expired: 'Unterbrochen', contradictory: 'Betriebsstatus nicht bestaetigt', 'no-plan': 'Sammelt Daten' })) { await showPlant(scenario); assert.equal(await page.locator('.forecast-workflow-state').innerText(), label, scenario); assert.equal(await page.locator('.forecast-planner details[open]').count(), 0, 'technical details remain collapsed'); if (scenario === 'no-plan') assert.match(await page.locator('#host').innerText(), /Noch kein Netzfahrplan/); } await showPlant('hostile-reason'); assert.equal(await page.locator('.forecast-workflow-reason img').count(), 0); assert.match(await page.locator('.forecast-workflow-reason').innerText(), / window.injected), undefined); await page.clock.install(); await showPlant('active'); assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Aktiv'); await page.clock.fastForward(120000); assert.equal(await page.locator('.forecast-workflow-state').innerText(), 'Unterbrochen', 'open pages expire the local report without hiding charts'); assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6); await page.clock.resume(); await page.evaluate(async () => { window.planner.dispose(); window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} }); await window.planner.ready; }); assert.equal(await slider.getAttribute('max'), '11'); assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Aelterer Plan/); await page.evaluate(async () => { window.planner.dispose(); window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'house-total', session: {} }); await window.planner.ready; }); assert.equal(await chart.locator('dd').nth(2).innerText(), 'nicht vorhanden'); await page.evaluate(async () => { window.planner.dispose(); window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'slow', session: {} }); window.planner.dispose(); await window.planner.ready; }); assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 0); assert.deepEqual(errors, []); } finally { await browser?.close(); await new Promise(resolve => server.close(resolve)); } });