import { describe, it, expect } from "vitest"; import { nodeIdOf, rollupMetrics, edgeActivity, fmtCount, fmtLatency } from "../node-metrics"; import type { TelemetryRecord } from "../telemetry"; const rec = (r: Partial & { kind: string }): TelemetryRecord => r as TelemetryRecord; describe("node-metrics: nodeIdOf", () => { it("maps each kind to its graph node id via the kind-specific name field", () => { expect(nodeIdOf(rec({ kind: "action.completed", action: "moderation.submit-post" }))).toBe( "action:moderation.submit-post", ); expect(nodeIdOf(rec({ kind: "query.executed", query: "moderation.list-pending" }))).toBe( "query:moderation.list-pending", ); expect(nodeIdOf(rec({ kind: "projection.folded", projection: "queue-dashboard" }))).toBe( "projection:queue-dashboard", ); expect(nodeIdOf(rec({ kind: "actor.transitioned", actor: "post" }))).toBe("actor:post"); expect(nodeIdOf(rec({ kind: "reaction.fired", workflow: "auto-moderate" }))).toBe( "workflow:auto-moderate", ); expect(nodeIdOf(rec({ kind: "external.call.completed", call: "stripe.charge" }))).toBe( "externalCall:stripe.charge", ); expect(nodeIdOf(rec({ kind: "dlq.recorded", action: "moderation.submit-post" }))).toBe( "action:moderation.submit-post", ); }); it("reads the event name from a string or an EventMessage object", () => { // event.emitted / listener.fired stamp a string name. expect(nodeIdOf(rec({ kind: "event.emitted", event: "moderation.post-was-submitted" }))).toBe( "event:moderation.post-was-submitted", ); expect(nodeIdOf(rec({ kind: "listener.fired", event: "moderation.post-was-approved" }))).toBe( "event:moderation.post-was-approved", ); // event.published stamps an EventMessage object carrying `eventName` // (forge's wire shape) — or `name` on other event-message shapes. expect( nodeIdOf( rec({ kind: "event.published", event: { eventName: "moderation.post-was-rejected" } }), ), ).toBe("event:moderation.post-was-rejected"); expect( nodeIdOf(rec({ kind: "event.deduped", event: { name: "moderation.post-was-rejected" } })), ).toBe("event:moderation.post-was-rejected"); }); it("returns null for stage/hook records that name no primitive", () => { expect(nodeIdOf(rec({ kind: "hook.step", hookName: "publish" }))).toBeNull(); expect(nodeIdOf(rec({ kind: "source.stage", stage: "auth" }))).toBeNull(); expect(nodeIdOf(rec({ kind: "sink.stage", stage: "outbox" }))).toBeNull(); expect(nodeIdOf(rec({ kind: "action.completed" }))).toBeNull(); // no name field }); }); describe("node-metrics: rollupMetrics", () => { it("returns an empty rollup for no records (neutral-placeholder state)", () => { const r = rollupMetrics([]); expect(r.total).toBe(0); expect(r.byNode.size).toBe(0); expect(r.byApp.size).toBe(0); }); it("counts throughput, errors, and p50 latency per node", () => { const r = rollupMetrics([ rec({ kind: "action.completed", action: "a.x", durationMs: 10, appName: "a", ts: "1" }), rec({ kind: "action.completed", action: "a.x", durationMs: 30, appName: "a", ts: "2" }), rec({ kind: "action.failed", action: "a.x", durationMs: 20, appName: "a", ts: "3", error: { name: "E", message: "boom" }, }), ]); const m = r.byNode.get("action:a.x")!; expect(m.count).toBe(3); expect(m.errors).toBe(1); expect(m.p50).toBe(20); // median of [10,20,30] expect(m.lastTs).toBe("3"); }); it("rolls dispatches/errors/total up per app", () => { const r = rollupMetrics([ rec({ kind: "action.dispatched", action: "a.x", appName: "alpha", ts: "1" }), rec({ kind: "query.executed", query: "a.q", appName: "alpha", ts: "2" }), rec({ kind: "event.emitted", event: "a.e", appName: "alpha", ts: "3" }), rec({ kind: "action.failed", action: "a.x", appName: "alpha", ts: "4", error: { name: "E", message: "x" }, }), ]); const m = r.byApp.get("alpha")!; expect(m.dispatches).toBe(2); // action.dispatched + query.executed expect(m.errors).toBe(1); expect(m.total).toBe(4); expect(m.lastTs).toBe("4"); }); it("is deterministic — same records yield the same rollup", () => { const recs = [ rec({ kind: "action.completed", action: "a.x", durationMs: 5, appName: "a", ts: "1" }), rec({ kind: "query.executed", query: "a.q", durationMs: 7, appName: "a", ts: "2" }), ]; expect(rollupMetrics(recs)).toEqual(rollupMetrics([...recs])); }); }); describe("node-metrics: edgeActivity", () => { it("attributes the destination node's volume to the edge and flags it active", () => { const byNode = new Map([ ["event:a.e", { count: 4, errors: 0 }], ["action:a.x", { count: 0, errors: 0 }], ]); const act = edgeActivity(byNode, [ { id: "action:a.x=>event:a.e", from: "action:a.x", to: "event:a.e" }, { id: "event:a.e=>action:a.x", from: "event:a.e", to: "action:a.x" }, ]); expect(act.get("action:a.x=>event:a.e")).toEqual({ volume: 4, active: true }); expect(act.get("event:a.e=>action:a.x")).toEqual({ volume: 0, active: false }); }); }); describe("node-metrics: formatters", () => { it("fmtCount shows a dim em dash for zero/undefined, the number otherwise", () => { expect(fmtCount(undefined)).toBe("—"); expect(fmtCount(0)).toBe("—"); expect(fmtCount(7)).toBe("7"); }); it("fmtLatency formats sub-ms, ms, and seconds; '—' when none", () => { expect(fmtLatency(undefined)).toBe("—"); expect(fmtLatency(0.4)).toBe("<1ms"); expect(fmtLatency(12)).toBe("12ms"); expect(fmtLatency(1234)).toBe("1.2s"); }); });