Files
gree-controller/scripts/live_realtime_test.js
T
2026-09-04 11:43:37 +02:00

279 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env node
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const timers = [];
let timerId = 0;
let apiCalls = [];
let apiResponder = async requestPath => requestPath === '/api/schedules' ? [] : {};
const calls = {};
const hit = name => () => { calls[name] = (calls[name] || 0) + 1; };
class FakeWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSED = 3;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
FakeWebSocket.instances.push(this);
}
}
const app = {
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [],
settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} },
system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null,
controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard',
sensorAliases: {}, flowSharedInputs: [], flowDraft: null, ws: null, wsTimer: null,
token: '', debugBacklogLoaded: false,
};
const context = {
console, JSON, Number, Date, Intl, Promise, app, WebSocket: FakeWebSocket,
location: { protocol: 'http:', host: 'controller.test' },
setTimeout: (fn, delay) => {
const id = ++timerId;
timers.push({ id, fn, delay, cleared: false });
return id;
},
clearTimeout: id => {
const timer = timers.find(item => item.id === id);
if (timer) timer.cleared = true;
},
api: async (...args) => {
apiCalls.push(args);
return apiResponder(...args);
},
withBase: value => value,
$: () => null,
isFormDirty: () => false,
loadSettingsSections: async () => {},
applySettingsSection: (name, data) => { app.settings[name] = data; },
loadDebugBacklog: hit('loadDebugBacklog'),
loadBootstrap: async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; },
updateConnectionIndicator: status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; },
updateDevice: device => {
const index = app.devices.findIndex(item => item.id === device.id);
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
},
toast: () => {}, tr: key => key, esc: value => String(value), locale: () => 'en-GB',
logCategory: () => 'test', debugLine: hit('debugLine'),
};
const renderNames = [
'renderAll', 'renderSummary', 'renderDevices', 'renderGroups', 'renderHouseClimate',
'renderZones', 'renderFlows', 'renderSchedules', 'renderAutomations', 'renderSettings',
'renderSimulationModeBanner', 'renderSystemInfo', 'renderLogRetention', 'renderNightSettings',
'renderHomeAssistantSettings', 'renderDebugOverlay', 'renderFlowEditor', 'renderGreeFrameStats',
'renderControlPlan', 'renderSimulationPage', 'fillSelects',
];
for (const name of renderNames) context[name] = hit(name);
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' });
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/realtime.js'), 'utf8'), context, { filename: 'realtime.js' });
// Source files define DOM-heavy renderers. Replace those implementations for this state-machine test.
for (const name of renderNames) context[name] = hit(name);
context.updateDevice = device => {
const index = app.devices.findIndex(item => item.id === device.id);
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
};
context.loadSettingsSections = async () => {};
context.applySettingsSection = (name, data) => { app.settings[name] = data; };
context.loadDebugBacklog = hit('loadDebugBacklog');
context.loadBootstrap = async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; };
context.isFormDirty = () => false;
context.updateConnectionIndicator = status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; };
context.withBase = value => value;
async function send(event, data) {
await context.handleWebSocketMessage({
data: JSON.stringify({ event, timestamp: new Date().toISOString(), data }),
});
}
async function testBootstrapReloadQueue() {
const queuedTimers = [];
let firstResolve;
let bootstrapCalls = 0;
const firstResponse = new Promise(resolve => { firstResolve = resolve; });
const c = {
console, Promise, Date,
app: { loading: false, bootstrapReloadPending: false, controlPlan: null, controlPlanRevision: null, settings: {}, sensorAliases: {}, flowSharedInputs: {}, system: {}, debugBacklogLoaded: false },
api: async requestPath => {
assert.equal(requestPath, '/api/bootstrap');
bootstrapCalls += 1;
if (bootstrapCalls === 1) return firstResponse;
return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 };
},
fetchSettingsSections: async () => ({ application: {}, gree: {}, history: {}, influxdb: {}, notifications: {}, night: {}, home_assistant: {}, debug: {} }),
aggregateSettingsSections: () => ({ home_assistant: {}, debug: { overlay_enabled: false } }),
renderAll: () => {}, scheduleControlPlanLoad: () => {}, loadDebugBacklog: () => {}, toast: () => {}, tr: key => key,
$: () => ({ open: false }), connectWebSocket: () => {},
setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; },
};
vm.createContext(c);
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' });
const first = c.loadBootstrap();
assert.equal(c.app.loading, true);
await c.loadBootstrap();
assert.equal(c.app.bootstrapReloadPending, true, 'a bootstrap request during loading must be queued');
firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 });
await first;
assert.equal(c.app.bootstrapReloadPending, false);
assert.equal(queuedTimers.length, 1);
assert.equal(queuedTimers[0].delay, 0);
queuedTimers[0].fn();
await new Promise(resolve => setImmediate(resolve));
assert.equal(bootstrapCalls, 2, 'queued bootstrap reload must run after the first request finishes');
}
async function main() {
await testBootstrapReloadQueue();
await send('bootstrap', {
devices: [{ id: 'd1', power: false }],
zones: [{ id: 'z1', demand: false }],
groups: [{ id: 'g1', name: 'G1' }],
schedules: [{ id: 's1', name: 'S1' }],
automations: [{ id: 'a1', name: 'A1' }],
flows: [{ id: 'f1', name: 'F1' }],
house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10,
control_plan: { marker: 'p1' }, control_plan_revision: 1,
});
assert.equal(app.controlPlan.marker, 'p1');
assert.equal(app.controlPlanRevision, 1);
assert.equal(app.controlPlanPushReady, true);
await send('control_plan.updated', { revision: 2, plan: { marker: 'p2' } });
await send('control_plan.updated', { revision: 1, plan: { marker: 'stale' } });
assert.equal(app.controlPlan.marker, 'p2', 'stale control-plan revision must be ignored');
app.ws = { readyState: FakeWebSocket.OPEN };
const timerCountWhilePushReady = timers.length;
await send('device.updated', { id: 'd1', power: true, online: true });
assert.equal(app.devices[0].power, true);
await send('device.created', { id: 'd2', power: false });
assert(app.devices.some(item => item.id === 'd2'));
await send('devices.discovered', { devices: [{ id: 'd3', power: false }] });
assert(app.devices.some(item => item.id === 'd3'));
await send('device.deleted', { id: 'd2' });
assert(!app.devices.some(item => item.id === 'd2'));
await send('zone.updated', { id: 'z1', demand: true, current_temperature: 22.1 });
assert.equal(app.zones[0].demand, true);
await send('zone.created', { id: 'z2', demand: false });
assert(app.zones.some(item => item.id === 'z2'));
await send('zone.deleted', { id: 'z2' });
assert(!app.zones.some(item => item.id === 'z2'));
await send('group.updated', { id: 'g1', name: 'G1 updated' });
assert.equal(app.groups[0].name, 'G1 updated');
await send('group.created', { id: 'g2', name: 'G2' });
assert(app.groups.some(item => item.id === 'g2'));
await send('group.deleted', { id: 'g2' });
assert(!app.groups.some(item => item.id === 'g2'));
await send('schedule.updated', { id: 's1', name: 'S1 updated' });
assert.equal(app.schedules[0].name, 'S1 updated');
await send('schedule.created', { id: 's2', name: 'S2' });
assert(app.schedules.some(item => item.id === 's2'));
await send('schedule.deleted', { id: 's2' });
assert(!app.schedules.some(item => item.id === 's2'));
apiResponder = async requestPath => requestPath === '/api/schedules' ? [{ id: 's3', name: 'Template' }] : {};
await send('schedule.template_applied', { zone_id: 'z1', template: 'family', count: 2 });
assert.equal(app.schedules[0].id, 's3');
await send('automation.updated', { id: 'a1', name: 'A1 updated', last_fired_at: '2026-09-04T09:00:00Z' });
assert.equal(app.automations[0].name, 'A1 updated');
await send('automation.created', { id: 'a2', name: 'A2' });
assert(app.automations.some(item => item.id === 'a2'));
await send('automation.deleted', { id: 'a2' });
assert(!app.automations.some(item => item.id === 'a2'));
await send('flow.updated', { id: 'f1', name: 'F1 updated' });
assert.equal(app.flows[0].name, 'F1 updated');
await send('flow.created', { id: 'f2', name: 'F2' });
assert(app.flows.some(item => item.id === 'f2'));
await send('flow.deleted', { id: 'f2' });
assert(!app.flows.some(item => item.id === 'f2'));
await send('settings.application.updated', { poll_interval_seconds: 15 });
await send('settings.gree.updated', { command_timeout_ms: 1000 });
await send('settings.history.updated', { retention_days: 30 });
await send('settings.influxdb.updated', { enabled: false });
await send('settings.notifications.updated', { enabled: false });
await send('settings.debug.updated', { overlay_enabled: false });
await send('settings.night.updated', { enabled: true, start_time: '22:00', end_time: '06:00' });
await send('settings.home_assistant.updated', { sensor_aliases: { 'sensor.room': 'Room' }, flow_inputs: [] });
assert.equal(app.settings.night.enabled, true);
assert.equal(app.sensorAliases['sensor.room'], 'Room');
await send('house.mode_changed', { mode: 'off' });
assert.equal(app.settings.house_mode, 'off');
await send('outdoor.updated', { temperature: 12.3 });
assert.equal(app.outdoorTemperature, 12.3);
await send('gree.frame_received', { total: 5, device_id: 'd1', device_count: 3 });
assert.equal(app.system.gree_received_frames, 5);
assert.equal(app.system.gree_received_frames_by_device.d1, 3);
app.settings.debug = { overlay_enabled: true };
await send('gree.frame', { direction: 'rx', payload: {} });
await send('api.request', { method: 'GET', status: 200, path: '/api/health', duration_ms: 1 });
await send('log.created', { level: 'info', kind: 'test', message: 'ok', metadata: {} });
await send('configuration.imported', { at: new Date().toISOString() });
assert.equal(calls.loadBootstrap, 1, 'configuration import must full-resync connected UIs');
// With a valid pushed control plan, ordinary state events must not re-enable HTTP control-plan polling.
assert.equal(timers.length, timerCountWhilePushReady, 'push-ready live events must not schedule control-plan HTTP fallback');
// When push is unavailable, state changes must schedule the existing HTTP fallback.
app.controlPlanPushReady = false;
app.ws = { readyState: FakeWebSocket.CLOSED };
const fallbackTimerCount = timers.length;
await send('zone.updated', { id: 'z1', demand: false });
assert(timers.length > fallbackTimerCount);
assert.equal(timers[timers.length - 1].delay, 180);
// An HTTP fallback started while disconnected must never overwrite a newer pushed revision after reconnect.
let resolveApi;
apiResponder = () => new Promise(resolve => { resolveApi = resolve; });
const pendingHttp = context.loadControlPlan();
app.ws = { readyState: FakeWebSocket.OPEN };
await send('control_plan.updated', { revision: 3, plan: { marker: 'p3' } });
resolveApi({ marker: 'old-http' });
await pendingHttp;
assert.equal(app.controlPlan.marker, 'p3');
// Connection lifecycle still switches to fallback and schedules reconnect.
app.ws = null;
app.controlPlanPushReady = true;
context.connectWebSocket();
const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
assert.equal(socket.url, 'ws://controller.test/ws');
socket.readyState = FakeWebSocket.OPEN;
socket.onopen();
assert.equal(calls['connection:connected'], 1);
const beforeCloseTimers = timers.length;
socket.readyState = FakeWebSocket.CLOSED;
socket.onclose();
assert.equal(app.controlPlanPushReady, false);
assert(timers.length >= beforeCloseTimers + 2, 'close must schedule fallback and reconnect');
console.log(`Live realtime test OK (${apiCalls.length} API fallback/resync calls observed)`);
}
main().catch(error => {
console.error(error.stack || error);
process.exit(1);
});