Tests / test (push) Successful in 1m1s
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
180 lines
12 KiB
JavaScript
180 lines
12 KiB
JavaScript
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 = '<!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(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' ? '<img src=x onerror="window.injected=true">' : 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(), /<img/);
|
|
assert.equal(await page.evaluate(() => 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));
|
|
}
|
|
});
|