feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s

This commit is contained in:
dh
2026-10-06 14:35:17 +00:00
parent faa905ab09
commit 5687596799
14 changed files with 9125 additions and 0 deletions
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { publicWizardCatalog, normalizeSystemConfiguration, recommendLicenses } from '../portal-domain.mjs';
import { publicCatalog } from '../stripe.mjs';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const require = createRequire(process.env.BROWSER_PACKAGE || import.meta.url);
const { chromium } = require('playwright');
const options = { headless: true };
if (process.env.BROWSER_EXECUTABLE) {
options.executablePath = process.env.BROWSER_EXECUTABLE;
const loaded = require('@sparticuz/chromium');
const standalone = loaded.default || loaded;
options.args = standalone.args.filter(arg => !['--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials'].includes(arg) && !arg.startsWith('--disable-features='));
}
const browser = await chromium.launch(options);
let plants = [];
let submitted;
const errors = [];
const json = (route, value) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(value) });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'de-CH' });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/catalog') return json(route, { items: publicCatalog(), vatRate: 8.1 });
if (url.pathname === '/api/wizard-catalog') return json(route, publicWizardCatalog());
if (url.pathname === '/api/session') return json(route, { authenticated: true, user: { name: 'Testkonto', email: 'portal-test@example.invalid' }, organization: { type: 'personal', role: 'owner', name: 'Testbereich' }, organizations: [] });
if (url.pathname === '/healthz') return json(route, { stripe: 'not_configured' });
if (url.pathname === '/api/plants') return json(route, { plants });
if (url.pathname === '/api/orders') return json(route, { orders: [] });
if (url.pathname === '/api/configurations') {
submitted = normalizeSystemConfiguration(route.request().postDataJSON());
const recommendation = recommendLicenses(submitted);
plants = [{ id: 'test-plant', name: submitted.name, location: submitted.location, type: submitted.siteType, manager_variant: 'manager_standard', configuration_id: 'test-config', configuration_json: JSON.stringify(submitted), recommendation_json: JSON.stringify(recommendation) }];
return json(route, { plant: plants[0], configurationId: 'test-config', packageReady: false, purchaseProposal: { required: true } });
}
const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
if (!['index.html', 'styles.css', 'docs-portal.css', 'app.js'].includes(file)) return route.fulfill({ status: 404, body: 'Not found' });
return route.fulfill({ contentType: file.endsWith('.js') ? 'text/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html', body: await fs.readFile(path.join(root, 'public', file)) });
});
await page.goto('http://portal.test/');
await page.locator('#portalApp:not(.hidden)').waitFor();
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
const layout = await page.locator('.topbar').evaluate(element => {
const nodes = [...element.querySelectorAll('.brand, .header-links a, .account-button')].map(node => {
const box = node.getBoundingClientRect(); return { x: box.x, right: box.right, top: box.top, bottom: box.bottom };
});
return { nodes, viewport: innerWidth, width: document.documentElement.scrollWidth };
});
assert.ok(layout.nodes.every(box => box.x >= 0 && box.right <= width), JSON.stringify({ width, layout }));
for (let i = 1; i < layout.nodes.length; i++) assert.ok(layout.nodes[i - 1].right <= layout.nodes[i].x, JSON.stringify({ width, layout }));
assert.equal(await page.locator('.sidebar a[href^="/docs/"]').count(), 0);
await page.screenshot({ path: path.join(root, `header-${width}.png`), clip: { x: 0, y: 0, width, height: 180 } });
}
await page.setViewportSize({ width: 1440, height: 1000 });
await page.locator('[data-open-new-license]').first().click();
await page.locator('#newLicenseDialog [data-goto="configurator"]').click();
await page.locator('#systemForm [name="name"]').fill('Testanlage');
await page.locator('#systemForm [name="location"]').fill('Teststandort');
for (let i = 0; i < 7; i++) await page.locator('#wizardNext').click();
assert.equal(await page.locator('#wizardStepTitle').textContent(), 'Wärmepumpe');
assert.equal(await page.locator('.wizard-progress li').evaluateAll(nodes => nodes.every(node => node.scrollWidth <= node.clientWidth)), true);
await page.locator('[name="heatPumpQuantity"]').fill('1');
const editor = page.locator('[data-device-editor="heat_pump"]');
await editor.locator('[data-device-property="Kontaktart"]').selectOption('1');
for (const [field, value] of Object.entries({ Kontakt1VariableID: 10001, Kontakt2VariableID: 10002, IstleistungVariableID: 10003, Nennleistung: 4200, Mindestlaufzeit: 1500 })) {
await editor.locator(`[data-device-property="${field}"]`).fill(String(value));
}
await editor.locator('[data-device-property="Rueckmeldungsart"]').selectOption('1');
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: path.join(root, 'wizard-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: path.join(root, 'wizard-mobile.png'), fullPage: true });
await page.locator('#wizardNext').click();
await page.locator('#wizardNext').click();
assert.match(await page.locator('#wizardReview').textContent(), /Wärmepumpe/);
await page.locator('#wizardSubmit').click();
await page.locator('#view-licenses.active').waitFor();
assert.equal(submitted.devices.heat_pump.items[0].properties.Mindestlaufzeit, 1500);
assert.equal(submitted.devices.heat_pump.items[0].properties.Kontaktart, 1);
assert.equal(await page.locator('.module-row[data-key="heat_pump"] .module-check').isChecked(), true);
assert.match(await page.locator('#orderItems').textContent(), /Wärmepumpe/);
await page.locator('[data-configure-current]').click();
for (let i = 0; i < 7; i++) await page.locator('#wizardNext').click();
assert.equal(await editor.locator('[data-device-property="Kontakt1VariableID"]').inputValue(), '10001');
assert.equal(await editor.locator('[data-device-property="Mindestlaufzeit"]').inputValue(), '1500');
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, headerWidths: [1440, 768, 390, 320], wizard: 'configure, save, license proposal, reopen', browserErrors: errors }));
} finally { await browser.close(); }
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { buildOrderLines, catalog } from "../stripe.mjs";
import { consumerFields, licenseCoverage, normalizeSystemConfiguration, publicWizardCatalog, recommendLicenses, systemDeviceTypes } from "../portal-domain.mjs";
import { buildSymconPackage } from "../symcon-package.mjs";
const configuration = () => ({
name: "Testsystem", location: "Teststandort", devices: {
buffer_storage: { quantity: 0 },
heat_pump: { quantity: 1, items: [{ name: "WP", properties: {
Kontaktart: 1, Kontakt1VariableID: 10001, Kontakt2VariableID: 10002,
Nennleistung: 4200, Rueckmeldungsart: 1, IstleistungVariableID: 10003,
Kontakt2Invertiert: true, Mindestlaufzeit: 1500
} }] }
}
});
test("heat-pump order includes separate licenses and setup at existing prices", () => {
const result = buildOrderLines("manager_standard", { heat_pump: 2 }, { modules: { heat_pump: 2 } }, "manager_standard");
assert.equal(result.length, 2);
assert.deepEqual(result.map(line => [line.catalogKey, line.lineType, line.quantity, line.unitAmount, line.entitlement]), [
["heat_pump", "license", 2, 2095, true], ["heat_pump", "setup", 2, 2725, false]
]);
});
test("heat-pump checkout respects catalog availability and quantities", () => {
const disabled = { ...catalog, heat_pump: { ...catalog.heat_pump, available: false } };
assert.throws(() => buildOrderLines("manager_standard", { heat_pump: 1 }, {}, "manager_standard", disabled), /catalog_item_unavailable/);
for (const quantity of [-1, 0.5, 1000]) {
assert.throws(() => buildOrderLines("manager_standard", { heat_pump: quantity }), /invalid_quantity/);
}
});
test("wizard publishes two-contact heat-pump fields without duplicates", () => {
const fields = publicWizardCatalog().consumerFields.heat_pump;
assert.equal(new Set(consumerFields.heat_pump.map(field => field.key)).size, consumerFields.heat_pump.length);
for (const key of ["Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Rueckmeldungsart", "IstleistungVariableID", "MaximaleSperrzeit24h"]) {
assert.ok(fields.some(field => field.key === key), key);
}
assert.equal(systemDeviceTypes.heat_pump.moduleId, "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}");
});
test("heat-pump settings survive normalize, save and recommendation", () => {
const result = normalizeSystemConfiguration(configuration());
assert.deepEqual(normalizeSystemConfiguration(result), result);
const properties = result.devices.heat_pump.items[0].properties;
assert.equal(properties.Kontaktart, 1);
assert.equal(properties.Kontakt1VariableID, 10001);
assert.equal(properties.Kontakt2VariableID, 10002);
assert.equal(properties.IstleistungVariableID, 10003);
assert.equal(properties.Rueckmeldungsart, 1);
assert.equal(properties.Kontakt2Invertiert, true);
assert.equal(properties.Mindestlaufzeit, 1500);
assert.equal(properties.MaximaleSperrzeit, 7200);
assert.equal(recommendLicenses(result).modules.heat_pump, 1);
});
test("older configurations gain no heat-pump device and retain consumers", () => {
const input = configuration();
delete input.devices.heat_pump;
input.devices.consumer_single = { quantity: 1, items: [{ name: "Bestand", properties: { Nennleistung: 2200 } }] };
const result = normalizeSystemConfiguration(input);
assert.deepEqual(result.devices.heat_pump, { quantity: 0, items: [] });
assert.equal(result.devices.consumer_single.items[0].properties.Nennleistung, 2200);
});
test("legacy coverage reserves single-consumer licenses without double counting", () => {
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 1 }, { consumer_single: 3 }).heat_pump, 2);
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 2 }, { consumer_single: 3 }).heat_pump, 1);
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 3 }, { consumer_single: 3 }).heat_pump, 0);
assert.equal(licenseCoverage({ heat_pump: 2 }, { heat_pump: 1, consumer_single: 3 }).heat_pump, 2);
const source = readFileSync(new URL("../public/app.js", import.meta.url), "utf8");
const functionSource = source.match(/ function licenseCoverage\(requested, owned\) \{[\s\S]*?\n \}/)[0];
const browserCoverage = vm.runInNewContext(`(${functionSource})`);
for (let requested = 0; requested < 5; requested++) {
for (let consumers = 0; consumers < 5; consumers++) {
const demand = { heat_pump: requested, consumer_single: consumers };
const owned = { heat_pump: 1, consumer_single: 3 };
assert.equal(JSON.stringify(browserCoverage(demand, owned)), JSON.stringify(licenseCoverage(demand, owned)));
}
}
});
test("package contains heat-pump configuration and preflight before writes", () => {
const archive = buildSymconPackage(configuration());
const files = {};
let offset = 0;
while (archive.readUInt32LE(offset) === 0x04034b50) {
const length = archive.readUInt32LE(offset + 18);
const nameLength = archive.readUInt16LE(offset + 26);
const extraLength = archive.readUInt16LE(offset + 28);
const name = archive.subarray(offset + 30, offset + 30 + nameLength).toString();
const start = offset + 30 + nameLength + extraLength;
files[name] = archive.subarray(start, start + length).toString();
offset = start + length;
}
const bootstrap = files["bootstrap.php"];
assert.match(bootstrap, /A31C9274-54F7-4BD2-9804-7AF23E86C4D1/);
assert.ok(bootstrap.indexOf("// Validate heat-pump bindings") < bootstrap.indexOf("$root = enxCategory"));
assert.match(bootstrap, /\$contact1 === \$contact2/);
assert.match(bootstrap, /Typ der Betriebsrueckmeldung/);
assert.match(bootstrap, /IPS_SetProperty\(\$instance, \(string\) \$property, \$value\)/);
});
test("header has both links, sidebar has neither, and wizard has ten steps", () => {
const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
const header = html.match(/<header class="topbar">([\s\S]*?)<\/header>/)[1];
const sidebar = html.match(/<aside class="sidebar">([\s\S]*?)<\/aside>/)[1];
assert.match(header, /href="\/docs\/index.html">Dokumentation/);
assert.match(header, /href="\/docs\/preise.html">Preise/);
assert.doesNotMatch(sidebar, /Dokumentation|Preise/);
assert.deepEqual([...html.matchAll(/data-wizard-step="(\d+)"/g)].map(match => Number(match[1])), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.equal([...html.matchAll(/data-wizard-marker="/g)].length, 10);
assert.match(html, /data-device-quantity="heat_pump"/);
assert.match(html, /data-key="heat_pump"/);
});
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
consumerFields,
normalizeSystemConfiguration,
publicWizardCatalog
} from "../portal-domain.mjs";
import { buildSymconPackage } from "../symcon-package.mjs";
const emptyDevices = () => ({
battery: { quantity: 0, items: [] },
buffer_storage: { quantity: 0, items: [] },
ev_charger: { quantity: 0, items: [] },
consumer_single: { quantity: 0, items: [] },
boiler_multi: { quantity: 0, items: [] },
heat_pump: { quantity: 0, items: [] }
});
const baseConfiguration = () => ({
name: "Testsystem",
location: "Zürich",
manager: { properties: {} },
gridMeasurement: { profile: "manual", sourceVariableId: 42 },
pvSystem: { enabled: false },
devices: emptyDevices()
});
test("wizard catalog contains complete manager, consumer and integration schemas", () => {
const catalog = publicWizardCatalog();
assert.equal(catalog.schemaVersion, 4);
assert.equal(catalog.managerFields.length, 11);
assert.deepEqual(Object.keys(catalog.consumerFields).sort(), Object.keys(emptyDevices()).sort());
assert.equal(catalog.evVariants.length, 2);
assert.ok(catalog.integrations.energy.some(profile => profile.id === "fronius_solar_api_v1"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "huawei_sun2000_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "sma_sunspec_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "goodwe_et_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "solaredge_sunspec_modbus"));
assert.ok(catalog.integrations.storage.some(profile => profile.id === "sigenergy_sigenstor"));
assert.ok(catalog.integrations.actuator.some(profile => profile.id === "shelly_pro_4pm"));
assert.ok(consumerFields.battery.length > 20);
});
test("normalizes individual device properties and integration metadata", () => {
const configuration = baseConfiguration();
configuration.manager.properties.MesswertMaxAlter = 15;
configuration.devices.consumer_single = {
quantity: 1,
items: [{
name: "Wärmepumpe",
integration: { profile: "shelly_pro_4pm", topicPrefix: "shellypro4pm-test", channel: 3 },
properties: { Nennleistung: 4200, PrioritaetPV: 2 }
}]
};
const result = normalizeSystemConfiguration(configuration);
assert.equal(result.manager.properties.MesswertMaxAlter, 15);
assert.equal(result.devices.consumer_single.items[0].integration.channel, 3);
assert.equal(result.devices.consumer_single.items[0].properties.Nennleistung, 4200);
assert.equal(result.devices.consumer_single.items[0].properties.PrioritaetPV, 2);
});
test("derives battery type and enables charging by default", () => {
const configuration = baseConfiguration();
configuration.devices.battery = {
quantity: 2,
items: [{
name: "Hausspeicher",
integration: { profile: "goodwe_lynx", host: "192.0.2.10" },
properties: { Batterietyp: 0 }
}, {
name: "Gewerbespeicher",
integration: { profile: "sigenergy_sigenstor", host: "192.0.2.11" },
properties: { Batterietyp: 0 }
}]
};
configuration.devices.ev_charger = {
quantity: 2,
items: [{ name: "Direkt", variant: "standalone", properties: {} },
{ name: "Gateway", variant: "gateway", properties: {} }]
};
const result = normalizeSystemConfiguration(configuration);
assert.equal(result.devices.battery.items[0].properties.Batterietyp, 2);
assert.equal(result.devices.battery.items[1].properties.Batterietyp, 4);
assert.equal(result.devices.ev_charger.items[0].properties.Ladefreigabe, true);
assert.equal(result.devices.ev_charger.items[1].properties.Ladefreigabe, true);
});
test("package wires gateway, Shelly feedback, levels, sensors and visualisation", () => {
const configuration = baseConfiguration();
configuration.devices.ev_charger = {
quantity: 1,
items: [{ name: "Garage", variant: "gateway", properties: { Ladestationskennung: "EH123" } }]
};
configuration.devices.consumer_single = {
quantity: 1,
items: [{ name: "Heizstab", integration: { profile: "shelly_plus_1pm", topicPrefix: "shelly/heizstab" }, properties: {} }]
};
configuration.devices.boiler_multi = {
quantity: 1,
items: [{
name: "Boiler",
properties: {},
powerLevels: [{ Stufe: 1, Leistung: 3000, integration: { profile: "shelly_pro_2pm", topicPrefix: "shelly/boiler", channel: 1 } }],
schedule: [{ Uhrzeit: "06:30", Solltemperatur: 55 }],
sensor: { profile: "shelly_addon_temperature", topicPrefix: "shelly/boiler" }
}]
};
const archive = buildSymconPackage(configuration).toString("utf8");
assert.match(archive, /RueckmeldungVariableID/);
assert.match(archive, /Boilerfuehler_PT1/);
assert.match(archive, /IPS_ConnectInstance/);
assert.match(archive, /Energie-Kreisdiagramm/);
});
test("requires a host for network profiles", () => {
const configuration = baseConfiguration();
configuration.gridMeasurement = { profile: "sma_sunspec_modbus" };
assert.throws(
() => normalizeSystemConfiguration(configuration),
error => error.message === "integration_host_missing"
);
});
test("requires an MQTT topic for Shelly profiles", () => {
const configuration = baseConfiguration();
configuration.devices.consumer_single = {
quantity: 1,
items: [{
name: "Heizstab",
integration: { profile: "shelly_plus_1pm" },
properties: {}
}]
};
assert.throws(
() => normalizeSystemConfiguration(configuration),
error => error.message === "integration_topic_missing"
);
});