From bcff17a71850d603122550d20f3677911e18758e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 19:41:36 +0200 Subject: [PATCH] feat(ui): Effect-native data layer + zero-host mock transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AtomHttpApi.Service derives the typed client straight from the shared RomManagerApi contract — no hand-mirrored types, no react-query. Queries carry reactivity keys; mutations pass them per-call (constants exported beside each mutation). liveStatusAtom merges the kit sseAtom feed over GET /api/status; statusEventsAtom folds frames into the activity feed. useConfigDraft keeps a raw-config working copy (defaults never baked in) with dirty tracking and toast-reporting save. Mock mode is a full in-memory RomManagerApi as an HttpClient layer plus a scenario ticker (healthy/firstRun/syncing/errors), registered through a fixture-free seam so production chunks stay clean — mocks/http.ts is only imported behind import.meta.env.DEV and by Storybook. Scenario and mode atoms are keepAlive: the transport reads them outside the reactive graph and registry-seeded values must survive pages that never subscribe them. Co-Authored-By: Claude Fable 5 --- ui/src/data/atoms.ts | 155 ++++++++++++++++ ui/src/data/client.ts | 42 +++++ ui/src/data/drafts.ts | 101 +++++++++++ ui/src/lib/errors.ts | 18 ++ ui/src/mocks/fixtures.ts | 383 +++++++++++++++++++++++++++++++++++++++ ui/src/mocks/http.ts | 145 +++++++++++++++ ui/src/mocks/register.ts | 34 ++++ ui/src/mocks/scenario.ts | 43 +++++ 8 files changed, 921 insertions(+) create mode 100644 ui/src/data/atoms.ts create mode 100644 ui/src/data/client.ts create mode 100644 ui/src/data/drafts.ts create mode 100644 ui/src/lib/errors.ts create mode 100644 ui/src/mocks/fixtures.ts create mode 100644 ui/src/mocks/http.ts create mode 100644 ui/src/mocks/register.ts create mode 100644 ui/src/mocks/scenario.ts diff --git a/ui/src/data/atoms.ts b/ui/src/data/atoms.ts new file mode 100644 index 0000000..96de4e4 --- /dev/null +++ b/ui/src/data/atoms.ts @@ -0,0 +1,155 @@ +// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the +// mutation constants below document which keys each mutation must pass at call time +// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`). + +import { sseAtom } from "@punktfunk/plugin-kit/react"; +import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@rom-manager/contract"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { installedMockStatusStream } from "@/mocks/register"; +import { apiModeAtom } from "@/mocks/scenario"; +import { Api, prefix } from "./client"; + +// ── Queries ──────────────────────────────────────────────────────────────────── + +export const statusAtom = Api.query("status", "get", { + reactivityKeys: ["status"], +}); + +export const configAtom = Api.query("config", "get", { + reactivityKeys: ["config"], +}); + +export const previewAtom = Api.query("library", "preview", { + reactivityKeys: ["preview"], +}); + +export const platformsAtom = Api.query("catalog", "platforms", { + reactivityKeys: ["catalog"], + timeToLive: "5 minutes", +}); + +export const emulatorsAtom = Api.query("catalog", "emulators", { + reactivityKeys: ["emulators"], +}); + +// ── Mutations + the keys they invalidate ─────────────────────────────────────── + +export const saveConfig = Api.mutation("config", "put"); +/** A config save changes the desired state everywhere derived from it. */ +export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const; + +export const runSync = Api.mutation("sync", "run"); +export const RUN_SYNC_KEYS = ["status", "preview"] as const; + +export const runDetect = Api.mutation("catalog", "detect"); +export const RUN_DETECT_KEYS = ["emulators", "status"] as const; + +// ── Live status (SSE with polling fallback) ──────────────────────────────────── + +/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */ +const liveStatusStream = sseAtom({ + url: `${prefix}${EVENTS_PATH}`, + event: EVENTS_EVENT, + schema: EngineStatus, +}); + +/** + * The status frame stream, mode-independent: SSE in live mode, the fixture ticker in + * mock mode. `undefined` until the first frame arrives. + */ +export const statusStreamAtom: Atom.Atom = Atom.make( + (get) => { + if (get(apiModeAtom) === "mock") { + const mock = installedMockStatusStream(); + return mock === undefined ? undefined : get(mock); + } + return get(liveStatusStream); + }, +); + +/** + * What pages actually render: the freshest engine status — the latest SSE frame once + * one has arrived, the plain GET /api/status result before that (and as error/skeleton + * source, since the SSE stream never fails). + */ +export const liveStatusAtom: Atom.Atom> = + Atom.make((get) => { + const frame = get(statusStreamAtom); + if (frame !== undefined) { + return AsyncResult.success>( + frame, + ); + } + return get(statusAtom); + }); + +// ── Status transitions → activity feed ───────────────────────────────────────── + +export type StatusEvent = { + readonly id: string; + readonly at: number; + readonly text: string; + readonly level: "info" | "warn" | "error"; +}; + +const EVENT_LIMIT = 50; + +/** + * Folds the status frame stream into a bounded activity list for the Overview feed — + * client-side only, no history endpoint: connect, sync start/finish (with the report's + * count), report warnings, detection runs. + */ +export const statusEventsAtom: Atom.Atom> = + Atom.make((get) => { + let prev: EngineStatus | undefined; + let items: ReadonlyArray = []; + let seq = 0; + get.subscribe( + statusStreamAtom, + (frame) => { + if (frame === undefined) return; + const next: Array = []; + const push = (text: string, level: StatusEvent["level"] = "info") => + next.push({ id: `ev-${seq++}`, at: Date.now(), text, level }); + + if (prev === undefined) { + const n = frame.rootsConfigured; + push(`Engine connected — ${n} ROM ${n === 1 ? "source" : "sources"}`); + if (frame.syncing) push("Sync running"); + } else { + if (!prev.syncing && frame.syncing) push("Sync started"); + if (prev.syncing && !frame.syncing) { + const report = frame.lastReport; + if (report === undefined) { + push("Sync finished"); + } else { + push( + `Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`, + ); + for (const warning of report.warnings) push(warning, "warn"); + if (report.overWarn) { + push( + `Library is over the warning threshold (${report.included} entries)`, + "warn", + ); + } + } + } + if ( + frame.detectedAt !== undefined && + frame.detectedAt !== prev.detectedAt + ) { + push("Emulator detection finished"); + } + } + + prev = frame; + if (next.length > 0) { + items = [...items, ...next].slice(-EVENT_LIMIT); + get.setSelf(items); + } + }, + { immediate: true }, + ); + return items; + }); diff --git a/ui/src/data/client.ts b/ui/src/data/client.ts new file mode 100644 index 0000000..2ae784e --- /dev/null +++ b/ui/src/data/client.ts @@ -0,0 +1,42 @@ +// The one place the UI talks HTTP: a typed client + atom factory derived from the +// SHARED HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in +// pages — endpoints, payloads, and errors all flow from @rom-manager/contract. + +import { resolvePluginBase } from "@punktfunk/plugin-kit/react"; +import { RomManagerApi } from "@rom-manager/contract"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, +} from "effect/unstable/http"; +import { AtomHttpApi } from "effect/unstable/reactivity"; +import { installedMockHttpLayer } from "@/mocks/register"; +import { apiModeAtom } from "@/mocks/scenario"; + +/** + * "/plugin-ui/" behind the console proxy, "" in dev — Vite serves at "/", so + * relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode). + */ +export const prefix = resolvePluginBase(); + +export class Api extends AtomHttpApi.Service()("RomApi", { + api: RomManagerApi, + // Mock mode swaps the transport UNDER the same typed client, so pages cannot tell + // fixtures from a live host. The mock layer arrives via mocks/register (dev-only + // import); if it is not installed we fall through to fetch — a loud, honest failure. + httpClient: (get) => { + if (get(apiModeAtom) === "mock") { + const mock = installedMockHttpLayer(get); + if (mock !== undefined) return mock; + } + return FetchHttpClient.layer; + }, + // prependUrl("") breaks URL joining — only install the transform when proxied. + ...(prefix !== "" + ? { + transformClient: HttpClient.mapRequest( + HttpClientRequest.prependUrl(prefix), + ), + } + : {}), +}) {} diff --git a/ui/src/data/drafts.ts b/ui/src/data/drafts.ts new file mode 100644 index 0000000..1143257 --- /dev/null +++ b/ui/src/data/drafts.ts @@ -0,0 +1,101 @@ +// The config draft: a local raw-config working copy seeded from GET /api/config. +// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved` +// decodes the draft through the SHARED RomConfigSchema so controls can display +// effective values (e.g. pollMinutes 15 when the file omits it). + +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + type Config, + type RawConfig, + resolveConfig, +} from "@rom-manager/contract"; +import { toast } from "@unom/ui/toast"; +import { Cause, Exit } from "effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { errorText } from "@/lib/errors"; +import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms"; + +export type ConfigDraft = { + /** False until GET /api/config has succeeded once (render a skeleton). */ + readonly loaded: boolean; + /** The working copy of the authored (raw) config. `{}` until loaded. */ + readonly draft: RawConfig; + /** The draft decoded through RomConfigSchema (defaults filled), if valid. */ + readonly resolved: Config | undefined; + readonly dirty: boolean; + readonly saving: boolean; + readonly patch: (patch: Partial) => void; + readonly save: () => void; + readonly discard: () => void; +}; + +type DraftState = { + readonly baseline: RawConfig; + readonly draft: RawConfig; +}; + +export const useConfigDraft = (): ConfigDraft => { + const result = useAtomValue(configAtom); + const saveResult = useAtomValue(saveConfig); + const write = useAtomSet(saveConfig, { mode: "promiseExit" }); + const [state, setState] = useState(null); + + // Seed once from the first successful load; later refetches (our own saves) must + // not clobber in-progress edits. + useEffect(() => { + if (state === null && AsyncResult.isSuccess(result)) { + const raw = result.value.raw as RawConfig; + setState({ baseline: raw, draft: raw }); + } + }, [result, state]); + + const draft = state?.draft ?? {}; + const dirty = + state !== null && + JSON.stringify(state.draft) !== JSON.stringify(state.baseline); + + const resolved = useMemo(() => { + try { + return resolveConfig(draft); + } catch { + return undefined; + } + }, [draft]); + + const patch = useCallback((p: Partial) => { + setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } })); + }, []); + + const discard = useCallback(() => { + setState((s) => (s === null ? s : { ...s, draft: s.baseline })); + }, []); + + const save = useCallback(() => { + if (state === null) return; + const raw = state.draft; + void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then( + (exit) => { + if (Exit.isSuccess(exit)) { + setState({ baseline: raw, draft: raw }); + toast.success("Configuration saved"); + } else { + toast.error("Save failed", { + description: errorText(Cause.squash(exit.cause)), + }); + } + }, + ); + }, [state, write]); + + return { + loaded: state !== null, + draft, + resolved, + dirty, + saving: AsyncResult.isWaiting(saveResult), + patch, + save, + discard, + }; +}; diff --git a/ui/src/lib/errors.ts b/ui/src/lib/errors.ts new file mode 100644 index 0000000..3268b8c --- /dev/null +++ b/ui/src/lib/errors.ts @@ -0,0 +1,18 @@ +// One place to turn a typed API error (or any transport failure) into a sentence. +export const errorText = (error: unknown): string => { + if (typeof error === "object" && error !== null) { + const e = error as { + _tag?: string; + issues?: string; + message?: string; + reason?: unknown; + }; + if (e._tag === "ConfigInvalid" && typeof e.issues === "string") { + return e.issues; + } + if (e._tag === "SyncInProgress") return "A sync pass is already running"; + if (typeof e.message === "string" && e.message.length > 0) return e.message; + if (typeof e._tag === "string") return e._tag; + } + return String(error); +}; diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts new file mode 100644 index 0000000..1fc44e8 --- /dev/null +++ b/ui/src/mocks/fixtures.ts @@ -0,0 +1,383 @@ +// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT — +// if the contract moves, this file fails typecheck, exactly like the real server would. +// Never imported by production app code (see mocks/register.ts for the seam). +import type { + DetectedEmulator, + EmulatorsPayload, + EngineStatus, + Platform, + PreviewResult, + RawConfig, + SyncReport, +} from "@rom-manager/contract"; +import type { Scenario } from "./scenario"; + +type ProviderEntry = PreviewResult["entries"][number]; + +// ── Games ────────────────────────────────────────────────────────────────────── + +const portrait = (title: string): string => + `https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`; + +const game = ( + platform: string, + file: string, + title: string, + launch: string, +): ProviderEntry => ({ + external_id: `${platform}/${file}`, + title, + art: { portrait: portrait(title) }, + launch: { kind: "command", value: launch }, +}); + +const retroarch = (core: string, platform: string, file: string): string => + `/usr/bin/retroarch -f -L ${core} '/roms/${platform}/${file}'`; + +/** Every game the mock scan finds (13 — one is excluded via config by default). */ +export const GAMES: ReadonlyArray = [ + game( + "snes", + "Chrono Trigger (USA).sfc", + "Chrono Trigger", + retroarch("snes9x", "snes", "Chrono Trigger (USA).sfc"), + ), + game( + "snes", + "Super Metroid (USA).sfc", + "Super Metroid", + retroarch("snes9x", "snes", "Super Metroid (USA).sfc"), + ), + game( + "snes", + "The Legend of Zelda - A Link to the Past (USA).sfc", + "The Legend of Zelda: A Link to the Past", + retroarch( + "snes9x", + "snes", + "The Legend of Zelda - A Link to the Past (USA).sfc", + ), + ), + game( + "snes", + "EarthBound (USA).sfc", + "EarthBound", + retroarch("snes9x", "snes", "EarthBound (USA).sfc"), + ), + game( + "snes", + "Super Mario World (USA).sfc", + "Super Mario World", + retroarch("snes9x", "snes", "Super Mario World (USA).sfc"), + ), + game( + "n64", + "Super Mario 64 (USA).z64", + "Super Mario 64", + retroarch("mupen64plus_next", "n64", "Super Mario 64 (USA).z64"), + ), + game( + "n64", + "The Legend of Zelda - Ocarina of Time (USA).z64", + "The Legend of Zelda: Ocarina of Time", + retroarch( + "mupen64plus_next", + "n64", + "The Legend of Zelda - Ocarina of Time (USA).z64", + ), + ), + game( + "n64", + "Mario Kart 64 (USA).z64", + "Mario Kart 64", + retroarch("mupen64plus_next", "n64", "Mario Kart 64 (USA).z64"), + ), + game( + "n64", + "GoldenEye 007 (USA).z64", + "GoldenEye 007", + retroarch("mupen64plus_next", "n64", "GoldenEye 007 (USA).z64"), + ), + game( + "ps1", + "Final Fantasy VII (USA).chd", + "Final Fantasy VII", + "/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Final Fantasy VII (USA).chd'", + ), + game( + "ps1", + "Castlevania - Symphony of the Night (USA).chd", + "Castlevania: Symphony of the Night", + "/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Castlevania - Symphony of the Night (USA).chd'", + ), + game( + "ps1", + "Metal Gear Solid (USA).chd", + "Metal Gear Solid", + "/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Metal Gear Solid (USA).chd'", + ), + game( + "ps1", + "Vagrant Story (USA).chd", + "Vagrant Story", + "/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Vagrant Story (USA).chd'", + ), +]; + +// ── Catalog ──────────────────────────────────────────────────────────────────── + +export const PLATFORMS: ReadonlyArray = [ + { + id: "snes", + name: "Super Nintendo", + extensions: ["sfc", "smc", "zip"], + libretroSystem: "Nintendo - Super Nintendo Entertainment System", + defaultLaunch: { emulator: "retroarch", core: "snes9x" }, + }, + { + id: "n64", + name: "Nintendo 64", + extensions: ["z64", "n64", "v64", "zip"], + libretroSystem: "Nintendo - Nintendo 64", + defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" }, + }, + { + id: "ps1", + name: "PlayStation", + extensions: ["chd", "cue", "pbp", "m3u"], + libretroSystem: "Sony - PlayStation", + defaultLaunch: { emulator: "duckstation" }, + disc: true, + }, + { + id: "gamecube", + name: "GameCube", + extensions: ["iso", "rvz", "gcz"], + libretroSystem: "Nintendo - GameCube", + defaultLaunch: { emulator: "dolphin" }, + disc: true, + }, +]; + +export const DETECTED_EMULATORS: ReadonlyArray = [ + { + id: "retroarch", + name: "RetroArch", + template: "{exe} -f -L {core} {rom}", + supportsArchives: true, + exeToken: "retroarch", + exePath: "/usr/bin/retroarch", + via: "path", + coresDir: "/home/deck/.config/retroarch/cores", + cores: ["snes9x", "mupen64plus_next", "mesen", "gambatte", "mgba"], + }, + { + id: "duckstation", + name: "DuckStation", + template: "{exe} -batch -fullscreen {rom}", + supportsArchives: false, + exeToken: "duckstation-qt", + exePath: "/usr/bin/duckstation-qt", + via: "path", + }, +]; + +const DETECTED_AT = Date.now() - 2 * 60 * 60 * 1000; + +export const emulatorsFor = (scenario: Scenario): EmulatorsPayload => + scenario === "firstRun" + ? { detected: [], detectedAt: null } + : { detected: [...DETECTED_EMULATORS], detectedAt: DETECTED_AT }; + +// ── Config ───────────────────────────────────────────────────────────────────── + +const HEALTHY_CONFIG: RawConfig = { + roots: [ + { dir: "/roms/snes", platform: "snes" }, + { dir: "/roms/n64", platform: "n64" }, + { dir: "/roms/ps1", platform: "ps1", excludes: ["**/*.bak"] }, + ], + gameOverrides: { + "snes/Super Mario World (USA).sfc": { exclude: true }, + }, + art: { enabled: true, provider: "steamgriddb", steamGridDbKey: "sgdb-demo" }, +}; + +export const configFor = (scenario: Scenario): RawConfig => + scenario === "firstRun" ? {} : HEALTHY_CONFIG; + +// ── Preview + report (derived from the current raw config, so toggles round-trip) ── + +const platformOf = (externalId: string): string => + externalId.split("/")[0] ?? "unknown"; + +const SKIPPED = [ + { + external_id: "n64/GoldenEye 007 (Europe).z64", + title: "GoldenEye 007 (Europe)", + reason: "region duplicate of GoldenEye 007 (USA)", + }, + { + external_id: "ps1/Final Fantasy VII (USA) (Disc 2).chd", + title: "Final Fantasy VII (Disc 2)", + reason: "multi-disc: only disc 1 is listed", + }, +]; + +export const previewFor = ( + scenario: Scenario, + raw: RawConfig, +): PreviewResult => { + if (scenario === "firstRun") { + return { + entries: [], + report: { + considered: 0, + included: 0, + skipped: [], + excluded: [], + warnings: [], + truncated: 0, + perPlatform: {}, + overWarn: false, + }, + }; + } + const overrides = raw.gameOverrides ?? {}; + const excludedIds = new Set( + Object.entries(overrides) + .filter(([, o]) => o.exclude === true) + .map(([id]) => id), + ); + const entries = GAMES.filter((g) => !excludedIds.has(g.external_id)); + const excluded = GAMES.filter((g) => excludedIds.has(g.external_id)).map( + (g) => ({ external_id: g.external_id, title: g.title }), + ); + const perPlatform: Record = {}; + for (const entry of entries) { + const p = platformOf(entry.external_id); + perPlatform[p] = (perPlatform[p] ?? 0) + 1; + } + const warnings = + scenario === "errors" + ? [ + "steamgriddb: 401 unauthorized — check the API key", + "/roms/n64: 3 files with unknown extensions ignored", + ] + : []; + const report: SyncReport = { + considered: GAMES.length + SKIPPED.length, + included: entries.length, + skipped: SKIPPED, + excluded, + warnings, + truncated: 0, + perPlatform, + overWarn: scenario === "errors", + }; + return { entries, report }; +}; + +export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport => + previewFor(scenario, raw).report; + +// ── Engine status + the SSE stand-in ticker ──────────────────────────────────── + +const PATHS = { + dir: "/home/deck/.local/share/punktfunk/plugin-state/rom-manager", + config: + "/home/deck/.local/share/punktfunk/plugin-state/rom-manager/config.json", + cache: + "/home/deck/.local/share/punktfunk/plugin-state/rom-manager/cache.json", +} as const; + +export const statusFor = (scenario: Scenario): EngineStatus => { + const report = reportFor(scenario, configFor(scenario)); + switch (scenario) { + case "firstRun": + return { + rootsConfigured: 0, + os: "linux", + artProvider: null, + syncing: false, + paths: PATHS, + }; + case "syncing": + return { + rootsConfigured: 3, + os: "linux", + artProvider: "steamgriddb", + syncing: true, + lastSync: { + fingerprint: "b3c1a94efd02", + count: report.included, + at: Date.now() - 8 * 60 * 1000, + }, + lastReport: report, + detectedAt: DETECTED_AT, + paths: PATHS, + }; + case "errors": + return { + rootsConfigured: 3, + os: "linux", + artProvider: null, + syncing: false, + lastSync: { + fingerprint: "b3c1a94efd02", + count: report.included, + at: Date.now() - 45 * 60 * 1000, + }, + lastReport: report, + detectedAt: DETECTED_AT, + paths: PATHS, + }; + default: + return { + rootsConfigured: 3, + os: "linux", + artProvider: "steamgriddb", + syncing: false, + lastSync: { + fingerprint: "b3c1a94efd02", + count: report.included, + at: Date.now() - 8 * 60 * 1000, + }, + lastReport: report, + detectedAt: DETECTED_AT, + paths: PATHS, + }; + } +}; + +/** + * Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop + * idle → syncing → done so the Overview feed and sync button stay alive without a host. + */ +export const tickerFrames = ( + scenario: Scenario, +): ReadonlyArray => { + const base = statusFor(scenario); + switch (scenario) { + case "firstRun": + return [base]; + case "syncing": + return [base]; + case "errors": + return [base, { ...base, syncing: true }, { ...base, syncing: false }]; + default: + return [ + base, + { ...base, syncing: true }, + { + ...base, + syncing: false, + lastSync: { + fingerprint: "c4d2ba50fe13", + count: base.lastReport?.included ?? 0, + at: Date.now(), + }, + }, + ]; + } +}; diff --git a/ui/src/mocks/http.ts b/ui/src/mocks/http.ts new file mode 100644 index 0000000..a86cde5 --- /dev/null +++ b/ui/src/mocks/http.ts @@ -0,0 +1,145 @@ +// The mock transport: a full in-memory implementation of the RomManagerApi surface as +// an HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS +// both (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and +// Storybook's preview import it, so production app chunks never contain fixtures. +import type { EngineStatus, RawConfig } from "@rom-manager/contract"; +import { Duration, Effect, Layer } from "effect"; +import { + HttpClient, + type HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; +import * as fx from "./fixtures"; +import { registerMocks } from "./register"; +import { type Scenario, scenarioAtom } from "./scenario"; + +/** PUT /api/config lands here so config edits round-trip within a registry. + * keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */ +const mockRawConfigAtom = Atom.keepAlive(Atom.make(null)); + +const currentRawConfig = ( + registry: AtomRegistry.AtomRegistry, + scenario: Scenario, +): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario); + +const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => { + const body = request.body; + if (body._tag === "Uint8Array") { + return JSON.parse(new TextDecoder().decode(body.body)); + } + if (body._tag === "Raw") return body.body; + return undefined; +}; + +export const mockHttpClientLayer = ( + get: Atom.AtomContext, +): Layer.Layer => { + const registry = get.registry; + return Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request, url) => + Effect.gen(function* () { + // Real-ish latency so skeletons/spinners are honest in dev + Storybook. + yield* Effect.sleep(Duration.millis(150 + Math.random() * 250)); + const scenario = registry.get(scenarioAtom); + const json = (body: unknown, status = 200) => + HttpClientResponse.fromWeb(request, Response.json(body, { status })); + + switch (`${request.method} ${url.pathname}`) { + case "GET /api/status": + return json(fx.statusFor(scenario)); + + case "GET /api/config": + return json({ raw: currentRawConfig(registry, scenario) }); + + case "PUT /api/config": { + if (scenario === "errors") { + return json( + { + _tag: "ConfigInvalid", + issues: 'roots[1].platform: unknown platform id "n65"', + }, + 400, + ); + } + const raw = requestJson(request) as RawConfig; + registry.set(mockRawConfigAtom, raw); + return json({ raw }); + } + + case "GET /api/preview": { + if (scenario === "errors") { + return json( + { + _tag: "ScanFailed", + message: "scan failed: /roms/n64: permission denied", + }, + 500, + ); + } + return json( + fx.previewFor(scenario, currentRawConfig(registry, scenario)), + ); + } + + case "GET /api/platforms": + return json(fx.PLATFORMS); + + case "GET /api/emulators": + return json(fx.emulatorsFor(scenario)); + + case "POST /api/detect": { + if (scenario === "errors") { + return json( + { + _tag: "DetectFailed", + message: "detection failed: PATH probe timed out", + }, + 500, + ); + } + return json({ + ...fx.emulatorsFor(scenario), + detectedAt: Date.now(), + }); + } + + case "POST /api/sync": { + if (scenario === "syncing") { + return json({ _tag: "SyncInProgress" }, 409); + } + return json( + fx.reportFor(scenario, currentRawConfig(registry, scenario)), + ); + } + + default: + return json( + { error: `mock: unhandled ${request.method} ${url.pathname}` }, + 404, + ); + } + }), + ), + ); +}; + +/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */ +export const mockStatusStreamAtom: Atom.Atom = + Atom.make((get) => { + const frames = fx.tickerFrames(get(scenarioAtom)); + if (frames.length > 1) { + let i = 0; + const timer = setInterval(() => { + i += 1; + get.setSelf(frames[i % frames.length]); + }, 2000); + get.addFinalizer(() => clearInterval(timer)); + } + return frames[0]; + }); + +registerMocks({ + httpLayer: mockHttpClientLayer, + statusStream: mockStatusStreamAtom, +}); diff --git a/ui/src/mocks/register.ts b/ui/src/mocks/register.ts new file mode 100644 index 0000000..4472d81 --- /dev/null +++ b/ui/src/mocks/register.ts @@ -0,0 +1,34 @@ +// The dev-only seam between the always-shipped data layer and the mock transport. +// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls +// `registerMocks` at import time and is only ever imported by main.tsx behind +// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay +// free of fixture code without any conditional imports in the data layer. +import type { EngineStatus } from "@rom-manager/contract"; +import type { Layer } from "effect"; +import type { HttpClient } from "effect/unstable/http"; +import type { Atom } from "effect/unstable/reactivity"; + +export type MockHttpLayerFactory = ( + get: Atom.AtomContext, +) => Layer.Layer; + +type Registered = { + readonly httpLayer: MockHttpLayerFactory; + readonly statusStream: Atom.Atom; +}; + +let registered: Registered | undefined; + +export const registerMocks = (mocks: Registered): void => { + registered = mocks; +}; + +/** The mock HttpClient layer, when mocks/http.ts has been imported. */ +export const installedMockHttpLayer = ( + get: Atom.AtomContext, +): Layer.Layer | undefined => registered?.httpLayer(get); + +/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */ +export const installedMockStatusStream = (): + | Atom.Atom + | undefined => registered?.statusStream; diff --git a/ui/src/mocks/scenario.ts b/ui/src/mocks/scenario.ts new file mode 100644 index 0000000..05e344a --- /dev/null +++ b/ui/src/mocks/scenario.ts @@ -0,0 +1,43 @@ +// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production +// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures. +import { Atom } from "effect/unstable/reactivity"; + +export type ApiMode = "mock" | "live"; + +/** The fixture worlds the mock transport can serve (Storybook picks one per story). */ +export type Scenario = "healthy" | "firstRun" | "syncing" | "errors"; + +/** + * Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and + * production builds hit the real API. Storybook seeds this atom to "mock" per registry. + */ +export const defaultApiMode: ApiMode = + import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live" + ? "mock" + : "live"; + +// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does +// a request-time registry.get) — without it, a registry-seeded value on a page that +// never subscribes them is collected and the read falls back to the default. +export const apiModeAtom: Atom.Writable = Atom.keepAlive( + Atom.make(defaultApiMode), +); + +const SCENARIOS: ReadonlyArray = [ + "healthy", + "firstRun", + "syncing", + "errors", +]; + +const isScenario = (value: unknown): value is Scenario => + SCENARIOS.includes(value as Scenario); + +/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */ +const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO) + ? import.meta.env.VITE_MOCK_SCENARIO + : "healthy"; + +export const scenarioAtom: Atom.Writable = Atom.keepAlive( + Atom.make(defaultScenario), +);