feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
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.
This commit is contained in:
+88
-14
@@ -28,7 +28,7 @@ const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
function node(id, active = false) {
|
||||
const classes = new Set(active ? ['active'] : []);
|
||||
return {
|
||||
id, textContent: '',
|
||||
id, textContent: '', open: false, dataset: {},
|
||||
replaceChildren() { this.textContent = ''; },
|
||||
classList: {
|
||||
contains(name) { return classes.has(name); },
|
||||
@@ -41,7 +41,11 @@ function node(id, active = false) {
|
||||
function harness(options = {}) {
|
||||
const nodes = new Map();
|
||||
const byId = id => {
|
||||
if (!nodes.has(id)) nodes.set(id, node(id, id === 'view-forecast' && options.active !== false));
|
||||
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: [] };
|
||||
@@ -66,14 +70,18 @@ function harness(options = {}) {
|
||||
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, showView, showAuth, currentPlanner: () => integratedPlanner};', context);
|
||||
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=20261006-forecast"', 'entrypoint version must invalidate the pre-integration cached app');
|
||||
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)['"]/);
|
||||
@@ -85,20 +93,22 @@ 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, 1);
|
||||
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 legacy responses', async () => {
|
||||
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({ importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise });
|
||||
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), ['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 () => {
|
||||
@@ -122,31 +132,95 @@ test('returning to authentication disposes the planner and invalidates pending r
|
||||
assert.equal(app.byId('forecastPlanner').textContent, '');
|
||||
|
||||
const imported = deferred(), result = deferred();
|
||||
const pending = harness({ importPlanner: () => imported.promise, api: () => result.promise });
|
||||
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.deepEqual(pending.calls.configuration, []);
|
||||
assert.equal(pending.calls.configuration.length, 1, 'configuration request starts independently and has its own response guard');
|
||||
});
|
||||
|
||||
test('legacy API failures do not clear or prevent the independent planner', async () => {
|
||||
const app = harness({ api: () => Promise.reject(new Error('legacy unavailable')) });
|
||||
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), 'legacy unavailable');
|
||||
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, 1);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user