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)", () => {
|
||||
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();
|
||||
// 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.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(
|
||||
(f) =>
|
||||
f.startsWith("src/platform/idb/") ||
|
||||
f.startsWith("src/platform/cache/") ||
|
||||
f.startsWith("src/storage/") ||
|
||||
f.startsWith("src/data/") ||
|
||||
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", () => {
|
||||
|
|
|
|||
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