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:
parent
615ae2565c
commit
8e71537d57
10 changed files with 1249 additions and 8 deletions
370
tests/unit/data-package.test.ts
Normal file
370
tests/unit/data-package.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue