From 00858eb853d1714803a310e7171c5dd8219ddd08 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:37:09 +0100 Subject: [PATCH] before functional changes by codex --- README.md | 14 +- examples/README.md | 31 + examples/basic.flow.json | 365 +++++++++ examples/edge.flow.json | 6 + examples/integration.flow.json | 6 + examples/monster-api-dashboard.flow.json | 743 ++++++++++++++++++ examples/monster-dashboard.flow.json | 483 ++++++++++++ package.json | 2 +- test/README.md | 12 + test/basic/.gitkeep | 0 test/basic/constructor.basic.test.js | 27 + .../basic/structure-module-load.basic.test.js | 8 + test/edge/.gitkeep | 0 test/edge/sampling-guards.edge.test.js | 58 ++ .../structure-examples-node-type.edge.test.js | 21 + test/helpers/.gitkeep | 0 test/helpers/factories.js | 128 +++ test/integration/.gitkeep | 0 .../flow-and-schedule.integration.test.js | 49 ++ .../structure-examples.integration.test.js | 32 + 20 files changed, 1982 insertions(+), 3 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/basic.flow.json create mode 100644 examples/edge.flow.json create mode 100644 examples/integration.flow.json create mode 100644 examples/monster-api-dashboard.flow.json create mode 100644 examples/monster-dashboard.flow.json create mode 100644 test/README.md create mode 100644 test/basic/.gitkeep create mode 100644 test/basic/constructor.basic.test.js create mode 100644 test/basic/structure-module-load.basic.test.js create mode 100644 test/edge/.gitkeep create mode 100644 test/edge/sampling-guards.edge.test.js create mode 100644 test/edge/structure-examples-node-type.edge.test.js create mode 100644 test/helpers/.gitkeep create mode 100644 test/helpers/factories.js create mode 100644 test/integration/.gitkeep create mode 100644 test/integration/flow-and-schedule.integration.test.js create mode 100644 test/integration/structure-examples.integration.test.js diff --git a/README.md b/README.md index f33b006..27b96fd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ -# convert +# monster -Makes unit conversions \ No newline at end of file +Monsternamekast control node for EVOLV. + +Primary responsibilities: +- combine measured/manual flow, rain context and schedule context +- predict sampling demand and pulse distribution over sampling window +- enforce bucket/sampling constraints (volume, weight, cooldown) +- emit process fields used by PLC pulse output, report tooling (e.g. Z-Info), and dashboards + +Examples: +- `nodes/monster/examples/monster-dashboard.flow.json` (dashboard-ready visualization flow) +- `nodes/monster/examples/monster-api-dashboard.flow.json` (full API orchestration template with placeholder credentials) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..78147d9 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,31 @@ +# Monster Example Flows + +Import-ready Node-RED examples for `monster`. + +## Files +- `basic.flow.json` + - Purpose: quick-start flow with dashboard charts for key monster outputs. +- `integration.flow.json` + - Purpose: lightweight integration contract example (`registerChild` path). +- `edge.flow.json` + - Purpose: unknown-topic/edge handling smoke example. +- `monster-dashboard.flow.json` + - Purpose: richer dashboard-focused visualization of process output. + - Includes: + - manual flow input + - manual start trigger + - seeded `rain_data` and `monsternametijden` + - parsed report fields (`m3Total`, `m3PerPuls`, `pulse`, `running`) +- `monster-api-dashboard.flow.json` + - Purpose: full orchestration template around `monster` with API paths and dashboard output. + - Includes: + - Open-Meteo weather fetch -> `rain_data` + - Aquon SFTP CSV fetch -> `monsternametijden` + - Z-Info token + import payload builder for `m3Total`/`m3PerPuls` + - dashboard API publish template (Grafana) + - placeholder-only credentials/hosts (`__SET_*__`) + +## Notes +- `basic.flow.json` and `monster-dashboard.flow.json` are intentionally API-free. +- `monster-api-dashboard.flow.json` is the full API template variant and must be hardened with environment-backed secrets before production use. +- `ui-chart` uses series by `msg.topic` (`category: "topic"`, `categoryType: "msg"`). diff --git a/examples/basic.flow.json b/examples/basic.flow.json new file mode 100644 index 0000000..7fa309e --- /dev/null +++ b/examples/basic.flow.json @@ -0,0 +1,365 @@ +[ + { + "id": "monster_basic_tab", + "type": "tab", + "label": "monster basic", + "disabled": false, + "info": "monster basic dashboard example" + }, + { + "id": "ui_base_monster_basic", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "appIcon": "", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_monster_basic", + "type": "ui-theme", + "name": "Monster Theme", + "colors": { + "surface": "#ffffff", + "primary": "#4f8582", + "bgPage": "#efefef", + "groupBg": "#ffffff", + "groupOutline": "#d8d8d8" + }, + "sizes": { + "density": "default", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", + "widgetGap": "12px" + } + }, + { + "id": "ui_page_monster_basic", + "type": "ui-page", + "name": "Monster Basic", + "ui": "ui_base_monster_basic", + "path": "/monster-basic", + "icon": "science", + "layout": "grid", + "theme": "ui_theme_monster_basic", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 1, + "className": "" + }, + { + "id": "ui_group_monster_basic_ctrl", + "type": "ui-group", + "name": "Input", + "page": "ui_page_monster_basic", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "" + }, + { + "id": "ui_group_monster_basic_obs", + "type": "ui-group", + "name": "Output", + "page": "ui_page_monster_basic", + "width": "12", + "height": "1", + "order": 2, + "showTitle": true, + "className": "" + }, + { + "id": "monster_basic_node", + "type": "monster", + "z": "monster_basic_tab", + "name": "monster basic", + "samplingtime": "24", + "minvolume": "5", + "maxweight": "23", + "nominalFlowMin": "1000", + "flowMax": "6000", + "maxRainRef": "10", + "minSampleIntervalSec": "60", + "emptyWeightBucket": "8.3", + "aquon_sample_name": "112150", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "⊥", + "hasDistance": false, + "distance": "", + "x": 710, + "y": 220, + "wires": [ + [ + "monster_basic_parse" + ], + [ + "monster_basic_dbg_influx" + ], + [ + "monster_basic_dbg_parent" + ] + ] + }, + { + "id": "monster_basic_inj_flow", + "type": "inject", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_ctrl", + "name": "flow 1800 m3/h", + "props": [ + { + "p": "payload" + } + ], + "repeat": "5", + "crontab": "", + "once": true, + "onceDelay": "1", + "topic": "", + "payload": "1800", + "payloadType": "num", + "x": 170, + "y": 180, + "wires": [ + [ + "monster_basic_build_flow" + ] + ] + }, + { + "id": "monster_basic_build_flow", + "type": "function", + "z": "monster_basic_tab", + "name": "build input_q", + "func": "msg.topic='input_q';\nmsg.payload={value:Number(msg.payload),unit:'m3/h'};\nreturn Number.isFinite(msg.payload.value)?msg:null;", + "outputs": 1, + "noerr": 0, + "x": 390, + "y": 180, + "wires": [ + [ + "monster_basic_node" + ] + ] + }, + { + "id": "monster_basic_inj_start", + "type": "inject", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_ctrl", + "name": "manual start", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "0.1", + "topic": "i_start", + "payload": "true", + "payloadType": "bool", + "x": 160, + "y": 240, + "wires": [ + [ + "monster_basic_node" + ] + ] + }, + { + "id": "monster_basic_parse", + "type": "function", + "z": "monster_basic_tab", + "name": "parse output", + "func": "const p=(msg&&msg.payload&&typeof msg.payload==='object')?msg.payload:{};\nconst now=Date.now();\nreturn [\n Number.isFinite(Number(p.q))?{topic:'q_m3h',payload:Number(p.q),timestamp:now}:null,\n Number.isFinite(Number(p.m3Total))?{topic:'m3_total',payload:Number(p.m3Total),timestamp:now}:null,\n Number.isFinite(Number(p.bucketVol))?{topic:'bucket_l',payload:Number(p.bucketVol),timestamp:now}:null,\n Number.isFinite(Number(p.m3PerPuls||p.m3PerPulse))?{topic:'m3_per_pulse',payload:Number(p.m3PerPuls||p.m3PerPulse),timestamp:now}:null,\n {topic:'status',payload:`running=${Boolean(p.running)} | pulse=${Boolean(p.pulse)} | remaining=${Number(p.pulsesRemaining||0)}`}\n];", + "outputs": 5, + "noerr": 0, + "x": 930, + "y": 220, + "wires": [ + [ + "monster_basic_chart_q" + ], + [ + "monster_basic_chart_total" + ], + [ + "monster_basic_chart_bucket" + ], + [ + "monster_basic_chart_pulse" + ], + [ + "monster_basic_text_status" + ] + ] + }, + { + "id": "monster_basic_chart_q", + "type": "ui-chart", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_obs", + "name": "q", + "label": "Flow q (m3/h)", + "order": 1, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "15", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1170, + "y": 120, + "wires": [] + }, + { + "id": "monster_basic_chart_total", + "type": "ui-chart", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_obs", + "name": "m3Total", + "label": "m3Total", + "order": 2, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "15", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1180, + "y": 180, + "wires": [] + }, + { + "id": "monster_basic_chart_bucket", + "type": "ui-chart", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_obs", + "name": "bucket", + "label": "Bucket (L)", + "order": 3, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "15", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1180, + "y": 240, + "wires": [] + }, + { + "id": "monster_basic_chart_pulse", + "type": "ui-chart", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_obs", + "name": "m3PerPuls", + "label": "m3PerPuls", + "order": 4, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "15", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1190, + "y": 300, + "wires": [] + }, + { + "id": "monster_basic_text_status", + "type": "ui-text", + "z": "monster_basic_tab", + "group": "ui_group_monster_basic_obs", + "name": "status", + "label": "Status", + "order": 5, + "width": 12, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1170, + "y": 360, + "wires": [] + }, + { + "id": "monster_basic_dbg_influx", + "type": "debug", + "z": "monster_basic_tab", + "name": "influx output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 930, + "y": 300, + "wires": [] + }, + { + "id": "monster_basic_dbg_parent", + "type": "debug", + "z": "monster_basic_tab", + "name": "parent output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 920, + "y": 340, + "wires": [] + } +] diff --git a/examples/edge.flow.json b/examples/edge.flow.json new file mode 100644 index 0000000..671aa3f --- /dev/null +++ b/examples/edge.flow.json @@ -0,0 +1,6 @@ +[ + {"id":"monster_edge_tab","type":"tab","label":"monster edge","disabled":false,"info":"monster edge example"}, + {"id":"monster_edge_node","type":"monster","z":"monster_edge_tab","name":"monster edge","x":420,"y":180,"wires":[["monster_edge_dbg"]]}, + {"id":"monster_edge_inj","type":"inject","z":"monster_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["monster_edge_node"]]}, + {"id":"monster_edge_dbg","type":"debug","z":"monster_edge_tab","name":"monster edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]} +] diff --git a/examples/integration.flow.json b/examples/integration.flow.json new file mode 100644 index 0000000..270a573 --- /dev/null +++ b/examples/integration.flow.json @@ -0,0 +1,6 @@ +[ + {"id":"monster_int_tab","type":"tab","label":"monster integration","disabled":false,"info":"monster integration example"}, + {"id":"monster_int_node","type":"monster","z":"monster_int_tab","name":"monster integration","x":420,"y":180,"wires":[["monster_int_dbg"]]}, + {"id":"monster_int_inj","type":"inject","z":"monster_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["monster_int_node"]]}, + {"id":"monster_int_dbg","type":"debug","z":"monster_int_tab","name":"monster integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]} +] diff --git a/examples/monster-api-dashboard.flow.json b/examples/monster-api-dashboard.flow.json new file mode 100644 index 0000000..3a3dfb0 --- /dev/null +++ b/examples/monster-api-dashboard.flow.json @@ -0,0 +1,743 @@ +[ + { + "id": "monster_api_tab", + "type": "tab", + "label": "Monster API + Dashboard", + "disabled": false, + "info": "Full monster orchestration example with API integrations. Credentials are placeholders." + }, + { + "id": "ui_base_monster_api", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "appIcon": "", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_monster_api", + "type": "ui-theme", + "name": "Monster API Theme", + "colors": { + "surface": "#ffffff", + "primary": "#4f8582", + "bgPage": "#efefef", + "groupBg": "#ffffff", + "groupOutline": "#d8d8d8" + }, + "sizes": { + "density": "default", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", + "widgetGap": "12px" + } + }, + { + "id": "ui_page_monster_api", + "type": "ui-page", + "name": "Monster API", + "ui": "ui_base_monster_api", + "path": "/monster-api", + "icon": "science", + "layout": "grid", + "theme": "ui_theme_monster_api", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 1, + "className": "" + }, + { + "id": "ui_group_monster_api_ctrl", + "type": "ui-group", + "name": "Input", + "page": "ui_page_monster_api", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "" + }, + { + "id": "ui_group_monster_api_obs", + "type": "ui-group", + "name": "Output", + "page": "ui_page_monster_api", + "width": "12", + "height": "1", + "order": 2, + "showTitle": true, + "className": "" + }, + { + "id": "monster_api_node", + "type": "monster", + "z": "monster_api_tab", + "name": "Monster API", + "samplingtime": "24", + "minvolume": "5", + "maxweight": "23", + "nominalFlowMin": "1000", + "flowMax": "6000", + "maxRainRef": "10", + "minSampleIntervalSec": "60", + "emptyWeightBucket": "8.3", + "aquon_sample_name": "112150", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "⊥", + "hasDistance": false, + "distance": "", + "x": 980, + "y": 320, + "wires": [ + [ + "monster_api_parse_output", + "monster_api_zinfo_prepare" + ], + [ + "monster_api_dbg_influx" + ], + [ + "monster_api_dbg_parent" + ] + ] + }, + { + "id": "monster_api_info", + "type": "comment", + "z": "monster_api_tab", + "name": "Template only: set credentials/URLs before production", + "info": "All secrets in this flow are placeholders. Replace with env vars or credential nodes.", + "x": 260, + "y": 80, + "wires": [] + }, + { + "id": "monster_api_inj_flow", + "type": "inject", + "z": "monster_api_tab", + "group": "ui_group_monster_api_ctrl", + "name": "Flow 1800 m3/h", + "props": [ + { + "p": "payload" + } + ], + "repeat": "5", + "crontab": "", + "once": true, + "onceDelay": "1", + "topic": "", + "payload": "1800", + "payloadType": "num", + "x": 170, + "y": 180, + "wires": [ + [ + "monster_api_build_flow" + ] + ] + }, + { + "id": "monster_api_build_flow", + "type": "function", + "z": "monster_api_tab", + "name": "Build input_q", + "func": "msg.topic='input_q';\nmsg.payload={value:Number(msg.payload),unit:'m3/h'};\nreturn Number.isFinite(msg.payload.value)?msg:null;", + "outputs": 1, + "noerr": 0, + "x": 390, + "y": 180, + "wires": [ + [ + "monster_api_node" + ] + ] + }, + { + "id": "monster_api_inj_start", + "type": "inject", + "z": "monster_api_tab", + "group": "ui_group_monster_api_ctrl", + "name": "Manual Start", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "0.1", + "topic": "i_start", + "payload": "true", + "payloadType": "bool", + "x": 160, + "y": 240, + "wires": [ + [ + "monster_api_node" + ] + ] + }, + { + "id": "monster_api_weather_trigger", + "type": "inject", + "z": "monster_api_tab", + "name": "Weather fetch (daily)", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "55 07 * * *", + "once": false, + "onceDelay": "", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 190, + "y": 420, + "wires": [ + [ + "monster_api_weather_http" + ] + ] + }, + { + "id": "monster_api_weather_http", + "type": "http request", + "z": "monster_api_tab", + "name": "Open-Meteo", + "method": "GET", + "ret": "txt", + "paytoqs": "ignore", + "url": "https://api.open-meteo.com/v1/forecast?latitude=51.71&longitude=4.81&hourly=precipitation,precipitation_probability&timezone=Europe%2FBerlin&past_days=1&forecast_days=2", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 380, + "y": 420, + "wires": [ + [ + "monster_api_weather_json" + ] + ] + }, + { + "id": "monster_api_weather_json", + "type": "json", + "z": "monster_api_tab", + "name": "rain_data", + "property": "payload", + "action": "", + "pretty": false, + "x": 550, + "y": 420, + "wires": [ + [ + "monster_api_weather_topic" + ] + ] + }, + { + "id": "monster_api_weather_topic", + "type": "change", + "z": "monster_api_tab", + "name": "topic rain_data", + "rules": [ + { + "t": "set", + "p": "topic", + "pt": "msg", + "to": "rain_data", + "tot": "str" + } + ], + "x": 720, + "y": 420, + "wires": [ + [ + "monster_api_node" + ] + ] + }, + { + "id": "monster_api_aquon_trigger", + "type": "inject", + "z": "monster_api_tab", + "name": "Aquon fetch (daily)", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "15 07 * * *", + "once": false, + "onceDelay": "", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 180, + "y": 500, + "wires": [ + [ + "monster_api_sftp_get" + ] + ] + }, + { + "id": "monster_api_sftp_get", + "type": "sftp in", + "z": "monster_api_tab", + "sftp": "monster_api_sftp_cfg", + "operation": "get", + "filename": "wsBD_MONSTERNAMETIJDEN.csv", + "localFilename": "./.node-red/node_modules/typicals/monster/config/monsternametijden.csv", + "name": "Aquon schedule", + "x": 380, + "y": 500, + "wires": [ + [ + "monster_api_file_in" + ] + ] + }, + { + "id": "monster_api_file_in", + "type": "file in", + "z": "monster_api_tab", + "name": "read monsternametijden", + "filename": "./.node-red/node_modules/typicals/monster/config/monsternametijden.csv", + "filenameType": "str", + "format": "utf8", + "chunk": false, + "sendError": false, + "encoding": "none", + "allProps": false, + "x": 590, + "y": 500, + "wires": [ + [ + "monster_api_csv" + ] + ] + }, + { + "id": "monster_api_csv", + "type": "csv", + "z": "monster_api_tab", + "name": "monsternametijden", + "sep": ",", + "hdrin": true, + "hdrout": "all", + "multi": "mult", + "ret": "\\n", + "temp": "SAMPLE_NAME,DESCRIPTION,SAMPLED_DATE,START_DATE,END_DATE", + "skip": "0", + "strings": true, + "include_empty_strings": "", + "include_null_values": "", + "x": 780, + "y": 500, + "wires": [ + [ + "monster_api_schedule_topic" + ] + ] + }, + { + "id": "monster_api_schedule_topic", + "type": "change", + "z": "monster_api_tab", + "name": "topic monsternametijden", + "rules": [ + { + "t": "set", + "p": "topic", + "pt": "msg", + "to": "monsternametijden", + "tot": "str" + } + ], + "x": 990, + "y": 500, + "wires": [ + [ + "monster_api_node" + ] + ] + }, + { + "id": "monster_api_zinfo_prepare", + "type": "function", + "z": "monster_api_tab", + "name": "Z-Info prepare on run stop", + "func": "const p=(msg&&msg.payload&&typeof msg.payload==='object')?msg.payload:{};\nconst runningNow=Boolean(p.running);\nconst runningPrev=Boolean(context.get('runningPrev'));\ncontext.set('runningPrev',runningNow);\nif(!(runningPrev && !runningNow)){\n return null;\n}\nconst today=new Date();\nconst day=String(today.getDate()).padStart(2,'0');\nconst month=String(today.getMonth()+1).padStart(2,'0');\nconst year=today.getFullYear();\nconst yesterdayDate=new Date(today.getTime()-24*3600*1000);\nconst yDay=String(yesterdayDate.getDate()).padStart(2,'0');\nconst yMonth=String(yesterdayDate.getMonth()+1).padStart(2,'0');\nconst yYear=yesterdayDate.getFullYear();\nmsg.zinfoDateFrom=`${yYear}-${yMonth}-${yDay}`;\nmsg.zinfoDateUntil=`${year}-${month}-${day}`;\nmsg.zinfoData={\n m3Total:Number(p.m3Total||0),\n pulse:Math.max(0,Math.floor(Number(p.m3PerPuls||p.m3PerPulse||0)))\n};\nmsg.payload='grant_type=password&username=__SET_ZINFO_USERNAME__&password=__SET_ZINFO_PASSWORD__&client_id=__SET_ZINFO_CLIENT_ID__&client_secret=__SET_ZINFO_CLIENT_SECRET__';\nmsg.headers=msg.headers||{};\nmsg.headers['content-type']='application/x-www-form-urlencoded';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "x": 1260, + "y": 320, + "wires": [ + [ + "monster_api_zinfo_token" + ] + ] + }, + { + "id": "monster_api_zinfo_token", + "type": "http request", + "z": "monster_api_tab", + "name": "Z-Info token", + "method": "POST", + "ret": "txt", + "paytoqs": "ignore", + "url": "https://__SET_ZINFO_HOST__/WSR/zi_wsr.svc/token", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 1450, + "y": 320, + "wires": [ + [ + "monster_api_zinfo_token_json" + ] + ] + }, + { + "id": "monster_api_zinfo_token_json", + "type": "json", + "z": "monster_api_tab", + "name": "token json", + "property": "payload", + "action": "", + "pretty": false, + "x": 1630, + "y": 320, + "wires": [ + [ + "monster_api_zinfo_import_builder" + ] + ] + }, + { + "id": "monster_api_zinfo_import_builder", + "type": "function", + "z": "monster_api_tab", + "name": "Build Z-Info import", + "func": "const token=msg.payload&&msg.payload.access_token;\nconst z=msg.zinfoData||{};\nconst from=msg.zinfoDateFrom;\nconst until=msg.zinfoDateUntil;\nconst ns='__SET_ZINFO_NAMESPACE__';\nmsg.payload={\n import:{\n algemeen:{\n AanleverendeOrganisatie:'NL.25',\n Versie:'IMm2018',\n Batchid:`ZI_PA_NL.25_${Date.now()}.json`,\n Systeembron:'WBD/NEERSG',\n Systeemdoel:'HWH/Z-info',\n Opmerking:'template'\n },\n data:[{\n Meetwaarden:[\n {mepid:`${ns}.F021.m3`,dbmDtm:from,dbmTijd:'06:00',demDtm:until,demTijd:'06:00',mwdWaarde:`${Number(z.m3Total||0)}`,mwdWaardeAN:'',nMwd:'',mwdOpmerk:'template'},\n {mepid:`${ns}.Q000.PULS`,dbmDtm:from,dbmTijd:'06:00',demDtm:until,demTijd:'06:00',mwdWaarde:`${Number(z.pulse||0)}`,mwdWaardeAN:'',nMwd:'',mwdOpmerk:'template'}\n ]\n }]\n }\n};\nmsg.headers=msg.headers||{};\nif(token){msg.headers.authorization='Bearer '+token;}\nmsg.headers['content-type']='application/json';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "x": 1830, + "y": 320, + "wires": [ + [ + "monster_api_zinfo_import_put" + ] + ] + }, + { + "id": "monster_api_zinfo_import_put", + "type": "http request", + "z": "monster_api_tab", + "name": "Z-Info import PUT", + "method": "PUT", + "ret": "txt", + "paytoqs": "ignore", + "url": "https://__SET_ZINFO_HOST__/WSR/zi_wsr.svc/json/NL.25/importmwd/pa/?gebruiker=__SET_ZINFO_USER__", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 2040, + "y": 320, + "wires": [ + [ + "monster_api_dbg_zinfo" + ] + ] + }, + { + "id": "monster_api_dbg_zinfo", + "type": "debug", + "z": "monster_api_tab", + "name": "Z-Info response", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "targetType": "full", + "x": 2250, + "y": 320, + "wires": [] + }, + { + "id": "monster_api_parse_output", + "type": "function", + "z": "monster_api_tab", + "name": "Parse output for dashboard", + "func": "const p=(msg&&msg.payload&&typeof msg.payload==='object')?msg.payload:{};\nconst now=Date.now();\nconst m3PerPuls=Number(p.m3PerPuls||p.m3PerPulse);\nreturn [\n Number.isFinite(Number(p.q))?{topic:'q_m3h',payload:Number(p.q),timestamp:now}:null,\n Number.isFinite(Number(p.m3Total))?{topic:'m3_total',payload:Number(p.m3Total),timestamp:now}:null,\n Number.isFinite(Number(p.bucketVol))?{topic:'bucket_l',payload:Number(p.bucketVol),timestamp:now}:null,\n Number.isFinite(m3PerPuls)?{topic:'m3_per_pulse',payload:m3PerPuls,timestamp:now}:null,\n {topic:'status',payload:`running=${Boolean(p.running)} | pulse=${Boolean(p.pulse)} | m3PerPuls=${Number.isFinite(m3PerPuls)?m3PerPuls:'n/a'} | missed=${Number(p.missedSamples||0)}`}\n];", + "outputs": 5, + "noerr": 0, + "x": 1240, + "y": 220, + "wires": [ + [ + "monster_api_chart_q" + ], + [ + "monster_api_chart_total" + ], + [ + "monster_api_chart_bucket" + ], + [ + "monster_api_chart_pulse" + ], + [ + "monster_api_text_status" + ] + ] + }, + { + "id": "monster_api_chart_q", + "type": "ui-chart", + "z": "monster_api_tab", + "group": "ui_group_monster_api_obs", + "name": "q", + "label": "Flow q (m3/h)", + "order": 1, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1470, + "y": 120, + "wires": [] + }, + { + "id": "monster_api_chart_total", + "type": "ui-chart", + "z": "monster_api_tab", + "group": "ui_group_monster_api_obs", + "name": "m3Total", + "label": "m3Total", + "order": 2, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1480, + "y": 180, + "wires": [] + }, + { + "id": "monster_api_chart_bucket", + "type": "ui-chart", + "z": "monster_api_tab", + "group": "ui_group_monster_api_obs", + "name": "bucket", + "label": "Bucket (L)", + "order": 3, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1480, + "y": 240, + "wires": [] + }, + { + "id": "monster_api_chart_pulse", + "type": "ui-chart", + "z": "monster_api_tab", + "group": "ui_group_monster_api_obs", + "name": "m3PerPuls", + "label": "m3PerPuls", + "order": 4, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1490, + "y": 300, + "wires": [] + }, + { + "id": "monster_api_text_status", + "type": "ui-text", + "z": "monster_api_tab", + "group": "ui_group_monster_api_obs", + "name": "status", + "label": "Status", + "order": 5, + "width": 12, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1460, + "y": 360, + "wires": [] + }, + { + "id": "monster_api_dashboardapi", + "type": "dashboardapi", + "z": "monster_api_tab", + "name": "dashboard template", + "x": 1430, + "y": 420, + "wires": [ + [ + "monster_api_grafana_post" + ] + ] + }, + { + "id": "monster_api_grafana_post", + "type": "http request", + "z": "monster_api_tab", + "name": "Grafana dashboard API", + "method": "POST", + "ret": "txt", + "paytoqs": "ignore", + "url": "https://__SET_GRAFANA_HOST__/api/dashboards/db", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 1650, + "y": 420, + "wires": [ + [ + "monster_api_dbg_dashboard" + ] + ] + }, + { + "id": "monster_api_dbg_dashboard", + "type": "debug", + "z": "monster_api_tab", + "name": "dashboard API response", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "targetType": "full", + "x": 1870, + "y": 420, + "wires": [] + }, + { + "id": "monster_api_dbg_influx", + "type": "debug", + "z": "monster_api_tab", + "name": "influx output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 1240, + "y": 460, + "wires": [] + }, + { + "id": "monster_api_dbg_parent", + "type": "debug", + "z": "monster_api_tab", + "name": "parent output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 1230, + "y": 500, + "wires": [] + }, + { + "id": "monster_api_sftp_cfg", + "type": "sftp", + "host": "__SET_AQUON_SFTP_HOST__", + "port": "22", + "username": "__SET_AQUON_SFTP_USERNAME__", + "password": "__SET_AQUON_SFTP_PASSWORD__", + "hmac": [], + "cipher": [] + } +] diff --git a/examples/monster-dashboard.flow.json b/examples/monster-dashboard.flow.json new file mode 100644 index 0000000..1843d97 --- /dev/null +++ b/examples/monster-dashboard.flow.json @@ -0,0 +1,483 @@ +[ + { + "id": "monster_tab_demo", + "type": "tab", + "label": "Monster Dashboard Demo", + "disabled": false, + "info": "Dashboard-focused example for monster output visualization" + }, + { + "id": "ui_base_monster_demo", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "appIcon": "", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_monster_demo", + "type": "ui-theme", + "name": "EVOLV Monster Theme", + "colors": { + "surface": "#ffffff", + "primary": "#4f8582", + "bgPage": "#efefef", + "groupBg": "#ffffff", + "groupOutline": "#d8d8d8" + }, + "sizes": { + "density": "default", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", + "widgetGap": "12px" + } + }, + { + "id": "ui_page_monster_demo", + "type": "ui-page", + "name": "Monster Demo", + "ui": "ui_base_monster_demo", + "path": "/monster-demo", + "icon": "science", + "layout": "grid", + "theme": "ui_theme_monster_demo", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 1, + "className": "" + }, + { + "id": "ui_group_monster_ctrl", + "type": "ui-group", + "name": "Monster Inputs", + "page": "ui_page_monster_demo", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "" + }, + { + "id": "ui_group_monster_obs", + "type": "ui-group", + "name": "Monster Output", + "page": "ui_page_monster_demo", + "width": "12", + "height": "1", + "order": 2, + "showTitle": true, + "className": "" + }, + { + "id": "monster_node_demo", + "type": "monster", + "z": "monster_tab_demo", + "name": "Monster Demo", + "samplingtime": "24", + "minvolume": "5", + "maxweight": "23", + "nominalFlowMin": "1000", + "flowMax": "6000", + "maxRainRef": "10", + "minSampleIntervalSec": "60", + "emptyWeightBucket": "8.3", + "aquon_sample_name": "112150", + "uuid": "", + "supplier": "monster", + "category": "monster", + "assetType": "sampling-cabinet", + "model": "monster-standard", + "unit": "m3/h", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "⊥", + "hasDistance": false, + "distance": "", + "x": 900, + "y": 260, + "wires": [ + [ + "monster_parse_output" + ], + [ + "monster_debug_influx" + ], + [ + "monster_debug_parent" + ] + ] + }, + { + "id": "monster_flow_inject", + "type": "inject", + "z": "monster_tab_demo", + "group": "ui_group_monster_ctrl", + "name": "Flow 1800 m3/h", + "props": [ + { + "p": "payload" + } + ], + "repeat": "5", + "crontab": "", + "once": true, + "onceDelay": "1", + "topic": "", + "payload": "1800", + "payloadType": "num", + "x": 170, + "y": 180, + "wires": [ + [ + "monster_build_flow" + ] + ] + }, + { + "id": "monster_build_flow", + "type": "function", + "z": "monster_tab_demo", + "name": "Build input_q", + "func": "msg.topic = 'input_q';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn Number.isFinite(msg.payload.value) ? msg : null;", + "outputs": 1, + "noerr": 0, + "x": 380, + "y": 180, + "wires": [ + [ + "monster_node_demo" + ] + ] + }, + { + "id": "monster_start_inject", + "type": "inject", + "z": "monster_tab_demo", + "group": "ui_group_monster_ctrl", + "name": "Manual Start", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "0.1", + "topic": "i_start", + "payload": "true", + "payloadType": "bool", + "x": 160, + "y": 240, + "wires": [ + [ + "monster_node_demo" + ] + ] + }, + { + "id": "monster_rain_inject", + "type": "inject", + "z": "monster_tab_demo", + "group": "ui_group_monster_ctrl", + "name": "Seed rain_data", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "2", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 160, + "y": 300, + "wires": [ + [ + "monster_build_rain" + ] + ] + }, + { + "id": "monster_build_rain", + "type": "function", + "z": "monster_tab_demo", + "name": "Build rain_data", + "func": "const now = new Date();\nconst mk = (offset, rain, prob) => {\n const d = new Date(now.getTime() + offset * 3600 * 1000);\n return { t: d.toISOString().slice(0, 13) + ':00', rain, prob };\n};\nconst rows = [mk(-1, 0.2, 20), mk(0, 0.8, 40), mk(1, 1.1, 60), mk(2, 0.5, 30)];\nmsg.topic = 'rain_data';\nmsg.payload = [\n {\n latitude: 51.71,\n longitude: 4.81,\n hourly: {\n time: rows.map(r => r.t),\n precipitation: rows.map(r => r.rain),\n precipitation_probability: rows.map(r => r.prob)\n }\n }\n];\nreturn msg;", + "outputs": 1, + "noerr": 0, + "x": 380, + "y": 300, + "wires": [ + [ + "monster_node_demo" + ] + ] + }, + { + "id": "monster_schedule_inject", + "type": "inject", + "z": "monster_tab_demo", + "group": "ui_group_monster_ctrl", + "name": "Seed monsternametijden", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "3", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 190, + "y": 360, + "wires": [ + [ + "monster_build_schedule" + ] + ] + }, + { + "id": "monster_build_schedule", + "type": "function", + "z": "monster_tab_demo", + "name": "Build monsternametijden", + "func": "const now = new Date();\nconst next = new Date(now.getTime() + 24 * 3600 * 1000);\nconst end = new Date(next.getTime() + 24 * 3600 * 1000);\nmsg.topic = 'monsternametijden';\nmsg.payload = [\n {\n SAMPLE_NAME: '112150',\n DESCRIPTION: 'demo schedule',\n SAMPLED_DATE: next.toISOString().slice(0, 19).replace('T', ' '),\n START_DATE: next.toISOString().slice(0, 19).replace('T', ' '),\n END_DATE: end.toISOString().slice(0, 19).replace('T', ' ')\n }\n];\nreturn msg;", + "outputs": 1, + "noerr": 0, + "x": 410, + "y": 360, + "wires": [ + [ + "monster_node_demo" + ] + ] + }, + { + "id": "monster_parse_output", + "type": "function", + "z": "monster_tab_demo", + "name": "Parse monster output", + "func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst now = Date.now();\nconst q = Number(p.q);\nconst total = Number(p.m3Total);\nconst bucket = Number(p.bucketVol);\nconst rem = Number(p.pulsesRemaining);\nconst m3PerPulse = Number(p.m3PerPuls || p.m3PerPulse);\nconst status = `running=${Boolean(p.running)} | pulse=${Boolean(p.pulse)} | m3PerPuls=${Number.isFinite(m3PerPulse) ? m3PerPulse : 'n/a'} | missed=${Number(p.missedSamples || 0)}`;\nreturn [\n Number.isFinite(q) ? { topic: 'q_m3h', payload: q, timestamp: now } : null,\n Number.isFinite(total) ? { topic: 'm3_total', payload: total, timestamp: now } : null,\n Number.isFinite(bucket) ? { topic: 'bucket_l', payload: bucket, timestamp: now } : null,\n Number.isFinite(rem) ? { topic: 'pulses_remaining', payload: rem, timestamp: now } : null,\n Number.isFinite(m3PerPulse) ? { topic: 'm3_per_pulse', payload: m3PerPulse, timestamp: now } : null,\n { topic: 'status', payload: status }\n];", + "outputs": 6, + "noerr": 0, + "x": 1130, + "y": 260, + "wires": [ + [ + "monster_chart_q" + ], + [ + "monster_chart_m3total" + ], + [ + "monster_chart_bucket" + ], + [ + "monster_chart_remaining" + ], + [ + "monster_chart_m3pulse" + ], + [ + "monster_text_status" + ] + ] + }, + { + "id": "monster_chart_q", + "type": "ui-chart", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "Flow q", + "label": "Flow q (m3/h)", + "order": 1, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisPropertyType": "msg", + "yAxisProperty": "payload", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1370, + "y": 120, + "wires": [] + }, + { + "id": "monster_chart_m3total", + "type": "ui-chart", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "m3 Total", + "label": "m3Total (m3)", + "order": 2, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisPropertyType": "msg", + "yAxisProperty": "payload", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1380, + "y": 180, + "wires": [] + }, + { + "id": "monster_chart_bucket", + "type": "ui-chart", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "Bucket Volume", + "label": "Bucket (L)", + "order": 3, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisPropertyType": "msg", + "yAxisProperty": "payload", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1380, + "y": 240, + "wires": [] + }, + { + "id": "monster_chart_remaining", + "type": "ui-chart", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "Pulses Remaining", + "label": "Pulses Remaining", + "order": 4, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisPropertyType": "msg", + "yAxisProperty": "payload", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1400, + "y": 300, + "wires": [] + }, + { + "id": "monster_chart_m3pulse", + "type": "ui-chart", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "m3 per pulse", + "label": "m3PerPuls", + "order": 5, + "width": 6, + "height": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "xAxisPropertyType": "timestamp", + "yAxisPropertyType": "msg", + "yAxisProperty": "payload", + "removeOlder": "30", + "removeOlderUnit": "60", + "showLegend": false, + "action": "append", + "x": 1390, + "y": 360, + "wires": [] + }, + { + "id": "monster_text_status", + "type": "ui-text", + "z": "monster_tab_demo", + "group": "ui_group_monster_obs", + "name": "Sampling status", + "label": "Status", + "order": 6, + "width": 12, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1380, + "y": 420, + "wires": [] + }, + { + "id": "monster_debug_influx", + "type": "debug", + "z": "monster_tab_demo", + "name": "Influx output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 1130, + "y": 320, + "wires": [] + }, + { + "id": "monster_debug_parent", + "type": "debug", + "z": "monster_tab_demo", + "name": "Parent output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "x": 1130, + "y": 360, + "wires": [] + } +] diff --git a/package.json b/package.json index 72d658f..c1a1952 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Control module Monsternamekast", "main": "monster.js", "scripts": { - "test": "node test/monster.specific.test.js" + "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js" }, "repository": { "type": "git", diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..cda3980 --- /dev/null +++ b/test/README.md @@ -0,0 +1,12 @@ +# monster Test Suite Layout + +Required EVOLV layout: +- basic/ +- integration/ +- edge/ +- helpers/ + +Baseline structure tests: +- basic/structure-module-load.basic.test.js +- integration/structure-examples.integration.test.js +- edge/structure-examples-node-type.edge.test.js diff --git a/test/basic/.gitkeep b/test/basic/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/basic/constructor.basic.test.js b/test/basic/constructor.basic.test.js new file mode 100644 index 0000000..8e163ca --- /dev/null +++ b/test/basic/constructor.basic.test.js @@ -0,0 +1,27 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Monster = require('../../src/specificClass'); +const { makeMonsterConfig } = require('../helpers/factories'); + +test('constructor initializes sampling boundaries and target values', () => { + const monster = new Monster(makeMonsterConfig()); + + assert.equal(monster.maxVolume, 20); + assert.equal(monster.minPuls, Math.round(monster.minVolume / monster.volume_pulse)); + assert.equal(monster.absMaxPuls, Math.round(monster.cap_volume / monster.volume_pulse)); + assert.ok(monster.targetPuls > 0); +}); + +test('output contract contains report tooling fields', () => { + const monster = new Monster(makeMonsterConfig()); + const output = monster.getOutput(); + + assert.ok(Object.prototype.hasOwnProperty.call(output, 'm3PerPuls')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'm3PerPulse')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'm3Total')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'pulse')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'pulsesRemaining')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'targetDeltaM3')); + assert.ok(Object.prototype.hasOwnProperty.call(output, 'predictedRateM3h')); +}); diff --git a/test/basic/structure-module-load.basic.test.js b/test/basic/structure-module-load.basic.test.js new file mode 100644 index 0000000..d9a5203 --- /dev/null +++ b/test/basic/structure-module-load.basic.test.js @@ -0,0 +1,8 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('monster module load smoke', () => { + assert.doesNotThrow(() => { + require('../../monster.js'); + }); +}); diff --git a/test/edge/.gitkeep b/test/edge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/edge/sampling-guards.edge.test.js b/test/edge/sampling-guards.edge.test.js new file mode 100644 index 0000000..2cdc9b6 --- /dev/null +++ b/test/edge/sampling-guards.edge.test.js @@ -0,0 +1,58 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Monster = require('../../src/specificClass'); +const { makeMonsterConfig, withMockedDate } = require('../helpers/factories'); + +test('invalid flow bounds prevent sampling start', () => { + const monster = new Monster( + makeMonsterConfig({ + constraints: { + samplingtime: 1, + minVolume: 5, + maxWeight: 23, + nominalFlowMin: 10, + flowMax: 5, + minSampleIntervalSec: 60, + }, + }) + ); + + monster.handleInput('i_start', true); + monster.sampling_program(); + + assert.equal(monster.invalidFlowBounds, true); + assert.equal(monster.running, false); + assert.equal(monster.i_start, false); +}); + +test('cooldown guard blocks pulses when flow implies oversampling', () => { + withMockedDate('2024-10-15T00:00:00Z', ({ advance }) => { + const monster = new Monster( + makeMonsterConfig({ + constraints: { + samplingtime: 1, + minVolume: 5, + maxWeight: 23, + nominalFlowMin: 0, + flowMax: 6000, + maxRainRef: 10, + minSampleIntervalSec: 60, + }, + }) + ); + + monster.handleInput('input_q', { value: 200, unit: 'm3/h' }); + monster.handleInput('i_start', true); + + for (let i = 0; i < 80; i++) { + advance(1000); + monster.tick(); + } + + assert.ok(monster.sumPuls > 0); + assert.ok(monster.bucketVol > 0); + assert.ok(monster.missedSamples > 0); + assert.ok(monster.getSampleCooldownMs() > 0); + }); +}); diff --git a/test/edge/structure-examples-node-type.edge.test.js b/test/edge/structure-examples-node-type.edge.test.js new file mode 100644 index 0000000..19cd65f --- /dev/null +++ b/test/edge/structure-examples-node-type.edge.test.js @@ -0,0 +1,21 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const dir = path.resolve(__dirname, '../../examples'); +const exampleFlows = [ + 'basic.flow.json', + 'integration.flow.json', + 'edge.flow.json', + 'monster-dashboard.flow.json', + 'monster-api-dashboard.flow.json' +]; + +test('all example flows include node type monster', () => { + for (const file of exampleFlows) { + const flow = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + const count = flow.filter((n) => n && n.type === 'monster').length; + assert.equal(count >= 1, true, file + ' missing monster node'); + } +}); diff --git a/test/helpers/.gitkeep b/test/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/factories.js b/test/helpers/factories.js new file mode 100644 index 0000000..55c7593 --- /dev/null +++ b/test/helpers/factories.js @@ -0,0 +1,128 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { MeasurementContainer } = require('generalFunctions'); + +function makeMonsterConfig(overrides = {}) { + return { + general: { + name: 'Monster Test', + logging: { enabled: false, logLevel: 'error' }, + }, + asset: { + emptyWeightBucket: 3, + }, + constraints: { + samplingtime: 1, + minVolume: 5, + maxWeight: 23, + nominalFlowMin: 1000, + flowMax: 6000, + maxRainRef: 10, + minSampleIntervalSec: 60, + }, + ...overrides, + }; +} + +function withMockedDate(iso, fn) { + const RealDate = Date; + let now = new RealDate(iso).getTime(); + + class MockDate extends RealDate { + constructor(...args) { + if (args.length === 0) { + super(now); + } else { + super(...args); + } + } + + static now() { + return now; + } + } + + global.Date = MockDate; + try { + return fn({ + advance(ms) { + now += ms; + }, + }); + } finally { + global.Date = RealDate; + } +} + +function parseMonsternametijdenCsv(filePath) { + const raw = fs.readFileSync(filePath, 'utf8').trim(); + const lines = raw.split(/\r?\n/); + const header = lines.shift(); + const columns = header.split(','); + + return lines + .filter((line) => line && !line.startsWith('-----------')) + .map((line) => { + const parts = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQ = !inQ; + continue; + } + if (ch === ',' && !inQ) { + parts.push(cur); + cur = ''; + } else { + cur += ch; + } + } + parts.push(cur); + const obj = {}; + columns.forEach((col, idx) => { + obj[col] = parts[idx]; + }); + return obj; + }); +} + +function makeFlowMeasurementChild({ + id = 'flow-child-1', + name = 'FlowSensor', + positionVsParent = 'downstream', + unit = 'm3/h', +} = {}) { + const measurements = new MeasurementContainer({ + autoConvert: true, + defaultUnits: { flow: 'm3/h' }, + }); + + return { + config: { + general: { id, name, unit }, + functionality: { positionVsParent }, + asset: { type: 'flow', unit }, + }, + measurements, + }; +} + +function loadRainSeed() { + const rainPath = path.join(__dirname, '..', 'seed_data', 'raindataFormat.json'); + return JSON.parse(fs.readFileSync(rainPath, 'utf8')); +} + +function loadScheduleSeed() { + const csvPath = path.join(__dirname, '..', 'seed_data', 'monsternametijden.csv'); + return parseMonsternametijdenCsv(csvPath); +} + +module.exports = { + makeMonsterConfig, + withMockedDate, + makeFlowMeasurementChild, + loadRainSeed, + loadScheduleSeed, +}; diff --git a/test/integration/.gitkeep b/test/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/flow-and-schedule.integration.test.js b/test/integration/flow-and-schedule.integration.test.js new file mode 100644 index 0000000..ba33329 --- /dev/null +++ b/test/integration/flow-and-schedule.integration.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Monster = require('../../src/specificClass'); +const { + makeMonsterConfig, + withMockedDate, + makeFlowMeasurementChild, + loadRainSeed, + loadScheduleSeed, +} = require('../helpers/factories'); + +test('effective flow uses average of measured and manual flow', () => { + withMockedDate('2024-10-15T00:00:00Z', ({ advance }) => { + const monster = new Monster(makeMonsterConfig()); + const child = makeFlowMeasurementChild({ positionVsParent: 'downstream' }); + monster.registerChild(child, 'measurement'); + + child.measurements + .type('flow') + .variant('measured') + .position('downstream') + .value(60, Date.now(), 'm3/h'); + + monster.handleInput('input_q', { value: 20, unit: 'm3/h' }); + advance(1000); + monster.tick(); + + assert.equal(monster.q, 40); + }); +}); + +test('rain and schedule payloads update prediction context and next date', () => { + withMockedDate('2024-10-15T00:00:00Z', () => { + const monster = new Monster(makeMonsterConfig()); + const rain = loadRainSeed(); + const schedule = loadScheduleSeed(); + + monster.aquonSampleName = '112100'; + monster.handleInput('rain_data', rain); + monster.handleInput('monsternametijden', schedule); + + assert.ok(monster.avgRain >= 0); + assert.ok(monster.sumRain >= 0); + const nextDate = monster.nextDate instanceof Date ? monster.nextDate.getTime() : Number(monster.nextDate); + assert.ok(Number.isFinite(nextDate)); + assert.ok(nextDate > Date.now()); + }); +}); diff --git a/test/integration/structure-examples.integration.test.js b/test/integration/structure-examples.integration.test.js new file mode 100644 index 0000000..492dc27 --- /dev/null +++ b/test/integration/structure-examples.integration.test.js @@ -0,0 +1,32 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const dir = path.resolve(__dirname, '../../examples'); +const requiredFiles = [ + 'README.md', + 'basic.flow.json', + 'integration.flow.json', + 'edge.flow.json', + 'monster-dashboard.flow.json', + 'monster-api-dashboard.flow.json' +]; +const flowFiles = requiredFiles.filter((file) => file.endsWith('.flow.json')); + +function loadJson(file) { + return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); +} + +test('examples package exists for monster', () => { + for (const file of requiredFiles) { + assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing'); + } +}); + +test('example flows are parseable arrays for monster', () => { + for (const file of flowFiles) { + const parsed = loadJson(file); + assert.equal(Array.isArray(parsed), true); + } +});