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,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"/);
});