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
This commit is contained in:
67
test/_output-manifest.md
Normal file
67
test/_output-manifest.md
Normal 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`.
|
||||||
146
test/basic/slice43-output-manifest.basic.test.js
Normal file
146
test/basic/slice43-output-manifest.basic.test.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user