Stage 4: data model — typed contracts + validation + time model

- src/data/festival-package/types.ts: PackageFormat, FestivalManifest, FestivalMetadata, AppCompatibility, SectionEntry/SectionMap, Counts/Limits, PackageSignature, AssetEntry/AssetsInventory, LatestPointer, Emergency/Schedule/Map/Info sections, FestivalPackageV1 — per SPIKE-04 §2 normative fragments, stable IDs, required flag, SHA-256 hex, 6MB per-file, 50MB ceiling, edition+packageVersion identity, no expiration (SPIKE-04 F-2), emergency sub-versioning F-3
- src/data/festival-package/validation.ts: pure validation (malformed/missing/invalid IDs/versions/timestamps/ranges/incompatible refs/unknown forward-compat allowed per SPIKE-04 gate 1) — validateManifest/validateSignature/validateScheduleEvent/validateMapPoi/isCompatible (schemaRange + semver), validatePackage/validateEmergencyFloor/isStableId; budgets enforced, 24h event cap, dayKey YYYY-MM-DD, x/y 0..1, lat/lng null GPS optional I-7
- src/data/user/types.ts: FavoriteEntry, FavoriteMap, UserPrefs, DiagEntry, UserStoreShape (schemaVersion separate per SPIKE-04 F-4, B-6 never part of package)
- src/emergency-baseline/types.ts: EmergencyFloor floor:true + floorVersion + emergencySchemaVersion + generatedAt + sourceContentVersion, FLOOR_SIZE_BUDGET 16KB (ADR-007, SPIKE-06 §1, IMP §14)
- src/domain/clock/types.ts + logic.ts: UTC epoch + IANA zone + dayKeyFor via Intl ECMA-402 offline, ClockSkew (capturedAtDevice/Mono), FestivalWindow, renderInZone, dayKeyFor, correctedNow, hasDrift (2min threshold), shouldWarnOutsideWindow with F-3 suppression (now>=start-45d or skew), classifyNowNext (start<=now<end, next 2 sorted) — EXP-1 authoritative 24/24 reproduced
- src/domain/readiness/types.ts: ReadinessInput C1..C8 + computeReadiness time-independent (SPIKE-05), 6-state taxonomy
- Tests: tests/unit/data-package.test.ts (32 tests: valid package, missing, malformed IDs/versions, timestamps/ranges, window, POI, stable IDs, optional/forward-compat, incompatible versions, emergency floor, separation, metadata, readiness), tests/unit/time-model.test.ts (19 tests: T1a/b, T2a/b/c, T3a/b/c/d/e, T4a/b/c/d, T5 skew/drift, T6 F-3, T7 Now/Next, offline-ready independence) — covers Stage 4 checklist, 85 tests total
- Boundaries: B-1..B-7 intact, no IDB/Cache/SW/sync/dataset activation, no runtime deps, separation authoritative vs user vs baseline enforced
This commit is contained in:
Lumen Stage1 2026-08-30 23:42:12 -05:00
commit 8e71537d57
10 changed files with 1249 additions and 8 deletions

View file

@ -0,0 +1,370 @@
/**
* Stage 4 — data contracts tests.
* Covers: valid package, missing/malformed, versions, timestamps, ranges,
* stable IDs, optional sections, forward compat, incompatible, emergency, separation, metadata, readiness inputs.
* Trace: SPIKE-04, IMPLEMENTATION-CONTRACT.md §15, §20, §24, §40
*/
import { describe, it, expect } from "vitest";
import {
validateManifest,
validateSignature,
validateScheduleEvent,
validateMapPoi,
validatePackage,
isCompatible,
validateEdition,
validatePackageVersion,
validateEmergencyFloor,
} from "../../src/data/festival-package/validation.js";
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
import { isStableId } from "../../src/data/festival-package/validation.js";
function validManifest(): FestivalManifest {
return {
format: "lumen.package/1",
edition: "lumen-2026",
packageVersion: 7,
schemaVersion: 1,
generatedAt: "2026-08-28T14:02:11Z",
festival: {
name: "Lumen Festival 2026",
timezone: "America/Chicago",
startUtc: Date.UTC(2026, 8, 10),
endUtc: Date.UTC(2026, 8, 13),
},
appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 41233, required: true },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 412201, required: true },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 38122, required: true },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 96710, required: true },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 7111, required: true },
},
counts: { events: 312, pois: 87, assets: 14 },
limits: { totalBytes: 31240012 },
};
}
function validSignature() {
return {
algorithm: "ed25519" as const,
over: "sha256(manifest.json exact bytes)",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "sha256:abc",
signature: "base64==",
};
}
describe("Festival Data Package — valid package", () => {
it("valid manifest passes", () => {
expect(validateManifest(validManifest()).ok).toBe(true);
});
it("valid signature passes", () => {
expect(validateSignature(validSignature()).ok).toBe(true);
});
it("valid package (manifest+signature+emergency) passes", () => {
expect(
validatePackage({
manifest: validManifest(),
signature: validSignature(),
emergency: {
section: "emergency",
emergencySchemaVersion: 1,
contentVersion: 3,
updatedAt: "2026-08-27T09:00:00Z",
services: {
emergencyNumber: "911",
security: { phone: "+1-555" },
firstAid: { location: "A" },
},
locations: { musterPoints: [], exits: [], aeds: [] },
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
procedures: [],
},
}).ok,
).toBe(true);
});
});
describe("missing required data", () => {
it("missing sections fails", () => {
const m = { ...validManifest(), sections: undefined as unknown as Record<string, unknown> };
expect(validateManifest(m).ok).toBe(false);
});
it("missing festival timezone fails", () => {
const m = validManifest();
const bad = { ...m, festival: { ...m.festival, timezone: "" } };
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
it("missing signature field fails", () => {
const sig = { ...validSignature(), signature: "" };
expect(validateSignature(sig).ok).toBe(false);
});
});
describe("malformed IDs", () => {
it("event id with spaces fails", () => {
expect(
validateScheduleEvent({
id: "bad id",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("valid event id passes", () => {
expect(
validateScheduleEvent({
id: "evt-0113",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(true);
});
});
describe("malformed versions", () => {
it("packageVersion 0 fails, 1 passes", () => {
expect(validatePackageVersion(0).ok).toBe(false);
expect(validatePackageVersion(1).ok).toBe(true);
});
it("edition empty fails", () => {
expect(validateEdition("").ok).toBe(false);
});
it("schemaVersion float fails", () => {
expect(
validatePackage({
manifest: { ...validManifest(), schemaVersion: 1.5 },
signature: validSignature(),
emergency: { section: "emergency", emergencySchemaVersion: 1 },
}).ok,
).toBe(false);
});
});
describe("invalid timestamps / ranges", () => {
it("end before start fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 2000,
endUtc: 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("zero-length fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("festival window end before start fails", () => {
const bad = {
...validManifest(),
festival: { ...validManifest().festival, startUtc: 2000, endUtc: 1000 },
};
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
it("event longer than 24h fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 0,
endUtc: 25 * 3600 * 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("dayKey malformed fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "11-09-2026",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
});
describe("map POI validation", () => {
it("x/y 0..1 passes, out of range fails", () => {
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 0.5, y: 0.5 }).ok).toBe(true);
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 1.5, y: 0.5 }).ok).toBe(false);
});
it("lat/lng null allowed (GPS optional per I-7)", () => {
expect(
validateMapPoi({ id: "p", name: "n", category: "stage", x: 0, y: 0, lat: null, lng: null })
.ok,
).toBe(true);
});
});
describe("stable ID relationships (favorites survive updates)", () => {
it("stable id check", () => {
const prev = new Set(["evt-0113", "evt-0114"]);
expect(isStableId("evt-0113", prev)).toBe(true);
expect(isStableId("evt-9999", prev)).toBe(false);
});
});
describe("optional sections + forward compat", () => {
it("optional section may be missing without failing manifest (required flag)", () => {
// In Stage 4 we require all 5 for now; but forward compat means unknown sections allowed
const m = validManifest() as unknown as Record<string, unknown>;
// Add unknown optional section — should not break manifest validation (allowed forward compat)
(m.sections as Record<string, unknown>).announcements = {
file: "announcements.json",
sha256: "a".repeat(64),
bytes: 100,
required: false,
};
expect(validateManifest(m).ok).toBe(true);
});
it("unknown fields on event are allowed forward-compat", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
// unknown future field
futureField: "ok",
}).ok,
).toBe(true);
});
});
describe("incompatible package versions", () => {
it("schemaVersion outside shell range is incompatible", () => {
const m = validManifest();
expect(isCompatible({ ...m, schemaVersion: 99 }, [1, 2], "1.0.0")).toBe(false);
});
it("app version older than min is incompatible", () => {
const m = validManifest();
expect(isCompatible(m, [1], "0.9.0")).toBe(false);
});
it("compatible within range passes", () => {
const m = validManifest();
expect(isCompatible(m, [1], "1.0.0")).toBe(true);
});
});
describe("emergency data representation (SPIKE-06)", () => {
it("floor shape passes", () => {
expect(
validateEmergencyFloor({
floor: true,
floorVersion: "1.0.0+2026.08.20",
emergencySchemaVersion: 1,
generatedAt: "2026-08-20T12:00:00Z",
sourceContentVersion: 5,
services: {},
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
musterPoints: [],
exits: [],
aedSummary: "x",
procedures: [],
}).ok,
).toBe(true);
});
it("floor missing version fails", () => {
expect(validateEmergencyFloor({ floor: true }).ok).toBe(false);
});
});
describe("separation of user data from festival data (IMP contract §CRITICAL)", () => {
it("festival package does not contain favorites", () => {
const pkg = validManifest() as unknown as Record<string, unknown>;
expect(pkg.favorites).toBeUndefined();
expect(pkg.favorites).not.toBeDefined();
});
it("user store is separate type", async () => {
const mod = await import("../../src/data/user/types.js");
expect(mod).toBeDefined();
});
});
describe("dataset metadata", () => {
it("counts and limits present", () => {
const m = validManifest();
expect(m.counts.events).toBe(312);
expect(m.limits.totalBytes).toBe(31240012);
});
it("totalBytes >50MB hard ceiling fails", () => {
const bad = { ...validManifest(), limits: { totalBytes: 60 * 1024 * 1024 } };
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
});
describe("readiness inputs (time-independent per SPIKE-05)", () => {
it("readiness is data-driven, not clock", async () => {
const { computeReadiness } = await import("../../src/domain/readiness/types.js");
expect(
computeReadiness({
shellValid: true,
datasetPresent: true,
authenticityOk: true,
integrityOk: true,
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
}),
).toBe("READY");
expect(
computeReadiness({
shellValid: true,
datasetPresent: false,
authenticityOk: false,
integrityOk: false,
schemaCompatible: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
emergencyFloorOk: true,
}),
).toBe("NOT_READY");
});
});