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,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('./forecast-chart.js', import.meta.url), 'utf8');
const { finiteValue, formatNumber, formatTime, chartDomain, seriesSegments, pointIndexAt, forecastSeries } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
const point = (minute, value, duration = 5) => ({ time: new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(), validUntil: new Date(Date.UTC(2026, 9, 6, 10, minute + duration)).toISOString(), value });
const series = { value: row => row.value };
test('missing values are not fabricated as zero', () => {
for (const value of [null, undefined, '12', NaN, Infinity]) assert.equal(finiteValue(value), false);
assert.equal(finiteValue(0), true);
assert.equal(formatNumber(null), 'nicht vorhanden');
assert.equal(chartDomain([point(0, null)], [series]).valid, false);
});
test('domains include negative values and interval ends', () => {
const result = chartDomain([point(0, -10), point(5, 20)], [series]);
assert.equal(result.low, -10); assert.equal(result.high, 20);
assert.equal(result.end - result.start, 600000);
assert.equal(chartDomain([], [series]), null);
assert.equal(chartDomain([{ time: 'invalid', value: 5 }], [series]), null);
});
test('power curves cover entire intervals and split missing values', () => {
const rows = [point(0, 5), point(5, null), point(10, 7)];
const segments = seriesSegments(rows, series, value => value, value => value);
assert.equal(segments.length, 2); assert.equal(segments[0].length, 2);
assert.equal(segments[0][1][0], Date.parse(rows[0].validUntil));
});
test('time gaps and reversed intervals do not join curves', () => {
assert.equal(seriesSegments([point(0, 5), point(10, 7)], series, x => x, y => y).length, 2);
assert.equal(seriesSegments([point(0, 5, -1)], series, x => x, y => y).length, 0);
});
test('cumulative and SOC samples use the interval end', () => {
const row = point(0, 7);
assert.deepEqual(seriesSegments([row], { ...series, position: 'end' }, x => x, y => y), [[[Date.parse(row.validUntil), 7]]]);
});
test('pointer selection identifies the containing interval and clamps endpoints', () => {
const rows = [point(0, 1), point(5, 2), point(10, 3)];
assert.equal(pointIndexAt(rows, Date.parse(rows[0].time) - 100), 0);
assert.equal(pointIndexAt(rows, Date.parse(rows[1].time)), 1);
assert.equal(pointIndexAt(rows, Date.parse(rows[1].time) + 1000), 1);
assert.equal(pointIndexAt(rows, Date.parse(rows[2].validUntil) + 100), 2);
});
test('time labels use Zurich and reject invalid timestamps', () => {
assert.match(formatTime('2026-10-06T10:00:00Z'), /12:00/);
assert.equal(formatTime('invalid'), 'nicht vorhanden');
});
test('full forecast horizon takes precedence without extending optimized plan points', () => {
const optimized = [point(0, 1)], full = [point(0, 1), point(5, 2)];
const plan = { points: optimized, forecastPoints: full };
assert.deepEqual(forecastSeries(plan), { fullHorizon: true, points: full });
assert.equal(plan.points.length, 1);
});
test('older or empty forecasts fall back to explicitly limited optimized points', () => {
const optimized = [point(0, 1)];
for (const forecastPoints of [undefined, null, [], {}, 'invalid']) {
assert.deepEqual(forecastSeries({ points: optimized, forecastPoints }), { fullHorizon: false, points: optimized });
}
assert.deepEqual(forecastSeries({}), { fullHorizon: false, points: [] });
});
test('chart styles stay scoped to the planner, including grouped and mobile selectors', async () => {
const css = await readFile(new URL('./netplan-v4.css', import.meta.url), 'utf8');
const selectors = [...css.matchAll(/([^{}]+)\{/g)].flatMap(match => match[1].split(',').map(selector => selector.trim()));
const chartSelectors = selectors.filter(selector => selector.includes('.forecast-chart'));
assert.ok(chartSelectors.length > 20, 'chart selectors are present and checked');
for (const selector of chartSelectors) assert.match(selector, /^\.forecast-planner\s+/, `unscoped chart selector: ${selector}`);
assert.match(css, /\.forecast-planner \.forecast-chart\s*\{[^}]*height:\s*auto\s*;/, 'legacy SVG height must not constrain the new chart sections');
});