From 3cd9a2377aee6047a8980d9b708e928269b0ad4a Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Wed, 30 Sep 2026 13:44:31 -0500 Subject: [PATCH 1/4] =?UTF-8?q?Fix=20checkpoint=20regressions:=20jsdom=20l?= =?UTF-8?q?ocalStorage=20shim=20(Node=2022=20shadowing)=20+=20restore=20pe?= =?UTF-8?q?rsistent=20nav=20links=20required=20by=20=C2=A77=20one-tap=20em?= =?UTF-8?q?ergency=20reachability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.ts | 13 ++++++++++++- tests/setup.ts | 35 +++++++++++++++++++++++++++++++++++ vitest.config.ts | 1 + 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/setup.ts diff --git a/src/app/layout.ts b/src/app/layout.ts index 21cb5a7..4c92d9d 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -73,7 +73,18 @@ export function createLayout(): { nav.className = "side-nav"; nav.setAttribute("aria-label", "Primary navigation"); nav.setAttribute("role", "navigation"); - // Bottom navigation removed — redundant with main-screen buttons. + // Persistent navigation: home + the four destinations (contract §7 — + // emergency must stay one tap from every screen). + const navTargets = [HOME_ROUTE, ...ROUTES]; + for (const r of navTargets) { + const a = document.createElement("a"); + a.href = r.path; + a.className = "side-nav__link"; + a.setAttribute("data-route", r.path); + a.textContent = r.id === "home" ? "Home" : r.label; + if (r.id === "emergency") a.classList.add("side-nav__link--emergency"); + nav.append(a); + } root.append(nav); return { root, main, nav, brand, statusChip, skipLink }; diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..1b6a4ae --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,35 @@ +/** + * Vitest setup — runs before every test file. + * + * Why: on Node >=22.4 the experimental native `localStorage` global shadows + * jsdom's implementation and is `undefined` unless `--localstorage-file` is + * passed, so jsdom-environment tests see `localStorage === undefined` + * (ExperimentalWarning at runtime). Install a minimal in-memory stand-in + * whenever the global is missing. + */ +class MemoryStorage implements Storage { + private map = new Map(); + get length(): number { + return this.map.size; + } + clear(): void { + this.map.clear(); + } + getItem(key: string): string | null { + return this.map.has(key) ? (this.map.get(key) as string) : null; + } + key(index: number): string | null { + return [...this.map.keys()][index] ?? null; + } + removeItem(key: string): void { + this.map.delete(key); + } + setItem(key: string, value: string): void { + this.map.set(key, String(value)); + } +} + +const g = globalThis as unknown as { localStorage?: Storage }; +if (typeof g.localStorage === "undefined") { + g.localStorage = new MemoryStorage(); +} diff --git a/vitest.config.ts b/vitest.config.ts index 8cdd72f..5765f26 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ globals: false, include: ["tests/**/*.test.ts"], environment: "node", + setupFiles: ["./tests/setup.ts"], reporters: ["verbose"], coverage: { enabled: false, From 99419880a7e731d66877d9a14f31a28373595ec4 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Wed, 30 Sep 2026 13:59:59 -0500 Subject: [PATCH 2/4] Lint/format cleanup: void-expression and empty-function fixes, optional-chain narrowing, ignore share/ in eslint+prettier --- .prettierignore | 1 + eslint.config.js | 9 ++- index.html | 4 +- src/app/layout.ts | 24 ++++++-- src/app/map.ts | 11 +++- src/app/styles.css | 112 +++++++++++++++++++++++++++++--------- src/domain/clock/logic.ts | 21 +++++-- src/domain/map/logic.ts | 38 +++++++------ src/domain/map/types.ts | 7 ++- src/ui/views/map/map.ts | 52 +++++++++++++----- tests/setup.ts | 4 +- 11 files changed, 208 insertions(+), 75 deletions(-) diff --git a/.prettierignore b/.prettierignore index 37f04d0..0e82e92 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ node_modules dist coverage experiments +share *.md diff --git a/eslint.config.js b/eslint.config.js index 0c82d63..ea26535 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,14 @@ import globals from "globals"; // eslint-disable-next-line @typescript-eslint/no-deprecated export default tseslint.config( { - ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**", "public/sw.js"], + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + "experiments/**", + "public/sw.js", + "share/**", + ], }, js.configs.recommended, ...tseslint.configs.strictTypeChecked, diff --git a/index.html b/index.html index bdb5da0..713d813 100644 --- a/index.html +++ b/index.html @@ -16,8 +16,8 @@ diff --git a/src/app/layout.ts b/src/app/layout.ts index 4c92d9d..da2a307 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -40,12 +40,28 @@ export function createLayout(): { themeBtn.setAttribute("aria-label", "Toggle light/dark theme"); themeBtn.onclick = () => { const html = document.documentElement; - if (html.classList.contains("dark")) { html.classList.remove("dark"); html.classList.add("light"); localStorage.setItem("theme","light"); themeBtn.textContent="☀"; } - else { document.documentElement.classList.remove("light"); document.documentElement.classList.add("dark"); localStorage.setItem("theme","dark"); themeBtn.textContent="◐"; } + if (html.classList.contains("dark")) { + html.classList.remove("dark"); + html.classList.add("light"); + localStorage.setItem("theme", "light"); + themeBtn.textContent = "☀"; + } else { + document.documentElement.classList.remove("light"); + document.documentElement.classList.add("dark"); + localStorage.setItem("theme", "dark"); + themeBtn.textContent = "◐"; + } }; // Apply saved theme on load - if (localStorage.getItem("theme") === "dark") { document.documentElement.classList.add("dark"); document.documentElement.classList.remove("light"); themeBtn.textContent="◐"; } - else { document.documentElement.classList.add("light"); document.documentElement.classList.remove("dark"); themeBtn.textContent="☀"; } + if (localStorage.getItem("theme") === "dark") { + document.documentElement.classList.add("dark"); + document.documentElement.classList.remove("light"); + themeBtn.textContent = "◐"; + } else { + document.documentElement.classList.add("light"); + document.documentElement.classList.remove("dark"); + themeBtn.textContent = "☀"; + } header.append(themeBtn); const statusChip = document.createElement("div"); diff --git a/src/app/map.ts b/src/app/map.ts index 3f6364c..ddd0438 100644 --- a/src/app/map.ts +++ b/src/app/map.ts @@ -18,7 +18,9 @@ export interface MapUrlFactory { const defaultUrls: MapUrlFactory = { createUrl: (blob) => URL.createObjectURL(blob), - revokeUrl: (url) => URL.revokeObjectURL(url), + revokeUrl: (url) => { + URL.revokeObjectURL(url); + }, }; export interface MapHandle { @@ -43,7 +45,12 @@ export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise {} }; + return { + data: null, + revoke: () => { + /* nothing was created */ + }, + }; } } diff --git a/src/app/styles.css b/src/app/styles.css index b5412a7..bc6887e 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -110,40 +110,91 @@ html { scroll-behavior: smooth; } html.dark { - --bg: #0b1411; --fg: #ede9de; --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-surface: rgba(255,255,255,0.035); --deck-line: rgba(255,255,255,0.1); --accent: #75c7a9; --surface: rgba(255,255,255,0.035); --border: rgba(255,255,255,0.1); + --bg: #0b1411; + --fg: #ede9de; + --deck-bg: #0b1411; + --deck-fg: #ede9de; + --deck-surface: rgba(255, 255, 255, 0.035); + --deck-line: rgba(255, 255, 255, 0.1); + --accent: #75c7a9; + --surface: rgba(255, 255, 255, 0.035); + --border: rgba(255, 255, 255, 0.1); } html.light :root, html.light body { - --deck-bg: #f6f9f7; --deck-fg: #172621; --deck-fg-muted: #576b63; - --deck-surface: rgba(20,30,25,0.04); --deck-surface-2: rgba(20,30,25,0.06); --deck-line: rgba(20,30,25,0.12); --deck-line-2: rgba(20,30,25,0.2); - --deck-pine: #1f7a5f; --deck-indigo: #683ecc; --deck-rose: #d3224b; --deck-clay: #bf4622; --deck-amber: #b8610a; - --bg: #f6f9f7; --fg: #172621; --muted: #576b63; --accent: #1f7a5f; --surface: rgba(20,30,25,0.04); --border: rgba(20,30,25,0.12); - --highlight-bg: rgba(184,97,10,0.07); --highlight-border: rgba(184,97,10,0.3); - --chip-pink-bg: rgba(211,34,75,0.08); --chip-pink-fg: #b5244a; - --chip-green-bg: rgba(31,122,95,0.09); --chip-green-fg: #1f7a5f; - --chip-indigo-bg: rgba(104,62,204,0.08); --chip-indigo-fg: #683ecc; - --chip-amber-bg: rgba(184,97,10,0.09); --chip-amber-fg: #96500a; - --focus: #1f7a5f; --emergency: #d3224b; --emergency-contrast: #fff; + --deck-bg: #f6f9f7; + --deck-fg: #172621; + --deck-fg-muted: #576b63; + --deck-surface: rgba(20, 30, 25, 0.04); + --deck-surface-2: rgba(20, 30, 25, 0.06); + --deck-line: rgba(20, 30, 25, 0.12); + --deck-line-2: rgba(20, 30, 25, 0.2); + --deck-pine: #1f7a5f; + --deck-indigo: #683ecc; + --deck-rose: #d3224b; + --deck-clay: #bf4622; + --deck-amber: #b8610a; + --bg: #f6f9f7; + --fg: #172621; + --muted: #576b63; + --accent: #1f7a5f; + --surface: rgba(20, 30, 25, 0.04); + --border: rgba(20, 30, 25, 0.12); + --highlight-bg: rgba(184, 97, 10, 0.07); + --highlight-border: rgba(184, 97, 10, 0.3); + --chip-pink-bg: rgba(211, 34, 75, 0.08); + --chip-pink-fg: #b5244a; + --chip-green-bg: rgba(31, 122, 95, 0.09); + --chip-green-fg: #1f7a5f; + --chip-indigo-bg: rgba(104, 62, 204, 0.08); + --chip-indigo-fg: #683ecc; + --chip-amber-bg: rgba(184, 97, 10, 0.09); + --chip-amber-fg: #96500a; + --focus: #1f7a5f; + --emergency: #d3224b; + --emergency-contrast: #fff; } html.dark :root, html.dark body { - --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-fg-muted: #96a69e; - --deck-surface: rgba(255,255,255,0.035); --deck-surface-2: rgba(255,255,255,0.055); - --deck-line: rgba(255,255,255,0.1); --deck-line-2: rgba(255,255,255,0.16); - --deck-pine: #75c7a9; --deck-indigo: #af97f7; --deck-rose: #fc88ab; --deck-clay: #e47958; --deck-amber: #fcc669; - --bg: var(--deck-bg); --fg: var(--deck-fg); --muted: var(--deck-fg-muted); - --accent: var(--deck-pine); --accent-contrast: var(--deck-on-accent); - --accent-soft: rgba(117,199,169,0.12); - --surface: var(--deck-surface); --border: var(--deck-line); --border-accent: rgba(117,199,169,0.32); - --highlight-bg: rgba(252,198,105,0.07); --highlight-border: rgba(252,198,105,0.3); - --chip-pink-bg: rgba(252,136,171,0.12); --chip-pink-fg: #fc88ab; - --chip-green-bg: rgba(117,199,169,0.12); --chip-green-fg: #75c7a9; - --chip-indigo-bg: rgba(175,151,247,0.12); --chip-indigo-fg: #af97f7; - --chip-amber-bg: rgba(252,198,105,0.12); --chip-amber-fg: #fcc669; - --focus: var(--deck-pine); --emergency: #fc88ab; --emergency-contrast: var(--deck-on-accent); + --deck-bg: #0b1411; + --deck-fg: #ede9de; + --deck-fg-muted: #96a69e; + --deck-surface: rgba(255, 255, 255, 0.035); + --deck-surface-2: rgba(255, 255, 255, 0.055); + --deck-line: rgba(255, 255, 255, 0.1); + --deck-line-2: rgba(255, 255, 255, 0.16); + --deck-pine: #75c7a9; + --deck-indigo: #af97f7; + --deck-rose: #fc88ab; + --deck-clay: #e47958; + --deck-amber: #fcc669; + --bg: var(--deck-bg); + --fg: var(--deck-fg); + --muted: var(--deck-fg-muted); + --accent: var(--deck-pine); + --accent-contrast: var(--deck-on-accent); + --accent-soft: rgba(117, 199, 169, 0.12); + --surface: var(--deck-surface); + --border: var(--deck-line); + --border-accent: rgba(117, 199, 169, 0.32); + --highlight-bg: rgba(252, 198, 105, 0.07); + --highlight-border: rgba(252, 198, 105, 0.3); + --chip-pink-bg: rgba(252, 136, 171, 0.12); + --chip-pink-fg: #fc88ab; + --chip-green-bg: rgba(117, 199, 169, 0.12); + --chip-green-fg: #75c7a9; + --chip-indigo-bg: rgba(175, 151, 247, 0.12); + --chip-indigo-fg: #af97f7; + --chip-amber-bg: rgba(252, 198, 105, 0.12); + --chip-amber-fg: #fcc669; + --focus: var(--deck-pine); + --emergency: #fc88ab; + --emergency-contrast: var(--deck-on-accent); } html.dark body { - background: radial-gradient(120% 80% at 80% -10%, rgba(14,57,44,0.55), transparent 60%), radial-gradient(90% 70% at -10% 110%, rgba(83,48,9,0.35), transparent 55%), var(--bg); + background: + radial-gradient(120% 80% at 80% -10%, rgba(14, 57, 44, 0.55), transparent 60%), + radial-gradient(90% 70% at -10% 110%, rgba(83, 48, 9, 0.35), transparent 55%), var(--bg); } @media (prefers-reduced-motion: reduce) { html { @@ -359,7 +410,14 @@ h3 { line-height: 1; } .theme-btn { - min-height: 36px; padding: 0.25rem 0.5rem; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; color: var(--fg); + min-height: 36px; + padding: 0.25rem 0.5rem; + font: inherit; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + color: var(--fg); } .app-header__status { font-size: 0.8125rem; diff --git a/src/domain/clock/logic.ts b/src/domain/clock/logic.ts index b053a07..35be4cd 100644 --- a/src/domain/clock/logic.ts +++ b/src/domain/clock/logic.ts @@ -12,15 +12,28 @@ const MS_PER_DAY = 24 * 3600 * 1000; /* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */ export interface ClockService { correctedNow(deviceNow: number, skew: ClockSkew | null): number; - hasDrift(baseDevice: number, monoElapsed: number, observedDevice: number, thresholdMs?: number): boolean; - shouldWarnOutsideWindow(nowCorrected: number, win: FestivalWindow, skew: ClockSkew | null, opts?: ClockOptions): boolean; + hasDrift( + baseDevice: number, + monoElapsed: number, + observedDevice: number, + thresholdMs?: number, + ): boolean; + shouldWarnOutsideWindow( + nowCorrected: number, + win: FestivalWindow, + skew: ClockSkew | null, + opts?: ClockOptions, + ): boolean; renderInZone(epochMs: number, timeZone: string): string; formatEventTime(epochMs: number, timeZone: string): string; formatDayLabel(epochMs: number, timeZone: string): string; formatClockTime(epochMs: number, timeZone: string): string; deviceTimeZone(): string; dayKeyFor(epochMs: number, timeZone: string): string; - classifyNowNext(events: readonly T[], now: number): { now: readonly T[]; next: readonly T[] }; + classifyNowNext( + events: readonly T[], + now: number, + ): { now: readonly T[]; next: readonly T[] }; } /** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */ @@ -72,7 +85,7 @@ export function formatClockTime(epochMs: number, timeZone: string): string { /** Device IANA zone for the display toggle — UTC fallback when unresolvable. */ export function deviceTimeZone(): string { try { - return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; + return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } diff --git a/src/domain/map/logic.ts b/src/domain/map/logic.ts index 445c616..7dee082 100644 --- a/src/domain/map/logic.ts +++ b/src/domain/map/logic.ts @@ -91,7 +91,11 @@ export function groupFacilities( category, pois: [...items].sort((a, b) => a.name.localeCompare(b.name)), })) - .sort((a, b) => (order.get(a.category) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.category) ?? Number.MAX_SAFE_INTEGER)); + .sort( + (a, b) => + (order.get(a.category) ?? Number.MAX_SAFE_INTEGER) - + (order.get(b.category) ?? Number.MAX_SAFE_INTEGER), + ); } /** Distinct categories present, in section order — for filter chips. */ @@ -119,14 +123,14 @@ function validLevel(value: unknown): boolean { const level = record(value); return Boolean( level && - typeof level.id === "string" && - typeof level.assetId === "string" && - typeof level.width === "number" && - Number.isFinite(level.width) && - level.width > 0 && - typeof level.height === "number" && - Number.isFinite(level.height) && - level.height > 0, + typeof level.id === "string" && + typeof level.assetId === "string" && + typeof level.width === "number" && + Number.isFinite(level.width) && + level.width > 0 && + typeof level.height === "number" && + Number.isFinite(level.height) && + level.height > 0, ); } @@ -134,20 +138,20 @@ function validPoi(value: unknown): boolean { const poi = record(value); return Boolean( poi && - typeof poi.id === "string" && - typeof poi.name === "string" && - typeof poi.category === "string" && - typeof poi.x === "number" && - Number.isFinite(poi.x) && - typeof poi.y === "number" && - Number.isFinite(poi.y), + typeof poi.id === "string" && + typeof poi.name === "string" && + typeof poi.category === "string" && + typeof poi.x === "number" && + Number.isFinite(poi.x) && + typeof poi.y === "number" && + Number.isFinite(poi.y), ); } /** Structural map check — per-POI tolerance lives in the queries above. */ export function narrowMapSection(value: unknown): MapSection | null { const section = record(value); - if (!section || section.section !== "map") return null; + if (section?.section !== "map") return null; const base = record(section.base); if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null; if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null; diff --git a/src/domain/map/types.ts b/src/domain/map/types.ts index ca31c7c..475f204 100644 --- a/src/domain/map/types.ts +++ b/src/domain/map/types.ts @@ -4,7 +4,12 @@ * optional lat/lng is a hook for future positioning only (no GPS in V1). * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03. */ -import type { MapLevel, MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js"; +import type { + MapLevel, + MapPoi, + MapSection, + PoiCategory, +} from "../../data/festival-package/types.js"; export type { MapPoi, PoiCategory }; diff --git a/src/ui/views/map/map.ts b/src/ui/views/map/map.ts index dc6cb19..e4119a1 100644 --- a/src/ui/views/map/map.ts +++ b/src/ui/views/map/map.ts @@ -13,12 +13,7 @@ import { poiPosition, presentCategories, } from "../../../domain/map/logic.js"; -import type { - MapLevelView, - MapPoi, - MapViewData, - PoiCategory, -} from "../../../domain/map/types.js"; +import type { MapLevelView, MapPoi, MapViewData, PoiCategory } from "../../../domain/map/types.js"; const MIN_SCALE = 1; const MAX_SCALE = 4; @@ -65,7 +60,10 @@ export function createMapView(input: MapViewData | null): HTMLElement { card.className = "view-placeholder"; card.append(text("p", "The map isn't on this device yet.")); card.append( - text("p", "Open with internet to get festival data — the emergency floor always works meanwhile."), + text( + "p", + "Open with internet to get festival data — the emergency floor always works meanwhile.", + ), ); section.append(card); return section; @@ -127,7 +125,10 @@ export function createMapView(input: MapViewData | null): HTMLElement { const stage = document.createElement("div"); stage.className = "map-stage"; stage.tabIndex = 0; - stage.setAttribute("aria-label", "Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows."); + stage.setAttribute( + "aria-label", + "Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows.", + ); stage.style.touchAction = "none"; const world = document.createElement("div"); @@ -216,7 +217,11 @@ export function createMapView(input: MapViewData | null): HTMLElement { (event) => { event.preventDefault(); const rect = stage.getBoundingClientRect(); - zoomAt(event.clientX - rect.left, event.clientY - rect.top, event.deltaY < 0 ? 1.25 : 1 / 1.25); + zoomAt( + event.clientX - rect.left, + event.clientY - rect.top, + event.deltaY < 0 ? 1.25 : 1 / 1.25, + ); }, { passive: false }, ); @@ -300,7 +305,9 @@ export function createMapView(input: MapViewData | null): HTMLElement { // Filter application shared by chips + search. const applyMarkerVisibility = (): void => { const visible = new Set( - filterPois(input.pois, { categories: state.categories, query: state.query }).map((p) => p.id), + filterPois(input.pois, { categories: state.categories, query: state.query }).map( + (p) => p.id, + ), ); for (const [id, marker] of markers) marker.hidden = !visible.has(id); }; @@ -313,7 +320,9 @@ export function createMapView(input: MapViewData | null): HTMLElement { } else { const card = document.createElement("div"); card.className = "view-placeholder"; - card.append(text("p", "The map image isn't downloaded — the facilities list below still works.")); + card.append( + text("p", "The map image isn't downloaded — the facilities list below still works."), + ); section.append(card); } @@ -330,7 +339,14 @@ export function createMapView(input: MapViewData | null): HTMLElement { const paintChips = (): void => { for (const button of chipButtons) { const category = button.getAttribute("data-category") ?? ""; - button.setAttribute("aria-pressed", String(category === "" ? state.categories.size === 0 : state.categories.has(category as PoiCategory))); + button.setAttribute( + "aria-pressed", + String( + category === "" + ? state.categories.size === 0 + : state.categories.has(category as PoiCategory), + ), + ); } }; const allButton = document.createElement("button"); @@ -382,14 +398,20 @@ export function createMapView(input: MapViewData | null): HTMLElement { facilities.className = "map-facilities"; section.append(facilities); + // Narrowed binding: the `!input` guard above returned early, so `input` is + // MapViewData from here on (captured for the closure below). + const data = input; + function refreshFacilities(): void { facilities.replaceChildren(); - const visible = filterPois(input!.pois, { categories: state.categories, query: state.query }); + const visible = filterPois(data.pois, { categories: state.categories, query: state.query }); if (visible.length === 0) { - facilities.append(text("p", input!.pois.length === 0 ? "No facilities listed." : "No facilities match.")); + facilities.append( + text("p", data.pois.length === 0 ? "No facilities listed." : "No facilities match."), + ); return; } - for (const group of groupFacilities(visible, input!.categories)) { + for (const group of groupFacilities(visible, data.categories)) { const heading = document.createElement("h2"); heading.textContent = `${group.category} (${String(group.pois.length)})`; facilities.append(heading); diff --git a/tests/setup.ts b/tests/setup.ts index 1b6a4ae..4d20a74 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -16,7 +16,7 @@ class MemoryStorage implements Storage { this.map.clear(); } getItem(key: string): string | null { - return this.map.has(key) ? (this.map.get(key) as string) : null; + return this.map.get(key) ?? null; } key(index: number): string | null { return [...this.map.keys()][index] ?? null; @@ -25,7 +25,7 @@ class MemoryStorage implements Storage { this.map.delete(key); } setItem(key: string, value: string): void { - this.map.set(key, String(value)); + this.map.set(key, value); } } From 8a1171fa414d4eb7c59e0d363327769cad80d413 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Wed, 30 Sep 2026 14:15:46 -0500 Subject: [PATCH 3/4] Stage 5 gaps: real QuotaExceededError injection (P3), openDB oldVersion migration-seam test, wrapper oldVersion from IDBVersionChangeEvent --- src/platform/idb/wrapper.ts | 14 +++-- tests/unit/persistence.test.ts | 93 ++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/platform/idb/wrapper.ts b/src/platform/idb/wrapper.ts index 1aec7a1..aa42301 100644 --- a/src/platform/idb/wrapper.ts +++ b/src/platform/idb/wrapper.ts @@ -40,11 +40,14 @@ export function openDB( ): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(name, version); - req.onupgradeneeded = () => { + req.onupgradeneeded = (event) => { const db = req.result; const tx = req.transaction; - if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx); - else onUpgrade(db, 0, null as unknown as IDBTransaction); + // IDBVersionChangeEvent.oldVersion is the authoritative pre-upgrade + // version (db.version is already the NEW version here). + const oldVersion = (event as IDBVersionChangeEvent).oldVersion ?? 0; + if (tx) onUpgrade(db, oldVersion, tx); + else onUpgrade(db, oldVersion, null as unknown as IDBTransaction); }; req.onsuccess = () => { resolve(req.result); @@ -59,11 +62,6 @@ export function openDB( }); } -function oldVersionFallback(req: IDBOpenDBRequest): number { - // Some fake-indexeddb versions expose oldVersion via transaction? Fallback 0. - return (req as unknown as { oldVersion?: number }).oldVersion ?? 0; -} - /** * Execute a single transaction over given stores — caller must NOT await * non-IDB work inside the callback (P1). Callback receives live tx. diff --git a/tests/unit/persistence.test.ts b/tests/unit/persistence.test.ts index fa74e89..f6881da 100644 --- a/tests/unit/persistence.test.ts +++ b/tests/unit/persistence.test.ts @@ -10,11 +10,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import FDBFactory from "fake-indexeddb/lib/FDBFactory"; // @ts-expect-error fake-indexeddb types via exports fallback import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBObjectStore from "fake-indexeddb/lib/FDBObjectStore"; // polyfill globals for Node environment const g = globalThis as unknown as Record; g.indexedDB = new FDBFactory() as unknown; g.IDBKeyRange = FDBKeyRange as unknown; +g.IDBObjectStore = FDBObjectStore as unknown; // dynamic imports after polyfill — wrapper reads global indexedDB at call time import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js"; @@ -270,11 +273,8 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => { }, appVersionAtActivation: "1.0.0", }); - // attempt staging into inactive slot B but inject quota error via mocked put - // Simulate by directly testing isQuotaError path and ensuring active still readable const activeBefore = await readSystemMeta(sys); expect(activeBefore.activeSlot).toBe("A"); - // No actual quota error from fake-indexeddb, but verify active dataset still complete expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 }); // B remains empty — staging interrupted keeps active expect(await readSlotFile(slotB, "emergency")).toBeUndefined(); @@ -282,6 +282,93 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => { slotB.close(); sys.close(); }); + + it("P3 quota INJECTION — put() throwing QuotaExceededError rejects the write and keeps active slot intact", async () => { + // Real injection: patch IDBObjectStore.prototype.put so writes of the + // staged blob throw a genuine QuotaExceededError-shaped failure. + const slotA = await openSlotDB("A"); + const slotB = await openSlotDB("B"); + const sys = await openSystemDB(); + await writeSlotFile(slotA, "emergency", { + bytes: 100, + sha256: "a".repeat(64), + json: { section: "emergency", v: 1 }, + }); + await writeSystemMeta(sys, { + ...INITIAL_SYSTEM_META, + activeSlot: "A", + activeEdition: "lumen-2026", + activePackageVersion: 1, + }); + + const proto = (globalThis as unknown as { IDBObjectStore: { prototype: IDBObjectStore } }) + .IDBObjectStore.prototype; + const originalPut = proto.put; + let injected = 0; + // Deliberate prototype patch — `this` scoping is the point of the shim. + // eslint-disable-next-line + proto.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey) { + const rec = value as { bytes?: number } | null; + if (rec && typeof rec.bytes === "number" && rec.bytes >= 1024) { + injected++; + throw new DOMException("injected quota", "QuotaExceededError"); + } + return originalPut.call(this, value, key); + } as typeof proto.put; + + try { + const big = new Blob([new Uint8Array(4096)]); + await expect( + writeSlotAsset(slotB, "map-base-overview", { + bytes: big.size, + sha256: "b".repeat(64), + blob: big, + }), + ).rejects.toSatisfy(isQuotaError); + expect(injected).toBeGreaterThan(0); + // P3 invariant: active dataset fully readable, inactive slot empty. + expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 }); + const meta = await readSystemMeta(sys); + expect(meta.activeSlot).toBe("A"); + expect(await readSlotAsset(slotB, "map-base-overview")).toBeUndefined(); + } finally { + proto.put = originalPut; + } + slotA.close(); + slotB.close(); + sys.close(); + }); +}); + +describe("migration seam — openDB versionchange (lumen-user migrations ride here)", () => { + beforeEach(cleanAll); + afterEach(cleanAll); + + it("openDB passes authoritative oldVersion to onUpgrade across versions", async () => { + // The seam lumen-user migrations will ride on: reopening at a higher + // version must report the PRE-upgrade version (not the new one) and run + // inside the versionchange txn so data from prior versions is preserved. + const name = "lumen-test-migrate"; + const db1 = await openDB(name, 1, (db) => { + db.createObjectStore("prefs"); + }); + await idbPut(db1, "prefs", { theme: "amber" }, "prefs"); + db1.close(); + + const seen: number[] = []; + const db2 = await openDB(name, 2, (db, oldVersion) => { + seen.push(oldVersion); + if (oldVersion < 2 && !db.objectStoreNames.contains("favs")) { + db.createObjectStore("favs"); + } + }); + expect(seen).toEqual([1]); + // prior-version data intact, new store usable + expect(await idbGet(db2, "prefs", "prefs")).toEqual({ theme: "amber" }); + await idbPut(db2, "favs", { eventId: "e1" }, "e1"); + db2.close(); + await deleteDB(name); + }); }); describe("system-meta store — P2 single-txn activation + F-2/F-3", () => { From 0f912547cd461644405b996e541b7986ab965ad4 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Wed, 30 Sep 2026 14:19:56 -0500 Subject: [PATCH 4/4] Stage 5: eslint-disable placement fix on quota-injection prototype patch; full CI green (275 tests) --- tests/unit/persistence.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/persistence.test.ts b/tests/unit/persistence.test.ts index f6881da..16267c4 100644 --- a/tests/unit/persistence.test.ts +++ b/tests/unit/persistence.test.ts @@ -303,10 +303,10 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => { const proto = (globalThis as unknown as { IDBObjectStore: { prototype: IDBObjectStore } }) .IDBObjectStore.prototype; + // Deliberate prototype patch — unbound read/assign is the point of the shim. + // eslint-disable-next-line @typescript-eslint/unbound-method const originalPut = proto.put; let injected = 0; - // Deliberate prototype patch — `this` scoping is the point of the shim. - // eslint-disable-next-line proto.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey) { const rec = value as { bytes?: number } | null; if (rec && typeof rec.bytes === "number" && rec.bytes >= 1024) {