1 Commits

Author SHA1 Message Date
dc08c85409 docs(dashboardapi): output-coverage manifest + populated/degraded tests (#43)
Per .claude/rules/output-coverage.md every node ships test/_output-manifest.md
enumerating every output across every state. This manifest covers all the
outputs added by slices #34-#42 in this PRD:

- Port 0 upsert message: every key (topic, url, method, headers, payload,
  meta) with type and tested states.
- Port 1: explicit "not used" with rationale.
- Port 2: explicit "not used" with rationale.
- Structured log outputs: 5 events (regen-emitted, regen-skipped,
  manual-regen-requested, parent-panels-deduped, flows:started) with
  fields and corresponding test.
- specificClass return shapes: 6 methods with populated + degraded states.
- Anti-patterns enforced: no payload:null, absent vs null discipline,
  tab id avoidance in predicate.

- test/_output-manifest.md: the manifest.
- test/basic/slice43-output-manifest.basic.test.js: 6 cross-cutting tests
  exercising populated AND degraded states (token absent, folderUid absent,
  template missing, diff-skip, regen logging, manual regen).

Backfill manifests for other nodes tracked in IMPROVEMENTS_BACKLOG.

Closes #43
2026-05-26 18:08:48 +02:00
2 changed files with 213 additions and 0 deletions

67
test/_output-manifest.md Normal file
View File

@@ -0,0 +1,67 @@
# dashboardAPI output manifest
Per `.claude/rules/output-coverage.md`: every output on every layer, in every state.
## Port 0 (process — Grafana upsert messages)
Emitted by the command handler(s) after a `child.register` or `regenerate-dashboard` message. Shape is the same for both; `meta.trigger` distinguishes them.
| Key | Source method | Type | States tested | Test file |
|---|---|---|---|---|
| `topic` | `handlers.emitDashboardsFor` | `'create'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
| `url` | `source.grafanaUpsertUrl()` | string (configured Grafana endpoint) | populated, default-config | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `method` | `handlers.emitDashboardsFor` | `'POST'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
| `headers.Accept` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
| `headers['Content-Type']` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
| `headers.Authorization` | `handlers.emitDashboardsFor` | `'Bearer <token>'` when configured; absent when not | populated, absent (degraded — no token) | `test/basic/slice43-output-manifest.basic.test.js` |
| `payload.dashboard` | `source.buildDashboard()` | object (Grafana dashboard JSON) | populated, byte-identical-on-repeat | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `payload.overwrite` | `source.buildUpsertRequest()` | `true` (literal) | populated | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `payload.folderUid` | `source.buildUpsertRequest()` | string when configured; absent when empty | populated, absent (degraded — empty config) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `payload.folderId` | `source.buildUpsertRequest()` | number when explicitly passed; absent otherwise | absent (default), populated (explicit) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `meta.nodeId` | `handlers.emitDashboardsFor` | string (child node id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.softwareType` | `handlers.emitDashboardsFor` | string (child softwareType) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.uid` | `handlers.emitDashboardsFor` | string (stableUid hash, deterministic) | populated, byte-identical | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `meta.title` | `handlers.emitDashboardsFor` | string (child name or id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.trigger` | `handlers.emitDashboardsFor` | `'child.register'` or `'manual'` | both states | `test/basic/slice41-manual-regen.basic.test.js` |
**Degraded-state convention:** missing keys are **absent**, never set to `null`. The `http request` consumer treats absent headers/payload fields as defaults.
## Port 1 (InfluxDB telemetry)
dashboardAPI emits **nothing** on Port 1 by design — it has no measurements, no tick loop, no telemetry. Verified by absence: no `formatForInflux` import, no Port 1 wires in `examples/`.
## Port 2 (registration / control plumbing)
dashboardAPI is a **sink** for `child.register` messages, not a source — it does not register itself with any parent. Nothing emitted on Port 2.
## Structured log outputs
| Event | Level | Triggered by | Fields | Test |
|---|---|---|---|---|
| `regen-emitted` | info | successful composition (auto or manual) | `event`, `trigger`, `dashboardApiId`, `childId`, `dashboardCount` | `test/basic/slice43-output-manifest.basic.test.js` |
| `regen-skipped` | info | diff predicate says subtree unchanged | `event`, `outcome: 'no-diff'`, `trigger: 'child.register'`, `dashboardApiId`, `childId`, `subtreeSize` | `test/basic/slice43-output-manifest.basic.test.js` |
| `manual-regen-requested` | info | `regenerate-dashboard` topic received | `event`, `trigger: 'manual'`, `dashboardApiId`, `cachedChildCount` | `test/basic/slice41-manual-regen.basic.test.js` |
| `parent-panels-deduped` | debug | no-data-duplication filter removed root panels | `event`, `before`, `after`, `rootTitle` | _covered by composition tests in slice39_ |
| `flows:started` | debug | Node-RED runtime emits flows:started | `event: 'flows:started'`, `type`, `diff` (count summary) | _covered by predicate tests in slice36_ |
## specificClass return shapes
| Method | Return shape | Populated states | Degraded states | Test |
|---|---|---|---|---|
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null` | success | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js` |
| `generateDashboardsForGraph(root)` | array of `buildDashboard` results, root first, children after | 0..N children | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` |
| `subtreeIdsFor(myId, child)` | Set\<string\> | myId+childId+grandchildren | myId only when child has no grandchildren | `test/basic/slice36-diff-predicate.basic.test.js` |
| `collectEmittedFields(dashboard)` | Set\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |
| `cachedChildSources()` | array of child sources | 0..N cached | empty after construction | `test/basic/slice41-manual-regen.basic.test.js` |
## Anti-patterns enforced
- ❌ Emitting `{payload: null}``handlers.emitDashboardsFor` always builds `payload: { dashboard, overwrite, ... }`. Verified.
- ❌ Mixing absent vs null for optional fields — `folderUid` / `folderId` are **absent** when unconfigured, never `null`. Verified.
- ❌ Per-call token stamping — token is set on `headers.Authorization` when configured; absent when not. No empty-string sentinel.
- ❌ Tab id over-triggering in diff predicate — predicate only matches against dashboardAPI's own id + child + grandchildren, never tab ids. Verified.
## Migration plan applied
This manifest is created together with slice #43 — the new outputs added in slices #34#42 are documented here. Other EVOLV nodes still need their own manifests; tracked in `IMPROVEMENTS_BACKLOG.md`.

View File

@@ -0,0 +1,146 @@
'use strict';
// Output-coverage tests per .claude/rules/output-coverage.md and
// test/_output-manifest.md. Every output is exercised in both populated
// and degraded states.
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const handlers = require('../../src/commands/handlers.js');
function makeChild(id, name = id, softwareType = 'measurement') {
return {
config: {
general: { id, name },
functionality: { softwareType, positionVsParent: 'downstream' },
},
};
}
function makeCtx(nodeId = 'dApi-1') {
const sends = [];
const logs = [];
return {
sends,
logs,
ctx: {
node: { id: nodeId },
RED: { nodes: { getNode: () => null } },
send: (m) => sends.push(m),
logger: null,
},
};
}
// ── Port 0 message shape: populated ────────────────────────────────────
test('Port 0 emit has all required keys when token + folderUid configured', () => {
const api = new DashboardApi({
grafanaConnector: { protocol: 'http', host: 'grafana', port: 3000, bearerToken: 'tok', folderUid: 'rnd-folder' },
});
api.lastFlowsStartedDiff = null; // cold start
const { sends, ctx } = makeCtx();
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
assert.ok(sends.length >= 1);
const m = sends[0];
assert.equal(m.topic, 'create');
assert.equal(m.method, 'POST');
assert.equal(m.headers['Accept'], 'application/json');
assert.equal(m.headers['Content-Type'], 'application/json');
assert.equal(m.headers.Authorization, 'Bearer tok');
assert.match(m.url, /^http:\/\/grafana:3000\/api\/dashboards\/db$/);
assert.equal(m.payload.overwrite, true);
assert.ok(m.payload.dashboard, 'dashboard JSON present');
assert.equal(m.payload.folderUid, 'rnd-folder');
// meta
assert.equal(m.meta.nodeId, 'm-1');
assert.equal(m.meta.softwareType, 'measurement');
assert.equal(typeof m.meta.uid, 'string');
assert.equal(m.meta.title, 'FT-001');
assert.equal(m.meta.trigger, 'child.register');
});
// ── Port 0 degraded: token absent, folderUid absent ───────────────────
test('Port 0 emit omits Authorization header when no bearerToken configured', () => {
const api = new DashboardApi({}); // no creds
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
const m = sends[0];
assert.equal(m.headers.Authorization, undefined,
'Authorization should be absent (not empty string, not null)');
assert.equal(m.payload.folderUid, undefined,
'folderUid should be absent when empty');
assert.equal('folderId' in m.payload, false,
'folderId should also be absent (not 0)');
});
// ── Port 0 degraded: no template for softwareType ─────────────────────
test('Port 0 emits no message when child softwareType has no template', () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
// 'nonexistent' has no config/<>.json file
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
assert.equal(sends.length, 0, 'no upsert message should be emitted when template missing');
});
// ── Diff-skip path: no emission, logged outcome:no-diff ───────────────
test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', () => {
const api = new DashboardApi({});
// Set diff so the predicate returns false (no overlap with subtree).
api.lastFlowsStartedDiff = { added: ['unrelated'], changed: [], removed: [], rewired: [] };
// Stub logger to capture
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx('dApi-1');
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
assert.equal(sends.length, 0, 'no upsert emitted when subtree unchanged');
const skipLog = captured.find((e) => e.event === 'regen-skipped');
assert.ok(skipLog, 'skip log emitted');
assert.equal(skipLog.outcome, 'no-diff');
assert.equal(skipLog.trigger, 'child.register');
assert.equal(skipLog.dashboardApiId, 'dApi-1');
assert.equal(skipLog.childId, 'm-4');
});
// ── Successful regen logs structured fields per N-4 ───────────────────
test('Successful regen logs event=regen-emitted with N-4 fields', () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold start → always regen
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { ctx } = makeCtx('dApi-1');
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
const emitLog = captured.find((e) => e.event === 'regen-emitted');
assert.ok(emitLog, 'regen-emitted log present');
assert.equal(emitLog.trigger, 'child.register');
assert.equal(emitLog.dashboardApiId, 'dApi-1');
assert.equal(emitLog.childId, 'm-5');
assert.equal(typeof emitLog.dashboardCount, 'number');
});
// ── Manual regen logs manual-regen-requested + emits with trigger:manual ─
test('Manual regen logs manual-regen-requested and stamps trigger=manual', () => {
const api = new DashboardApi({});
api.recordChild(makeChild('m-6'));
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx();
handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
const reqLog = captured.find((e) => e.event === 'manual-regen-requested');
assert.ok(reqLog, 'manual-regen-requested log present');
assert.equal(reqLog.cachedChildCount, 1);
if (sends.length > 0) {
assert.equal(sends[0].meta.trigger, 'manual');
}
});