feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s

This commit is contained in:
dh
2026-10-06 17:40:45 +00:00
parent 5687596799
commit 372b997876
30 changed files with 4377 additions and 53 deletions
@@ -0,0 +1,119 @@
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 { chromium } = createRequire(import.meta.url)('playwright');
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 = '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>.forecast-chart{display:block;width:100%;height:320px}.forecast-chart-legend{border-bottom:1px solid black}</style><link rel="stylesheet" href="/netplan-v4.css"></head><body style="margin:16px"><svg id="legacy-chart" class="forecast-chart"></svg><div id="legacy-legend" class="forecast-chart-legend">Legacy</div><div id="host"></div><script type="module">import {mountPlanner} from "/netplan-v4.js"; window.mountPlanner=mountPlanner; window.planner=mountPlanner(document.querySelector("#host"),{plantId:"test",session:{csrfToken:"test-only"}});</script></body></html>';
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({ headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}) });
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; });
}
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');
assert.equal(await page.locator('select#plants').count(), 0);
assert.equal(requests.length, 1);
assert.match(await page.locator('#host').innerText(), /tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt/);
assert.match(await page.locator('#host').innerText(), /Vorhanden; lokale Schutzpruefung/);
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();
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('summary').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);
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));
}
});