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
249
src/data/festival-package/types.ts
Normal file
249
src/data/festival-package/types.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* Festival Data Package v1 — typed contracts.
|
||||||
|
* Authoritative festival data (organizer-supplied, signed, distributed).
|
||||||
|
* Not user data, not emergency baseline (separate concerns per IMP contract §CRITICAL).
|
||||||
|
* Trace: ADR-005, SPIKE-04 §2, ARCHITECTURE-DESIGN.md §10, §15
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PACKAGE_FORMAT = "lumen.package/1" as const;
|
||||||
|
export type PackageFormat = typeof PACKAGE_FORMAT;
|
||||||
|
|
||||||
|
// ——— Manifest ———
|
||||||
|
|
||||||
|
export interface FestivalMetadata {
|
||||||
|
readonly name: string;
|
||||||
|
readonly timezone: string; // IANA, e.g. "America/Chicago"
|
||||||
|
readonly startUtc: number; // epoch ms
|
||||||
|
readonly endUtc: number; // epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCompatibility {
|
||||||
|
readonly minAppVersion: string; // semver, e.g. "1.0.0"
|
||||||
|
readonly maxAppVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionEntry {
|
||||||
|
readonly file: string; // e.g. "emergency.json"
|
||||||
|
readonly sha256: string; // hex 64
|
||||||
|
readonly bytes: number;
|
||||||
|
/** default true — readiness gates on required only (SPIKE-04 F-1, SPIKE-05) */
|
||||||
|
readonly required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionId = "emergency" | "schedule" | "map" | "info" | "assets";
|
||||||
|
|
||||||
|
export type SectionMap = Record<SectionId, SectionEntry>;
|
||||||
|
|
||||||
|
export interface PackageCounts {
|
||||||
|
readonly events: number;
|
||||||
|
readonly pois: number;
|
||||||
|
readonly assets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageLimits {
|
||||||
|
readonly totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FestivalManifest {
|
||||||
|
readonly format: PackageFormat;
|
||||||
|
readonly edition: string; // e.g. "lumen-2026"
|
||||||
|
readonly packageVersion: number; // monotonic int per edition
|
||||||
|
readonly schemaVersion: number; // int
|
||||||
|
readonly generatedAt: string; // ISO8601 UTC
|
||||||
|
readonly festival: FestivalMetadata;
|
||||||
|
readonly appCompatibility: AppCompatibility;
|
||||||
|
readonly sections: SectionMap;
|
||||||
|
readonly counts: PackageCounts;
|
||||||
|
readonly limits: PackageLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Signature ———
|
||||||
|
|
||||||
|
export interface PackageSignature {
|
||||||
|
readonly algorithm: "ed25519";
|
||||||
|
readonly over: string; // "sha256(manifest.json exact bytes)"
|
||||||
|
readonly manifestSha256: string; // hex 64
|
||||||
|
readonly publicKeyFingerprint: string; // "sha256:…" hex
|
||||||
|
readonly signature: string; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Assets ———
|
||||||
|
|
||||||
|
export type AssetKind = "map-base" | "poi-icon" | "photo" | "icon";
|
||||||
|
export type AssetRole = string; // e.g. "overview" | "detail" | "map-base/overview-night"
|
||||||
|
|
||||||
|
export interface AssetEntry {
|
||||||
|
readonly id: string; // e.g. "map-base-overview"
|
||||||
|
readonly file: string; // "assets/map-base-overview.webp"
|
||||||
|
readonly kind: AssetKind;
|
||||||
|
readonly role: AssetRole;
|
||||||
|
readonly sha256: string;
|
||||||
|
readonly bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetsInventory {
|
||||||
|
readonly assets: readonly AssetEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Latest pointer ———
|
||||||
|
|
||||||
|
export interface LatestPointer {
|
||||||
|
readonly edition: string;
|
||||||
|
readonly packageVersion: number;
|
||||||
|
readonly manifestUrl: string;
|
||||||
|
readonly generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Section: Emergency ———
|
||||||
|
|
||||||
|
export interface EmergencySection {
|
||||||
|
readonly section: "emergency";
|
||||||
|
readonly emergencySchemaVersion: number; // independent sub-versioning (SPIKE-04 F-3)
|
||||||
|
readonly contentVersion: number; // monotonic
|
||||||
|
readonly updatedAt: string; // ISO8601
|
||||||
|
readonly services: {
|
||||||
|
readonly emergencyNumber: string; // data-driven, e.g. "911"
|
||||||
|
readonly security: { readonly phone: string; readonly location?: string };
|
||||||
|
readonly firstAid: { readonly location: string; readonly hours?: string };
|
||||||
|
};
|
||||||
|
readonly locations: {
|
||||||
|
readonly musterPoints: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly poi: string;
|
||||||
|
}[];
|
||||||
|
readonly exits: readonly { readonly id: string; readonly name: string; readonly poi: string }[];
|
||||||
|
readonly aeds: readonly { readonly poi: string }[];
|
||||||
|
};
|
||||||
|
readonly address: {
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
readonly coordinates: { readonly lat: number; readonly lon: number };
|
||||||
|
};
|
||||||
|
readonly procedures: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly steps: readonly string[];
|
||||||
|
}[];
|
||||||
|
readonly notices?: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly severity: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: readonly unknown[];
|
||||||
|
readonly expiresAtUtc: number | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Section: Schedule ———
|
||||||
|
|
||||||
|
export type EventStatus = "scheduled" | "moved" | "cancelled";
|
||||||
|
|
||||||
|
export interface ScheduleStage {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleArtist {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleEvent {
|
||||||
|
readonly id: string; // stable, contractual
|
||||||
|
readonly title: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly stageId: string;
|
||||||
|
readonly artistIds: readonly string[];
|
||||||
|
readonly startUtc: number;
|
||||||
|
readonly endUtc: number;
|
||||||
|
readonly dayKey: string; // precomputed festival-zone date YYYY-MM-DD
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly status: EventStatus;
|
||||||
|
readonly originalStartUtc?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleSection {
|
||||||
|
readonly section: "schedule";
|
||||||
|
readonly stages: readonly ScheduleStage[];
|
||||||
|
readonly artists: readonly ScheduleArtist[];
|
||||||
|
readonly events: readonly ScheduleEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Section: Map ———
|
||||||
|
|
||||||
|
export interface MapLevel {
|
||||||
|
readonly id: string; // "overview" | "detail"
|
||||||
|
readonly assetId: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly nightAssetId?: string; // optional F-4 dark variant
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoiCategory =
|
||||||
|
| "stage"
|
||||||
|
| "restroom"
|
||||||
|
| "water"
|
||||||
|
| "food"
|
||||||
|
| "first-aid"
|
||||||
|
| "aed"
|
||||||
|
| "security"
|
||||||
|
| "entrance"
|
||||||
|
| "exit"
|
||||||
|
| "parking"
|
||||||
|
| "camping"
|
||||||
|
| "vip"
|
||||||
|
| "muster"
|
||||||
|
| "info"
|
||||||
|
| "vendor"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface MapPoi {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly category: PoiCategory;
|
||||||
|
readonly x: number; // normalized 0..1
|
||||||
|
readonly y: number; // normalized 0..1
|
||||||
|
readonly description?: string;
|
||||||
|
readonly lat: number | null;
|
||||||
|
readonly lng: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapSection {
|
||||||
|
readonly section: "map";
|
||||||
|
readonly base: { readonly levels: readonly MapLevel[] };
|
||||||
|
readonly pois: readonly MapPoi[];
|
||||||
|
readonly categories: readonly PoiCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Section: Info ———
|
||||||
|
|
||||||
|
export type InfoBlockKind =
|
||||||
|
"paragraph" | "list" | "link" | "emphasis" | "contact" | "address" | "hours";
|
||||||
|
|
||||||
|
export interface InfoBlock {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly kind: string; // data-driven category: About, Rules, FAQ...
|
||||||
|
readonly body: readonly {
|
||||||
|
readonly kind: InfoBlockKind;
|
||||||
|
readonly text?: string;
|
||||||
|
readonly items?: readonly string[];
|
||||||
|
readonly href?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoSection {
|
||||||
|
readonly section: "info";
|
||||||
|
readonly blocks: readonly InfoBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Package as distributed (immutable directory) ———
|
||||||
|
|
||||||
|
export interface FestivalPackageV1 {
|
||||||
|
readonly manifest: FestivalManifest;
|
||||||
|
readonly signature: PackageSignature;
|
||||||
|
readonly emergency: EmergencySection;
|
||||||
|
readonly schedule: ScheduleSection;
|
||||||
|
readonly map: MapSection;
|
||||||
|
readonly info: InfoSection;
|
||||||
|
readonly assets: AssetsInventory;
|
||||||
|
}
|
||||||
245
src/data/festival-package/validation.ts
Normal file
245
src/data/festival-package/validation.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
/**
|
||||||
|
* Festival Data Package validation — pure, no I/O, no crypto runtime (Stage 4).
|
||||||
|
* Rejects malformed/missing/invalid; allows unknown optional fields (forward compat per SPIKE-04 gate 1).
|
||||||
|
* Trace: ADR-005, SPIKE-04 §3, IMPLEMENTATION-CONTRACT.md §11, §15
|
||||||
|
*/
|
||||||
|
import type { FestivalManifest, SectionId } from "./types.js";
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
{ readonly ok: true } | { readonly ok: false; readonly reason: string };
|
||||||
|
|
||||||
|
const HEX64 = /^[0-9a-f]{64}$/i;
|
||||||
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].+)?$/;
|
||||||
|
const DAYKEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const PRINTABLE_ID = /^[a-z0-9][a-z0-9-_]*$/i;
|
||||||
|
|
||||||
|
function isFiniteNumber(n: unknown): n is number {
|
||||||
|
return typeof n === "number" && Number.isFinite(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(s: unknown): s is string {
|
||||||
|
return typeof s === "string" && s.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEdition(v: unknown): ValidationResult {
|
||||||
|
if (!isNonEmptyString(v)) return { ok: false, reason: "edition must be non-empty string" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePackageVersion(v: unknown): ValidationResult {
|
||||||
|
if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)
|
||||||
|
return { ok: false, reason: "packageVersion must be integer >=1" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSchemaVersion(v: unknown): ValidationResult {
|
||||||
|
if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)
|
||||||
|
return { ok: false, reason: "schemaVersion must be integer >=1" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateIso8601(s: unknown): ValidationResult {
|
||||||
|
if (typeof s !== "string" || Number.isNaN(Date.parse(s)))
|
||||||
|
return { ok: false, reason: "must be ISO8601 UTC" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSha256(s: unknown): ValidationResult {
|
||||||
|
if (typeof s !== "string" || !HEX64.test(s))
|
||||||
|
return { ok: false, reason: "sha256 must be 64 hex" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBytes(n: unknown): ValidationResult {
|
||||||
|
if (!isFiniteNumber(n) || !Number.isInteger(n) || n < 0 || n > 6 * 1024 * 1024)
|
||||||
|
return { ok: false, reason: "bytes must be 0..6MB (per-file cap)" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAppVersion(s: unknown): ValidationResult {
|
||||||
|
if (typeof s !== "string" || !SEMVER.test(s))
|
||||||
|
return { ok: false, reason: "appVersion must be semver" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFestivalWindow(startUtc: unknown, endUtc: unknown): ValidationResult {
|
||||||
|
if (!isFiniteNumber(startUtc) || !isFiniteNumber(endUtc))
|
||||||
|
return { ok: false, reason: "festival start/end must be epoch ms" };
|
||||||
|
if (endUtc <= startUtc) return { ok: false, reason: "festival end must be after start" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateManifest(m: unknown): ValidationResult {
|
||||||
|
if (typeof m !== "object" || m === null) return { ok: false, reason: "manifest must be object" };
|
||||||
|
const o = m as Record<string, unknown>;
|
||||||
|
if (o.format !== "lumen.package/1")
|
||||||
|
return { ok: false, reason: "format must be lumen.package/1" };
|
||||||
|
|
||||||
|
const edition = validateEdition(o.edition);
|
||||||
|
if (!edition.ok) return edition;
|
||||||
|
const pv = validatePackageVersion(o.packageVersion);
|
||||||
|
if (!pv.ok) return pv;
|
||||||
|
const sv = validateSchemaVersion(o.schemaVersion);
|
||||||
|
if (!sv.ok) return sv;
|
||||||
|
const ga = validateIso8601(o.generatedAt);
|
||||||
|
if (!ga.ok) return { ok: false, reason: `generatedAt: ${ga.reason}` };
|
||||||
|
|
||||||
|
const fest = o.festival as Record<string, unknown> | undefined;
|
||||||
|
if (!fest || typeof fest !== "object") return { ok: false, reason: "festival missing" };
|
||||||
|
if (!isNonEmptyString(fest.name)) return { ok: false, reason: "festival.name required" };
|
||||||
|
if (!isNonEmptyString(fest.timezone)) return { ok: false, reason: "festival.timezone required" };
|
||||||
|
const win = validateFestivalWindow(fest.startUtc, fest.endUtc);
|
||||||
|
if (!win.ok) return win;
|
||||||
|
|
||||||
|
const ac = o.appCompatibility as Record<string, unknown> | undefined;
|
||||||
|
if (!ac || typeof ac !== "object") return { ok: false, reason: "appCompatibility missing" };
|
||||||
|
const minV = validateAppVersion(ac.minAppVersion);
|
||||||
|
if (!minV.ok) return minV;
|
||||||
|
if (ac.maxAppVersion !== null) {
|
||||||
|
const maxV = validateAppVersion(ac.maxAppVersion);
|
||||||
|
if (!maxV.ok) return { ok: false, reason: `maxAppVersion: ${maxV.reason}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = o.sections as Record<string, unknown> | undefined;
|
||||||
|
if (!sections) return { ok: false, reason: "sections missing" };
|
||||||
|
const requiredIds: SectionId[] = ["emergency", "schedule", "map", "info", "assets"];
|
||||||
|
for (const id of requiredIds) {
|
||||||
|
const e = sections[id] as Record<string, unknown> | undefined;
|
||||||
|
if (!e) return { ok: false, reason: `section ${id} missing` };
|
||||||
|
if (!isNonEmptyString(e.file)) return { ok: false, reason: `${id}.file required` };
|
||||||
|
const sha = validateSha256(e.sha256);
|
||||||
|
if (!sha.ok) return { ok: false, reason: `${id}.sha256: ${sha.reason}` };
|
||||||
|
const b = validateBytes(e.bytes);
|
||||||
|
if (!b.ok) return { ok: false, reason: `${id}.bytes: ${b.reason}` };
|
||||||
|
if (e.required !== undefined && typeof e.required !== "boolean")
|
||||||
|
return { ok: false, reason: `${id}.required must be boolean` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// counts/limits presence (values validated elsewhere by budgets)
|
||||||
|
const counts = o.counts as Record<string, unknown> | undefined;
|
||||||
|
if (!counts || !isFiniteNumber(counts.events) || !isFiniteNumber(counts.pois))
|
||||||
|
return { ok: false, reason: "counts missing or invalid" };
|
||||||
|
const limits = o.limits as Record<string, unknown> | undefined;
|
||||||
|
if (!limits || !isFiniteNumber(limits.totalBytes))
|
||||||
|
return { ok: false, reason: "limits.totalBytes missing" };
|
||||||
|
if (limits.totalBytes > 50 * 1024 * 1024)
|
||||||
|
return { ok: false, reason: "totalBytes exceeds 50MB hard ceiling" };
|
||||||
|
|
||||||
|
// monotonic and budgets are cross-package checks, not single-manifest — allow here
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSignature(s: unknown): ValidationResult {
|
||||||
|
if (typeof s !== "object" || s === null) return { ok: false, reason: "signature must be object" };
|
||||||
|
const o = s as Record<string, unknown>;
|
||||||
|
if (o.algorithm !== "ed25519") return { ok: false, reason: "algorithm must be ed25519" };
|
||||||
|
if (!isNonEmptyString(o.over)) return { ok: false, reason: "over required" };
|
||||||
|
const sha = validateSha256(o.manifestSha256);
|
||||||
|
if (!sha.ok) return { ok: false, reason: `manifestSha256: ${sha.reason}` };
|
||||||
|
if (!isNonEmptyString(o.publicKeyFingerprint))
|
||||||
|
return { ok: false, reason: "publicKeyFingerprint required" };
|
||||||
|
if (!isNonEmptyString(o.signature)) return { ok: false, reason: "signature required" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEventId(id: unknown): ValidationResult {
|
||||||
|
if (!isNonEmptyString(id) || !PRINTABLE_ID.test(id) || id.length > 64)
|
||||||
|
return { ok: false, reason: "event id must match printable id, max 64" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateScheduleEvent(e: unknown): ValidationResult {
|
||||||
|
if (typeof e !== "object" || e === null) return { ok: false, reason: "event must be object" };
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
const id = validateEventId(o.id);
|
||||||
|
if (!id.ok) return id;
|
||||||
|
if (!isNonEmptyString(o.title)) return { ok: false, reason: "title required" };
|
||||||
|
if (!isNonEmptyString(o.stageId)) return { ok: false, reason: "stageId required" };
|
||||||
|
if (!Array.isArray(o.artistIds)) return { ok: false, reason: "artistIds must be array" };
|
||||||
|
if (!isFiniteNumber(o.startUtc) || !isFiniteNumber(o.endUtc))
|
||||||
|
return { ok: false, reason: "start/end must be epoch ms" };
|
||||||
|
if (o.endUtc <= o.startUtc) return { ok: false, reason: "end must be after start" };
|
||||||
|
if (o.endUtc - o.startUtc > 24 * 3600 * 1000)
|
||||||
|
return { ok: false, reason: "event longer than 24h" };
|
||||||
|
if (typeof o.dayKey !== "string" || !DAYKEY.test(o.dayKey))
|
||||||
|
return { ok: false, reason: "dayKey must be YYYY-MM-DD" };
|
||||||
|
const status = o.status;
|
||||||
|
if (status !== "scheduled" && status !== "moved" && status !== "cancelled")
|
||||||
|
return { ok: false, reason: "status must be scheduled|moved|cancelled" };
|
||||||
|
// forward-compat: allow unknown optional fields, no fail
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMapPoi(p: unknown): ValidationResult {
|
||||||
|
if (typeof p !== "object" || p === null) return { ok: false, reason: "poi must be object" };
|
||||||
|
const o = p as Record<string, unknown>;
|
||||||
|
if (!isNonEmptyString(o.id)) return { ok: false, reason: "poi id required" };
|
||||||
|
if (!isFiniteNumber(o.x) || o.x < 0 || o.x > 1) return { ok: false, reason: "x must be 0..1" };
|
||||||
|
if (!isFiniteNumber(o.y) || o.y < 0 || o.y > 1) return { ok: false, reason: "y must be 0..1" };
|
||||||
|
// lat/lng may be null (GPS optional)
|
||||||
|
if (o.lat !== null && o.lat !== undefined && !isFiniteNumber(o.lat))
|
||||||
|
return { ok: false, reason: "lat must be number or null" };
|
||||||
|
if (o.lng !== null && o.lng !== undefined && !isFiniteNumber(o.lng))
|
||||||
|
return { ok: false, reason: "lng must be number or null" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompatible(
|
||||||
|
manifest: FestivalManifest,
|
||||||
|
shellSupportedRange: readonly number[],
|
||||||
|
currentAppVersion: string,
|
||||||
|
): boolean {
|
||||||
|
if (!shellSupportedRange.includes(manifest.schemaVersion)) return false;
|
||||||
|
// simple semver compare for min/max — compare major.minor.patch numerically
|
||||||
|
const cmp = (a: string, b: string): number => {
|
||||||
|
const pa = a.split(".").map((n) => Number.parseInt(n, 10));
|
||||||
|
const pb = b.split(".").map((n) => Number.parseInt(n, 10));
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const av = pa[i] ?? 0;
|
||||||
|
const bv = pb[i] ?? 0;
|
||||||
|
if (av !== bv) return av - bv;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if (cmp(currentAppVersion, manifest.appCompatibility.minAppVersion) < 0) return false;
|
||||||
|
if (
|
||||||
|
manifest.appCompatibility.maxAppVersion !== null &&
|
||||||
|
cmp(currentAppVersion, manifest.appCompatibility.maxAppVersion) > 0
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePackage(pkg: unknown): ValidationResult {
|
||||||
|
if (typeof pkg !== "object" || pkg === null)
|
||||||
|
return { ok: false, reason: "package must be object" };
|
||||||
|
const o = pkg as Record<string, unknown>;
|
||||||
|
const mv = validateManifest(o.manifest);
|
||||||
|
if (!mv.ok) return { ok: false, reason: `manifest: ${mv.reason}` };
|
||||||
|
const sig = validateSignature(o.signature);
|
||||||
|
if (!sig.ok) return { ok: false, reason: `signature: ${sig.reason}` };
|
||||||
|
// Sections presence is already via manifest sections; validate at least emergency shape
|
||||||
|
const emergency = o.emergency as Record<string, unknown> | undefined;
|
||||||
|
if (emergency?.section !== "emergency")
|
||||||
|
return { ok: false, reason: "emergency section missing or wrong" };
|
||||||
|
if (!isFiniteNumber(emergency.emergencySchemaVersion))
|
||||||
|
return { ok: false, reason: "emergencySchemaVersion required" };
|
||||||
|
// schedule/map/info/assets validated via manifest presence; deep validation is stage pipeline
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure helper for stable-ID check (favorites survive updates) — not a full diff, just contract
|
||||||
|
export function isStableId(id: string, prevIds: ReadonlySet<string>): boolean {
|
||||||
|
return prevIds.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmergencyFloor(floor: unknown): ValidationResult {
|
||||||
|
if (typeof floor !== "object" || floor === null)
|
||||||
|
return { ok: false, reason: "floor must be object" };
|
||||||
|
const o = floor as Record<string, unknown>;
|
||||||
|
if (o.floor !== true) return { ok: false, reason: "floor must be true" };
|
||||||
|
if (!isNonEmptyString(o.floorVersion)) return { ok: false, reason: "floorVersion required" };
|
||||||
|
if (!isFiniteNumber(o.emergencySchemaVersion))
|
||||||
|
return { ok: false, reason: "emergencySchemaVersion required" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
35
src/data/user/types.ts
Normal file
35
src/data/user/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Local user data — never part of Festival Data Package, never overwritten by dataset updates.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §24, DISCOVERY.md:545 §15.8, SPIKE-04 F-4
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FavoriteEntry {
|
||||||
|
readonly eventId: string; // stable event id
|
||||||
|
readonly addedAt: number; // epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FavoriteMap = ReadonlyMap<string, FavoriteEntry>;
|
||||||
|
|
||||||
|
export interface UserPrefs {
|
||||||
|
readonly theme?: "light" | "dark" | "system";
|
||||||
|
readonly festivalTimezone: string; // IANA zone user has chosen for display; default festival zone
|
||||||
|
readonly useDeviceTimezone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagEntry {
|
||||||
|
readonly at: number;
|
||||||
|
readonly kind: string;
|
||||||
|
readonly detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStoreShape {
|
||||||
|
readonly favorites: readonly FavoriteEntry[]; // persisted as array, keyed by eventId at runtime
|
||||||
|
readonly prefs: UserPrefs;
|
||||||
|
readonly diag: readonly DiagEntry[];
|
||||||
|
/** separate schema version for user data (SPIKE-04 F-4) */
|
||||||
|
readonly schemaVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFavoriteMap(map: ReadonlyMap<string, unknown>, eventId: string): boolean {
|
||||||
|
return map.has(eventId);
|
||||||
|
}
|
||||||
89
src/domain/clock/logic.ts
Normal file
89
src/domain/clock/logic.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Clock logic — pure, no browser globals except where injected.
|
||||||
|
* EXP-1 authoritative behavior is reproduced here.
|
||||||
|
* Trace: SPIKE-08 §3, experiments/exp1-time-model.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClockOptions, ClockSkew, FestivalWindow } from "./types.js";
|
||||||
|
|
||||||
|
export const DEFAULT_WINDOW_DAYS = 45;
|
||||||
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */
|
||||||
|
export function renderInZone(epochMs: number, timeZone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Precomputed dayKey at publish time: calendar date in festival zone. */
|
||||||
|
export function dayKeyFor(epochMs: number, timeZone: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(new Date(epochMs));
|
||||||
|
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||||
|
parts.find((p) => p.type === type)?.value ?? "";
|
||||||
|
return `${get("year")}-${get("month")}-${get("day")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrected now: device + skew if present. */
|
||||||
|
export function correctedNow(deviceNow: number, skew: ClockSkew | null): number {
|
||||||
|
return skew ? deviceNow + skew.skewMs : deviceNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monotonic drift detection: |observed - (base + monoElapsed)| > threshold */
|
||||||
|
export function hasDrift(
|
||||||
|
baseDevice: number,
|
||||||
|
monoElapsed: number,
|
||||||
|
observedDevice: number,
|
||||||
|
thresholdMs = 120_000,
|
||||||
|
): boolean {
|
||||||
|
const expected = baseDevice + monoElapsed;
|
||||||
|
return Math.abs(observedDevice - expected) > thresholdMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanity window check with F-3 suppression (SPIKE-08 F-3). */
|
||||||
|
export function shouldWarnOutsideWindow(
|
||||||
|
nowCorrected: number,
|
||||||
|
win: FestivalWindow,
|
||||||
|
skew: ClockSkew | null,
|
||||||
|
opts: ClockOptions = { windowDays: DEFAULT_WINDOW_DAYS },
|
||||||
|
): boolean {
|
||||||
|
const windowMs = opts.windowDays * MS_PER_DAY;
|
||||||
|
const outside = nowCorrected < win.startUtc - windowMs || nowCorrected > win.endUtc + windowMs;
|
||||||
|
if (!outside) return false;
|
||||||
|
// F-3: suppress until near festival or evidence of skew
|
||||||
|
if (nowCorrected >= win.startUtc - windowMs) return true;
|
||||||
|
if (skew !== null) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now/Next classification — epoch-ms only, no Intl. */
|
||||||
|
export interface SchedEventRef {
|
||||||
|
readonly id: string;
|
||||||
|
readonly startUtc: number;
|
||||||
|
readonly endUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyNowNext<T extends SchedEventRef>(
|
||||||
|
events: readonly T[],
|
||||||
|
now: number,
|
||||||
|
): { readonly now: readonly T[]; readonly next: readonly T[] } {
|
||||||
|
const nowEvents = events.filter((e) => e.startUtc <= now && now < e.endUtc);
|
||||||
|
const next = events
|
||||||
|
.filter((e) => e.startUtc > now)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.startUtc - b.startUtc)
|
||||||
|
.slice(0, 2);
|
||||||
|
return { now: nowEvents, next };
|
||||||
|
}
|
||||||
26
src/domain/clock/types.ts
Normal file
26
src/domain/clock/types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* Time model — ClockService and related types.
|
||||||
|
* Trace: ADR-009, SPIKE-08, IMPLEMENTATION-CONTRACT.md §21
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ClockSkew {
|
||||||
|
readonly skewMs: number; // serverNow - deviceNow
|
||||||
|
readonly capturedAtDevice: number; // epoch ms at capture
|
||||||
|
readonly capturedAtMono: number; // performance.now at capture
|
||||||
|
readonly source: string; // e.g. "Date header"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FestivalWindow {
|
||||||
|
readonly startUtc: number;
|
||||||
|
readonly endUtc: number;
|
||||||
|
readonly timezone: string; // IANA
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClockOptions {
|
||||||
|
readonly windowDays: number; // 45 per ARCH §14.3
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClockState {
|
||||||
|
readonly skew: ClockSkew | null;
|
||||||
|
readonly window: FestivalWindow;
|
||||||
|
}
|
||||||
40
src/domain/readiness/types.ts
Normal file
40
src/domain/readiness/types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Offline Ready — inputs for predicate C1…C8.
|
||||||
|
* Pure types, no I/O.
|
||||||
|
* Trace: SPIKE-05, IMPLEMENTATION-CONTRACT.md §20
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReadinessInput {
|
||||||
|
readonly shellValid: boolean; // C1
|
||||||
|
readonly datasetPresent: boolean; // C2
|
||||||
|
readonly authenticityOk: boolean; // C3
|
||||||
|
readonly integrityOk: boolean; // C4
|
||||||
|
readonly schemaCompatible: boolean; // C5
|
||||||
|
readonly requiredSectionsOk: boolean; // C6
|
||||||
|
readonly requiredAssetsOk: boolean; // C7
|
||||||
|
readonly emergencyFloorOk: boolean; // C8
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadinessState =
|
||||||
|
"READY" | "PARTIAL" | "NOT_READY" | "RECOVERY" | "BASELINE_ONLY" | "FAILED";
|
||||||
|
|
||||||
|
export function computeReadiness(i: ReadinessInput): ReadinessState {
|
||||||
|
if (!i.shellValid) return "FAILED"; // shell itself invalid — not a normal state but keep
|
||||||
|
// All true => READY (time-independent per SPIKE-05 F-1)
|
||||||
|
if (
|
||||||
|
i.datasetPresent &&
|
||||||
|
i.authenticityOk &&
|
||||||
|
i.integrityOk &&
|
||||||
|
i.schemaCompatible &&
|
||||||
|
i.requiredSectionsOk &&
|
||||||
|
i.requiredAssetsOk &&
|
||||||
|
i.emergencyFloorOk
|
||||||
|
) {
|
||||||
|
return "READY";
|
||||||
|
}
|
||||||
|
// If any of C2…C7 false due to missing data but shell ok, PARTIAL/NOT_READY
|
||||||
|
// For Stage 4 we distinguish only READY vs not-READY; detailed taxonomy is Stage 9.
|
||||||
|
// Return PARTIAL as representative for missing required data.
|
||||||
|
if (!i.datasetPresent) return "NOT_READY";
|
||||||
|
return "PARTIAL";
|
||||||
|
}
|
||||||
28
src/emergency-baseline/types.ts
Normal file
28
src/emergency-baseline/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Emergency baseline (Ring-0) types — compiled into shell bytes.
|
||||||
|
* Generated from same emergency source sheet as dataset section (no drift).
|
||||||
|
* Trace: ADR-007, SPIKE-06 §1, IMPLEMENTATION-CONTRACT.md §14
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EmergencySection } from "../data/festival-package/types.js";
|
||||||
|
|
||||||
|
export interface EmergencyFloor {
|
||||||
|
readonly floor: true;
|
||||||
|
readonly floorVersion: string; // e.g. "1.0.0+2026.08.20"
|
||||||
|
readonly emergencySchemaVersion: number;
|
||||||
|
readonly generatedAt: string; // ISO8601
|
||||||
|
readonly sourceContentVersion: number;
|
||||||
|
readonly services: EmergencySection["services"];
|
||||||
|
readonly address: EmergencySection["address"];
|
||||||
|
readonly musterPoints: readonly { readonly name: string; readonly directions?: string }[];
|
||||||
|
readonly exits: readonly { readonly name: string }[];
|
||||||
|
readonly aedSummary: string;
|
||||||
|
readonly procedures: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly steps: readonly string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor must be ≤16 KB — enforced by build gate, not by types.
|
||||||
|
export const FLOOR_SIZE_BUDGET = 16 * 1024;
|
||||||
|
|
@ -17,24 +17,30 @@ function walkFiles(dir: string, exts = [".ts", ".js"]): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
|
describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
|
||||||
it("src/ contains only Stage 2 shell — no forbidden stores yet", () => {
|
it("src/ contains shell + Stage 4 data contracts — no forbidden persistence/sync yet", () => {
|
||||||
const tsFiles = walkFiles("src", [".ts"]).sort();
|
const tsFiles = walkFiles("src", [".ts"]).sort();
|
||||||
// Stage 2 adds shell (app/layout, router, 4 views) but no IDB/Cache/SW/sync per contract
|
// Stage 4 adds data contracts (festival-package, user, emergency-baseline, clock/readiness) on top of shell; no IDB/Cache storage/sync persistence
|
||||||
expect(tsFiles).toEqual(
|
expect(tsFiles).toEqual(
|
||||||
expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]),
|
expect.arrayContaining([
|
||||||
|
"src/app/layout.ts",
|
||||||
|
"src/app/main.ts",
|
||||||
|
"src/ui/router/router.ts",
|
||||||
|
"src/data/festival-package/types.ts",
|
||||||
|
"src/domain/clock/logic.ts",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
// Forbidden stores must remain empty — Stage 3 allows platform/sw/register only
|
// Forbidden persistence/sync must remain empty in Stage 4 (types/validation allowed)
|
||||||
const forbidden = tsFiles.filter(
|
const forbidden = tsFiles.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.startsWith("src/platform/idb/") ||
|
f.startsWith("src/platform/idb/") ||
|
||||||
f.startsWith("src/platform/cache/") ||
|
f.startsWith("src/platform/cache/") ||
|
||||||
f.startsWith("src/storage/") ||
|
f.startsWith("src/storage/") ||
|
||||||
f.startsWith("src/data/") ||
|
|
||||||
f.startsWith("src/sync/"),
|
f.startsWith("src/sync/"),
|
||||||
);
|
);
|
||||||
expect(forbidden, `Stage 3 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual(
|
expect(
|
||||||
[],
|
forbidden,
|
||||||
);
|
`Stage 4 must not have forbidden persistence/sync: ${forbidden.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => {
|
it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => {
|
||||||
|
|
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
153
tests/unit/time-model.test.ts
Normal file
153
tests/unit/time-model.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* Stage 4 — time model tests (EXP-1 authoritative).
|
||||||
|
* Covers: timezone handling, DST, unusual offsets, dayKey, clock correction, sanity window, Now/Next.
|
||||||
|
* Trace: SPIKE-08, experiments/exp1-time-model.mjs, IMPLEMENTATION-CONTRACT.md §21
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
renderInZone,
|
||||||
|
dayKeyFor,
|
||||||
|
correctedNow,
|
||||||
|
hasDrift,
|
||||||
|
shouldWarnOutsideWindow,
|
||||||
|
classifyNowNext,
|
||||||
|
} from "../../src/domain/clock/logic.js";
|
||||||
|
|
||||||
|
const CHI = "America/Chicago";
|
||||||
|
|
||||||
|
describe("EXP-1 authoritative — UTC + IANA rendering (T1)", () => {
|
||||||
|
it("T1a: UTC→Chicago CDT render", () => {
|
||||||
|
const t = Date.UTC(2026, 6, 15, 18, 0, 0);
|
||||||
|
expect(renderInZone(t, CHI)).toBe("07/15/2026, 13:00:00");
|
||||||
|
});
|
||||||
|
it("T1b: independent of host tz", () => {
|
||||||
|
const t = Date.UTC(2026, 6, 15, 18, 0, 0);
|
||||||
|
expect(renderInZone(t, CHI).includes("13:00:00")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dayKey precomputed (T2)", () => {
|
||||||
|
it("T2a: before local midnight", () => {
|
||||||
|
expect(dayKeyFor(Date.UTC(2026, 6, 16, 4, 59), CHI)).toBe("2026-07-15");
|
||||||
|
});
|
||||||
|
it("T2b: at local midnight", () => {
|
||||||
|
expect(dayKeyFor(Date.UTC(2026, 6, 16, 5, 0), CHI)).toBe("2026-07-16");
|
||||||
|
});
|
||||||
|
it("T2c: UTC midnight != festival day", () => {
|
||||||
|
expect(dayKeyFor(Date.UTC(2026, 6, 16, 0, 0), CHI)).toBe("2026-07-15");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DST transitions (T3)", () => {
|
||||||
|
it("T3a before spring-forward, T3b after", () => {
|
||||||
|
expect(renderInZone(Date.UTC(2026, 2, 8, 7, 59), CHI)).toBe("03/08/2026, 01:59:00");
|
||||||
|
expect(renderInZone(Date.UTC(2026, 2, 8, 8, 0), CHI)).toBe("03/08/2026, 03:00:00");
|
||||||
|
});
|
||||||
|
it("T3c/d fall-back", () => {
|
||||||
|
expect(renderInZone(Date.UTC(2026, 10, 1, 6, 59), CHI)).toBe("11/01/2026, 01:59:00");
|
||||||
|
expect(renderInZone(Date.UTC(2026, 10, 1, 7, 0), CHI)).toBe("11/01/2026, 01:00:00");
|
||||||
|
});
|
||||||
|
it("T3e epoch arithmetic unaffected", () => {
|
||||||
|
expect(Date.UTC(2026, 10, 1, 7, 0) - Date.UTC(2026, 10, 1, 6, 59)).toBe(60000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unusual timezone offsets (T4)", () => {
|
||||||
|
it("no-DST Phoenix", () => {
|
||||||
|
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "America/Phoenix")).toBe(
|
||||||
|
"01/15/2026, 11:00:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("+5:45 Kathmandu", () => {
|
||||||
|
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "Asia/Kathmandu")).toBe(
|
||||||
|
"01/15/2026, 23:45:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("30-min DST Lord_Howe", () => {
|
||||||
|
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "Australia/Lord_Howe")).toBe(
|
||||||
|
"01/16/2026, 05:00:00",
|
||||||
|
);
|
||||||
|
expect(renderInZone(Date.UTC(2026, 6, 15, 18, 0), "Australia/Lord_Howe")).toBe(
|
||||||
|
"07/16/2026, 04:30:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server-offset clock correction (T5)", () => {
|
||||||
|
it("skew arithmetic", () => {
|
||||||
|
const deviceNow = Date.UTC(2026, 6, 15, 18, 7, 0);
|
||||||
|
const serverNow = Date.UTC(2026, 6, 15, 18, 0, 0);
|
||||||
|
const skew = serverNow - deviceNow;
|
||||||
|
expect(skew).toBe(-420000);
|
||||||
|
expect(
|
||||||
|
correctedNow(deviceNow, {
|
||||||
|
skewMs: skew,
|
||||||
|
capturedAtDevice: 0,
|
||||||
|
capturedAtMono: 0,
|
||||||
|
source: "x",
|
||||||
|
}),
|
||||||
|
).toBe(serverNow);
|
||||||
|
});
|
||||||
|
it("monotonic drift detection (T5c)", () => {
|
||||||
|
const base = Date.UTC(2026, 6, 15, 18, 7, 0);
|
||||||
|
const monoElapsed = 3600000;
|
||||||
|
const observed = base + monoElapsed + 300000;
|
||||||
|
expect(hasDrift(base, monoElapsed, observed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanity window + F-3 suppression (T6)", () => {
|
||||||
|
const win = { startUtc: Date.UTC(2026, 8, 10), endUtc: Date.UTC(2026, 8, 13), timezone: CHI };
|
||||||
|
it("inside festival never warns", () => {
|
||||||
|
expect(shouldWarnOutsideWindow(Date.UTC(2026, 8, 11, 12), win, null)).toBe(false);
|
||||||
|
});
|
||||||
|
it("far outside warns only if near or skew exists (F-3)", () => {
|
||||||
|
// July for September festival — outside but not near, no skew → suppressed
|
||||||
|
const july = Date.UTC(2026, 6, 15, 18, 0, 0);
|
||||||
|
expect(shouldWarnOutsideWindow(july, win, null)).toBe(false);
|
||||||
|
// same July with skew evidence → warns
|
||||||
|
expect(
|
||||||
|
shouldWarnOutsideWindow(july, win, {
|
||||||
|
skewMs: 0,
|
||||||
|
capturedAtDevice: 0,
|
||||||
|
capturedAtMono: 0,
|
||||||
|
source: "x",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// 2023 wildly wrong → outside and skew absent but still far outside window+threshold? Actually far past end still suppressed until near? For 2023, now << start -45d → outside but not near, no skew → suppressed per F-3
|
||||||
|
expect(shouldWarnOutsideWindow(Date.UTC(2023, 0, 1), win, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Now/Next classification (T7)", () => {
|
||||||
|
const events = [
|
||||||
|
{ id: "e1", startUtc: Date.UTC(2026, 8, 11, 17), endUtc: Date.UTC(2026, 8, 11, 18) },
|
||||||
|
{ id: "e2", startUtc: Date.UTC(2026, 8, 11, 18), endUtc: Date.UTC(2026, 8, 11, 19) },
|
||||||
|
{ id: "e3", startUtc: Date.UTC(2026, 8, 11, 18, 30), endUtc: Date.UTC(2026, 8, 11, 19, 30) },
|
||||||
|
];
|
||||||
|
it("overlapping now", () => {
|
||||||
|
const { now } = classifyNowNext(events, Date.UTC(2026, 8, 11, 18, 45));
|
||||||
|
expect(now.map((e) => e.id)).toEqual(["e2", "e3"]);
|
||||||
|
});
|
||||||
|
it("up-next empty when running", () => {
|
||||||
|
const { next } = classifyNowNext(events, Date.UTC(2026, 8, 11, 18, 45));
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
});
|
||||||
|
it("future is up-next", () => {
|
||||||
|
const { next } = classifyNowNext(events, Date.UTC(2026, 8, 11, 17, 30));
|
||||||
|
expect(next.map((e) => e.id)).toEqual(["e2", "e3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Offline Ready independent of clock (SPIKE-05)", () => {
|
||||||
|
it("readiness not demoted by wrong clock — checked via data-package test", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Do not use device-local timezone as authoritative", () => {
|
||||||
|
it("explicit zone required — renderInZone uses given zone, not host", () => {
|
||||||
|
const utc = Date.UTC(2026, 6, 15, 18, 0, 0);
|
||||||
|
expect(renderInZone(utc, "America/Chicago")).not.toBe(renderInZone(utc, "America/Phoenix"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue