mirror of
https://github.com/ruvnet/RuView
synced 2026-06-19 11:53:19 +00:00
2a2f16a380
- Add validate.ts: validateCsiWindow (56×20 shape) + validateSensingLatestResponse
(schema_version 2 pin per ADR-101); returns actionable errors on schema drift
- Wire csi-latest.ts: call validateSensingLatestResponse after every sensingGet;
return {ok:false,warn:true,raw_response,...} on mismatch so agents can inspect
- Fix csi-latest.ts: subcarriers now reads amplitudes.length (not hardcoded 56)
- Add tests/validate.test.ts: 5+5 = 10 tests covering valid, null, wrong shape,
schema_version 3, missing captured_at, window error propagation
- All 16 tests pass (validate × 10 + tools × 6); build clean
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
/**
|
||
* Tests for runtime schema validators (validate.ts).
|
||
*
|
||
* Pinned to sensing-server schema_version 2 (ADR-101).
|
||
* These tests document the exact shapes we accept and reject so that
|
||
* any schema drift from the sensing-server is caught immediately.
|
||
*/
|
||
|
||
import { validateCsiWindow, validateSensingLatestResponse } from "../src/validate.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function makeAmplitudes(rows = 56, cols = 20): number[][] {
|
||
return Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0));
|
||
}
|
||
|
||
function makeValidWindow(): unknown {
|
||
return {
|
||
ts: 1716300000.0,
|
||
n_paths: 3,
|
||
amplitudes: makeAmplitudes(),
|
||
};
|
||
}
|
||
|
||
function makeValidResponse(): unknown {
|
||
return {
|
||
schema_version: 2,
|
||
captured_at: "2026-05-21T20:00:00.000Z",
|
||
window: makeValidWindow(),
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// validateCsiWindow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("validateCsiWindow", () => {
|
||
it("accepts a valid 56×20 window", () => {
|
||
const result = validateCsiWindow(makeValidWindow());
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects null", () => {
|
||
const result = validateCsiWindow(null);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors).toContain("window is not an object");
|
||
}
|
||
});
|
||
|
||
it("rejects wrong subcarrier count (e.g. 57)", () => {
|
||
const w = makeValidWindow() as Record<string, unknown>;
|
||
w["amplitudes"] = makeAmplitudes(57, 20);
|
||
const result = validateCsiWindow(w);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("56 rows"))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("rejects wrong frame count (e.g. 10 instead of 20)", () => {
|
||
const w = makeValidWindow() as Record<string, unknown>;
|
||
w["amplitudes"] = makeAmplitudes(56, 10);
|
||
const result = validateCsiWindow(w);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("20 frames"))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("rejects missing ts field", () => {
|
||
const w = makeValidWindow() as Record<string, unknown>;
|
||
delete w["ts"];
|
||
const result = validateCsiWindow(w);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("ts"))).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// validateSensingLatestResponse
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("validateSensingLatestResponse", () => {
|
||
it("accepts a valid schema_version 2 response", () => {
|
||
const result = validateSensingLatestResponse(makeValidResponse());
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects schema_version 3 (not yet supported)", () => {
|
||
const d = makeValidResponse() as Record<string, unknown>;
|
||
d["schema_version"] = 3;
|
||
const result = validateSensingLatestResponse(d);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("schema_version 3 is not supported"))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("rejects missing captured_at", () => {
|
||
const d = makeValidResponse() as Record<string, unknown>;
|
||
delete d["captured_at"];
|
||
const result = validateSensingLatestResponse(d);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("captured_at"))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("rejects null response", () => {
|
||
const result = validateSensingLatestResponse(null);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.includes("not an object"))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("propagates window validation errors with 'window:' prefix", () => {
|
||
const d = makeValidResponse() as Record<string, unknown>;
|
||
const w = (d["window"] as Record<string, unknown>);
|
||
w["amplitudes"] = makeAmplitudes(57, 20);
|
||
const result = validateSensingLatestResponse(d);
|
||
expect(result.valid).toBe(false);
|
||
if (!result.valid) {
|
||
expect(result.errors.some((e) => e.startsWith("window:"))).toBe(true);
|
||
}
|
||
});
|
||
});
|