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.
232 lines
14 KiB
JavaScript
232 lines
14 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import vm from 'node:vm';
|
|
|
|
const source = await readFile(new URL('./public/app.js', import.meta.url), 'utf8');
|
|
const html = await readFile(new URL('./public/index.html', import.meta.url), 'utf8');
|
|
const plannerSource = await readFile(new URL('./public/netplan-v4.js', import.meta.url), 'utf8');
|
|
|
|
function section(start, end) {
|
|
const first = source.indexOf(start), last = source.indexOf(end, first + start.length);
|
|
assert.ok(first >= 0 && last > first, `integration source boundaries: ${start}`);
|
|
return source.slice(first, last);
|
|
}
|
|
const mountSource = section(' var integratedPlanner = null;', ' function faultDate(');
|
|
const viewSource = section(' function showView(name)', ' async function loadPortalHealth(');
|
|
const authSource = section(' function showAuth()', ' function workspaceOptions(');
|
|
const importPattern = /import\(("\/netplan-v4\.js[^"\n]*")\)/;
|
|
assert.match(mountSource, importPattern);
|
|
const executableSource = mountSource.replace(importPattern, 'importPlanner($1)') + '\n' + viewSource + '\n' + authSource;
|
|
|
|
function deferred() {
|
|
let resolve, reject;
|
|
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
const settle = () => new Promise(resolve => setImmediate(resolve));
|
|
function node(id, active = false) {
|
|
const classes = new Set(active ? ['active'] : []);
|
|
return {
|
|
id, textContent: '', open: false, dataset: {},
|
|
replaceChildren() { this.textContent = ''; },
|
|
classList: {
|
|
contains(name) { return classes.has(name); },
|
|
add(name) { classes.add(name); },
|
|
remove(name) { classes.delete(name); },
|
|
toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); }
|
|
}
|
|
};
|
|
}
|
|
function harness(options = {}) {
|
|
const nodes = new Map();
|
|
const byId = id => {
|
|
if (!nodes.has(id)) {
|
|
const item = node(id, id === 'view-forecast' && options.active !== false);
|
|
if (id === 'forecastHistory') item.open = options.historyOpen === true;
|
|
nodes.set(id, item);
|
|
}
|
|
return nodes.get(id);
|
|
};
|
|
const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] };
|
|
let plant = { id: 'A', installation_id: 'installation-A', manager_variant: 'manager_standard' };
|
|
const plannerModule = {
|
|
mountPlanner(host, config) {
|
|
calls.mounted.push(config.plantId); host.textContent = `mounted:${config.plantId}`;
|
|
return { ready: options.ready ? options.ready(config.plantId) : Promise.resolve(), dispose() { calls.disposed.push(config.plantId); host.replaceChildren(); } };
|
|
}
|
|
};
|
|
const state = { session: { csrfToken: 'test-only' }, forecastRequest: 0, forecastHistoryDays: 1 };
|
|
const context = vm.createContext({
|
|
state, byId, encodeURIComponent,
|
|
document: { querySelectorAll(selector) { return selector === '.view' ? [byId('view-forecast'), byId('view-overview')] : []; } },
|
|
history: { replaceState() {} }, location: { pathname: '/', search: '' }, window: { scrollTo() {} },
|
|
selectedPlant() { return plant; }, managerLabel(value) { return value; },
|
|
resetForecastChart() {}, renderForecastLicenseWarning() {},
|
|
setForecastState(message) { calls.legacyStatus.push(message); },
|
|
renderForecastResult(result) { calls.rendered.push(result); },
|
|
loadForecastConfiguration(selected, requestId) { calls.configuration.push({ plant: selected.id, requestId }); return Promise.resolve(); },
|
|
loadInvitations() {}, loadOrders() {}, updateFaults() {},
|
|
api(url) { calls.api.push(url); return options.api ? options.api(url) : Promise.resolve({ status: 'available', source: url }); },
|
|
importPlanner(url) { calls.imports.push(url); return options.importPlanner ? options.importPlanner(url, plannerModule) : Promise.resolve(plannerModule); }
|
|
});
|
|
vm.runInContext(executableSource + '\nthis.integration = {updateForecast, loadForecastHistory, historicalForecastResult, showView, showAuth, currentPlanner: () => integratedPlanner};', context);
|
|
return { state, calls, byId, module: plannerModule, ...context.integration, select(id, linked = true) { plant = id ? { id, installation_id: linked ? `installation-${id}` : null, manager_variant: 'manager_standard' } : null; } };
|
|
}
|
|
|
|
test('release assets expose the host, stylesheet and relative chart dependency', async () => {
|
|
assert.equal((html.match(/id="forecastPlanner"/g) || []).length, 1);
|
|
assert.match(html, /href="\/netplan-v4\.css\?[^"\s]+"/);
|
|
assert.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261008-workflow"', 'entrypoint version must invalidate the previous mixed forecast app');
|
|
assert.match(mountSource, /netplan-v4\.js\?v=20261008-workflow/);
|
|
assert.match(html, /<details id="forecastHistory"[^>]*>/);
|
|
assert.doesNotMatch(html, /<details id="forecastHistory"[^>]*\bopen\b/);
|
|
assert.doesNotMatch(html, /<input[^>]+name="(?:forecast_pv|forecast_load|grid_schedule)"/);
|
|
assert.ok(html.indexOf('id="forecastPlanner"') > html.indexOf('id="view-forecast"'));
|
|
assert.match(plannerSource, /export function mountPlanner\(/);
|
|
const dependency = plannerSource.match(/from ['"](\.\/forecast-chart\.js)['"]/);
|
|
assert.ok(dependency, 'chart import remains relative to the deployed planner module');
|
|
assert.ok((await readFile(new URL('./public/' + dependency[1], import.meta.url), 'utf8')).includes('export function renderForecastChart'));
|
|
});
|
|
|
|
test('inactive views and unlinked plants do not mount the planner', async () => {
|
|
const hidden = harness({ active: false }); await hidden.updateForecast(); await settle();
|
|
assert.equal(hidden.calls.imports.length, 0); assert.equal(hidden.calls.api.length, 0);
|
|
const unlinked = harness(); unlinked.select('A', false); await unlinked.updateForecast(); await settle();
|
|
assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 0);
|
|
assert.match(unlinked.byId('forecastPlanner').textContent, /Lizenzcode/);
|
|
assert.equal(unlinked.calls.configuration.length, 1, 'tariff and PV configuration remains available');
|
|
});
|
|
|
|
test('changing plants invalidates stale imports and explicitly opened history responses', async () => {
|
|
const imports = [deferred(), deferred()], results = [deferred(), deferred()];
|
|
let importIndex = 0, resultIndex = 0;
|
|
const app = harness({ historyOpen: true, importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise });
|
|
const first = app.updateForecast(); app.select('B'); const second = app.updateForecast();
|
|
imports[0].resolve(app.module); await settle(); assert.deepEqual(app.calls.mounted, []);
|
|
imports[1].resolve(app.module); results[1].resolve({ status: 'available', plant: 'B' });
|
|
results[0].resolve({ status: 'available', plant: 'A' }); await Promise.all([first, second]); await settle();
|
|
assert.deepEqual(app.calls.mounted, ['B']);
|
|
assert.deepEqual(app.calls.rendered.map(result => result.plant), ['B']);
|
|
assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['A', 'B']);
|
|
});
|
|
|
|
test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => {
|
|
const app = harness(); await app.updateForecast(); await settle();
|
|
const revision = app.state.forecastRequest; app.showView('overview');
|
|
assert.equal(app.state.forecastRequest, revision + 1); assert.deepEqual(app.calls.disposed, ['A']);
|
|
assert.equal(app.currentPlanner(), null);
|
|
const imported = deferred(), pending = harness({ importPlanner: () => imported.promise });
|
|
await pending.updateForecast(); pending.showView('overview'); imported.resolve(pending.module); await settle();
|
|
assert.deepEqual(pending.calls.mounted, []);
|
|
});
|
|
|
|
test('returning to authentication disposes the planner and invalidates pending responses', async () => {
|
|
const ready = deferred(), app = harness({ ready: () => ready.promise });
|
|
await app.updateForecast(); await settle();
|
|
const revision = app.state.forecastRequest; app.showAuth();
|
|
assert.equal(app.state.forecastRequest, revision + 1);
|
|
assert.deepEqual(app.calls.disposed, ['A']); assert.equal(app.currentPlanner(), null);
|
|
assert.equal(app.byId('portalApp').classList.contains('hidden'), true);
|
|
ready.reject(new Error('late post-logout failure')); await settle();
|
|
assert.equal(app.byId('forecastPlanner').textContent, '');
|
|
|
|
const imported = deferred(), result = deferred();
|
|
const pending = harness({ historyOpen: true, importPlanner: () => imported.promise, api: () => result.promise });
|
|
const update = pending.updateForecast(); pending.showAuth();
|
|
imported.resolve(pending.module); result.resolve({ status: 'available', plant: 'A' });
|
|
await update; await settle();
|
|
assert.deepEqual(pending.calls.mounted, []);
|
|
assert.deepEqual(pending.calls.rendered, []);
|
|
assert.equal(pending.calls.configuration.length, 1, 'configuration request starts independently and has its own response guard');
|
|
});
|
|
|
|
test('default view loads V4 and configuration without calling the old forecast endpoint', async () => {
|
|
const app = harness(); await app.updateForecast(); await settle();
|
|
assert.deepEqual(app.calls.mounted, ['A']);
|
|
assert.equal(app.calls.api.length, 0); assert.equal(app.calls.rendered.length, 0);
|
|
assert.equal(app.calls.configuration.length, 1);
|
|
});
|
|
|
|
test('historical API failures do not clear or prevent the independent planner', async () => {
|
|
const app = harness({ historyOpen: true, api: () => Promise.reject(new Error('history unavailable')) });
|
|
await app.updateForecast(); await settle();
|
|
assert.deepEqual(app.calls.mounted, ['A']);
|
|
assert.equal(app.byId('forecastPlanner').textContent, 'mounted:A');
|
|
assert.equal(app.calls.legacyStatus.at(-1), 'history unavailable');
|
|
});
|
|
|
|
test('planner import failures remain inside the planner host', async () => {
|
|
const app = harness({ importPlanner: () => Promise.reject(new Error('planner unavailable')) });
|
|
await app.updateForecast(); await settle();
|
|
assert.equal(app.calls.rendered.length, 0);
|
|
assert.equal(app.calls.configuration.length, 1, 'configuration is independent of the planner import');
|
|
assert.match(app.byId('forecastPlanner').textContent, /planner unavailable/);
|
|
assert.ok(!app.calls.legacyStatus.includes('planner unavailable'));
|
|
});
|
|
|
|
test('history is lazy, cached per plant and range, and never remounts the active planner', async () => {
|
|
const app = harness(); await app.updateForecast(); await settle();
|
|
assert.equal(app.calls.api.length, 0);
|
|
app.byId('forecastHistory').open = true; await app.loadForecastHistory();
|
|
assert.equal(app.calls.api.length, 1); assert.equal(app.calls.rendered.length, 1);
|
|
await app.loadForecastHistory(); assert.equal(app.calls.api.length, 1);
|
|
app.state.forecastHistoryDays = 7; await app.loadForecastHistory();
|
|
assert.equal(app.calls.api.length, 2); assert.match(app.calls.api.at(-1), /historyDays=7/);
|
|
assert.deepEqual(app.calls.mounted, ['A']); assert.equal(app.calls.disposed.length, 0);
|
|
});
|
|
|
|
test('historical rendering excludes future and legacy schedule curves without mutating stored results', () => {
|
|
const now = Date.parse('2026-10-08T12:00:00Z');
|
|
const points = [{ time: '2026-10-08T11:00:00Z', value: 1 }, { time: '2026-10-08T13:00:00Z', value: 2 }, { time: 'invalid', value: 3 }];
|
|
const result = { scheduleSourceModel: 'prog_var_3', series: [
|
|
{ key: 'PV', points }, { key: 'prog_var_1', points }, { key: 'prog_var_3', points }, { key: 'prog_var_3_battery', points }
|
|
] };
|
|
const output = harness().historicalForecastResult(result, now);
|
|
assert.equal(output.scheduleSourceModel, null);
|
|
assert.deepEqual(Array.from(output.series, row => row.key), ['PV', 'prog_var_1']);
|
|
for (const row of output.series) assert.equal(row.points.length, 1);
|
|
assert.equal(result.series.length, 4); assert.equal(points.length, 3); assert.equal(result.scheduleSourceModel, 'prog_var_3');
|
|
});
|
|
|
|
test('tariff changes preserve existing generator flags without recreating legacy operator switches', () => {
|
|
const code = section(' var forecastCapabilityVariants =', ' var forecastTariffs =')
|
|
+ section(' function forecastGeneratorFlags(', ' var integratedPlanner =');
|
|
const context = vm.createContext({ byId() { return { querySelectorAll() { return []; } }; } });
|
|
vm.runInContext(code + '\nthis.payload = forecastConfigurationPayload; this.flags = forecastGeneratorFlags;', context);
|
|
const flags = { prog_var_1: true, prog_var_2: false, prog_var_3: true, prog_var_10: false, unknown: true };
|
|
const form = { dataset: { forecastVariants: JSON.stringify(flags) }, elements: new Proxy({}, { get() { return { value: '1' }; } }) };
|
|
const payload = context.payload(form);
|
|
assert.equal(payload.prog_var_1, true); assert.equal(payload.prog_var_2, false);
|
|
assert.equal(payload.prog_var_3, true); assert.equal(payload.prog_var_10, false);
|
|
assert.equal(Object.hasOwn(payload, 'prog_var_13'), false, 'absent flags are not manufactured');
|
|
assert.equal(Object.hasOwn(payload, 'unknown'), false);
|
|
assert.equal(payload.tarif_bezug_fest, 1); assert.equal(payload.tarif_einspeisung_fest, 1);
|
|
});
|
|
|
|
test('configuration failures cannot expose the previous plant as an editable form', async () => {
|
|
const code = section(' async function loadForecastConfiguration(', ' function forecastGeneratorFlags(');
|
|
const nodes = new Map();
|
|
const byId = id => { if (!nodes.has(id)) nodes.set(id, node(id)); return nodes.get(id); };
|
|
const pending = deferred(), state = { forecastRequest: 1 }, filled = [];
|
|
const context = vm.createContext({ state, byId, api() { return pending.promise; }, fillForecastConfiguration(plant) { filled.push(plant.id); } });
|
|
vm.runInContext(code + '\nthis.load = loadForecastConfiguration;', context);
|
|
byId('forecastConfigForm').dataset.plantId = 'old-plant';
|
|
const load = context.load({ id: 'new-plant' }, 1);
|
|
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
|
|
assert.equal(byId('forecastConfigForm').dataset.plantId, '');
|
|
pending.reject(new Error('configuration unavailable')); await load;
|
|
assert.equal(byId('forecastConfigError').textContent, 'configuration unavailable');
|
|
assert.equal(byId('forecastConfigForm').classList.contains('hidden'), true);
|
|
assert.equal(filled.length, 0);
|
|
});
|
|
|
|
test('late planner errors cannot overwrite the next plant after disposal', async () => {
|
|
const ready = deferred(), app = harness({ ready: id => id === 'A' ? ready.promise : Promise.resolve() });
|
|
await app.updateForecast(); await settle(); app.select('B'); await app.updateForecast(); await settle();
|
|
ready.reject(new Error('late old failure')); await settle();
|
|
assert.deepEqual(app.calls.disposed, ['A']);
|
|
assert.deepEqual(app.calls.mounted, ['A', 'B']);
|
|
assert.equal(app.byId('forecastPlanner').textContent, 'mounted:B');
|
|
});
|