feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
This commit is contained in:
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user