diff --git a/ui/PLAN-finalize.md b/ui/PLAN-finalize.md index efd3e37..523555d 100644 --- a/ui/PLAN-finalize.md +++ b/ui/PLAN-finalize.md @@ -91,7 +91,18 @@ Acceptance: no untranslated visible strings; missing-translation test is green; locale persists. Tests: Vitest i18n bundle-structure + missing-key detection. -## F4 — Error & state UX +## F4 — Error & state UX — done + +Central error surface `src/lib/error/` (classify any error into a stable +`ErrorKind` from the transport signal; map to translated `error.*` +messages via `reportError` toast — sticky + Retry for retryable kinds — +or `errorMessageKey` for inline). Shared `ViewState` placeholder +(loading/empty/error with a11y roles), adopted by the entity tables. +Selected-planet ring on the map (`map/selection-ring.ts`, fed into +`buildExtras`). Bottom-sheet tap-outside + swipe-down dismissal +(`lib/ui/sheet-dismiss.ts`, hand-rolled pointer events). Error-surface +and `ViewState` adoption continue incrementally across the remaining +views. Docs: `ui/docs/error-state-ux.md`. (From Phase 35, plus the deferred Phase 13/35 items.) Goal: consistent, actionable feedback everywhere. diff --git a/ui/docs/README.md b/ui/docs/README.md index 01650ca..626853d 100644 --- a/ui/docs/README.md +++ b/ui/docs/README.md @@ -15,6 +15,9 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in - [a11y.md](a11y.md) — the WCAG 2.2 AA approach: axe + keyboard test gates, the shared a11y primitives, coverage by area, and the map-canvas alternative. +- [error-state-ux.md](error-state-ux.md) — the central error surface + (taxonomy → translated/actionable messages), the shared `ViewState` + placeholder, the selected-planet ring, and bottom-sheet dismissal. - [navigation.md](navigation.md) — routes, the sidebar tabs, and the state-preservation rules across view/tab switches. - [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and diff --git a/ui/docs/error-state-ux.md b/ui/docs/error-state-ux.md new file mode 100644 index 0000000..b0cfd4b --- /dev/null +++ b/ui/docs/error-state-ux.md @@ -0,0 +1,51 @@ +# Error & state UX + +How the client gives consistent, actionable feedback for failures and for +loading / empty / error states. + +## Error surface (`src/lib/error/`) + +Server `result_code`s are downstream-opaque (only `"ok"` is contractual — +`docs/ARCHITECTURE.md`), so the client keys UX off the reliable +transport-level signal instead. + +- [`classify.ts`](../frontend/src/lib/error/classify.ts) collapses any + caught value into a stable `ErrorKind` (`offline`, `network`, `auth`, + `forbidden`, `conflict`, `notFound`, `rateLimit`, `server`, `unknown`) + from `navigator.onLine`, an `AuthError` HTTP status, a Connect `Code`, + or a fetch `TypeError`. `isRetryable` marks the transient kinds. +- [`report.ts`](../frontend/src/lib/error/report.ts) maps each kind to a + translated `error.*` message and either: + - `reportError(err, { onRetry? })` — raises a toast (a sticky toast + with a **Retry** action for a retryable kind that supplies `onRetry`, + otherwise auto-dismiss); or + - `errorMessageKey(err)` — returns the translated key for a view to + render the message inline (e.g. the mail compose 403). + +Messages live under `error.*` (en + ru); the action label is +`common.retry`. New call sites adopt this surface incrementally; the mail +compose dialog is the worked inline example. + +## View states (`src/lib/ui/view-state.svelte`) + +`` +is the one placeholder for a view or panel: a spinner for loading, an +accent action button when given, and the right live-region role +(`status` for loading/empty, `alert` for error). The entity tables +(races / sciences / ship-classes) use it; remaining views adopt it +incrementally. + +## Selected-planet marker (`src/map/selection-ring.ts`) + +When the `SelectionStore` holds a planet, the map draws one accent ring +tight around it (`computeSelectionRing`, fed into the map's `buildExtras` +alongside the reach circles). Ship-group selection is not ringed — groups +are addressed by report index and have no single stable map coordinate. + +## Mobile bottom-sheet dismissal (`src/lib/ui/sheet-dismiss.ts`) + +`use:sheetDismiss={{ onDismiss }}` adds two hand-rolled (no-dependency) +gestures to the planet / ship-group bottom-sheets: tap anywhere outside +the sheet, or drag its `[data-sheet-handle]` grabber down past a +threshold. The swipe starts only from the grabber so it never fights the +sheet's own content scrolling. diff --git a/ui/frontend/src/lib/active-view/mail/compose.svelte b/ui/frontend/src/lib/active-view/mail/compose.svelte index ae9f5f8..722623d 100644 --- a/ui/frontend/src/lib/active-view/mail/compose.svelte +++ b/ui/frontend/src/lib/active-view/mail/compose.svelte @@ -11,6 +11,7 @@ surfaces the resulting 403 inline. import { i18n } from "$lib/i18n/index.svelte"; import { trapFocus } from "$lib/a11y/focus-trap"; + import { errorMessageKey } from "$lib/error"; import { mailStore } from "$lib/mail-store.svelte"; import { RENDERED_REPORT_CONTEXT_KEY, @@ -82,7 +83,7 @@ surfaces the resulting 403 inline. await mailStore.composeBroadcast({ subject, body: bodyText }); onSent(null); } catch (err) { - error = err instanceof Error ? err.message : String(err); + error = i18n.t(errorMessageKey(err)); } finally { sending = false; } diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 04070b5..3ad8b9a 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -32,6 +32,7 @@ preference the store already manages. import { buildCargoRouteLines } from "../../map/cargo-routes"; import { buildPendingSendLines } from "../../map/pending-send-routes"; import { computeReachCircles } from "../../map/reach-circles"; + import { computeSelectionRing } from "../../map/selection-ring"; import { reachStore } from "$lib/calculator/reach.svelte"; import { reportToWorld, @@ -202,6 +203,8 @@ preference the store already manages. // redraw as the design or the selected planet changes. void reachStore.origin; void reachStore.speedPerTurn; + // Redraw the selected-planet ring when the selection changes. + void selection?.selected; // Phase 29 visibility derivation. Cargo routes and pending- // Send overlay are extras (no Pixi remount on flip); the @@ -231,9 +234,11 @@ preference the store already manages. reachOrigin === null ? "" : `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`; + const selectedPlanetId = + selection?.selected?.kind === "planet" ? selection.selected.id : null; const extrasFingerprint = `cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` + - `reach=${reachFingerprint}|` + + `reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` + computeRoutesFingerprint(report.routes) + "|" + computePendingSendFingerprint(draftCommands, draftStatuses); @@ -329,7 +334,15 @@ preference the store already manages. mode, ) : []; - return [...cargo, ...pending, ...reach]; + const selectedPlanetId = + selection?.selected?.kind === "planet" ? selection.selected.id : null; + const selectionRing = computeSelectionRing(report.planets, selectedPlanetId); + return [ + ...cargo, + ...pending, + ...reach, + ...(selectionRing === null ? [] : [selectionRing]), + ]; } function applyVisibilityState( diff --git a/ui/frontend/src/lib/active-view/table-races.svelte b/ui/frontend/src/lib/active-view/table-races.svelte index 936d012..61f1e91 100644 --- a/ui/frontend/src/lib/active-view/table-races.svelte +++ b/ui/frontend/src/lib/active-view/table-races.svelte @@ -34,6 +34,7 @@ data fetching is performed here — the layout is responsible. OrderDraftStore, } from "../../sync/order-draft.svelte"; import type { Relation } from "../../sync/order-types"; + import ViewState from "$lib/ui/view-state.svelte"; type SortColumn = | "name" @@ -227,13 +228,17 @@ data fetching is performed here — the layout is responsible. {#if !reportLoaded} -

- {i18n.t("game.table.races.loading")} -

+ {:else if races.length === 0} -

- {i18n.t("game.table.races.empty")} -

+ {:else} @@ -388,11 +393,6 @@ data fetching is performed here — the layout is responsible. flex: 1 1 12rem; min-width: 8rem; } - .status { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; - } .grid { border-collapse: collapse; width: 100%; diff --git a/ui/frontend/src/lib/active-view/table-sciences.svelte b/ui/frontend/src/lib/active-view/table-sciences.svelte index 6eb6898..516263f 100644 --- a/ui/frontend/src/lib/active-view/table-sciences.svelte +++ b/ui/frontend/src/lib/active-view/table-sciences.svelte @@ -23,6 +23,7 @@ data fetching is performed here — the layout is responsible. import type { ScienceSummary } from "../../api/game-state"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; + import ViewState from "$lib/ui/view-state.svelte"; import { RENDERED_REPORT_CONTEXT_KEY, type RenderedReportSource, @@ -160,13 +161,17 @@ data fetching is performed here — the layout is responsible. {#if !reportLoaded} -

- {i18n.t("game.table.sciences.loading")} -

+ {:else if localScience.length === 0} -

- {i18n.t("game.table.sciences.empty")} -

+ {:else}
@@ -271,11 +276,6 @@ data fetching is performed here — the layout is responsible. color: var(--color-text); border-color: var(--color-accent); } - .status { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; - } .grid { border-collapse: collapse; width: 100%; diff --git a/ui/frontend/src/lib/active-view/table-ship-classes.svelte b/ui/frontend/src/lib/active-view/table-ship-classes.svelte index 93c4bd5..0cb02af 100644 --- a/ui/frontend/src/lib/active-view/table-ship-classes.svelte +++ b/ui/frontend/src/lib/active-view/table-ship-classes.svelte @@ -26,6 +26,7 @@ data fetching is performed here — the layout is responsible. OrderDraftStore, } from "../../sync/order-draft.svelte"; import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte"; + import ViewState from "$lib/ui/view-state.svelte"; type SortColumn = | "name" @@ -154,13 +155,17 @@ data fetching is performed here — the layout is responsible. {#if !reportLoaded} -

- {i18n.t("game.table.ship_classes.loading")} -

+ {:else if localShipClass.length === 0} -

- {i18n.t("game.table.ship_classes.empty")} -

+ {:else}
@@ -262,11 +267,6 @@ data fetching is performed here — the layout is responsible. color: var(--color-text); border-color: var(--color-accent); } - .status { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; - } .grid { border-collapse: collapse; width: 100%; diff --git a/ui/frontend/src/lib/error/classify.ts b/ui/frontend/src/lib/error/classify.ts new file mode 100644 index 0000000..6644653 --- /dev/null +++ b/ui/frontend/src/lib/error/classify.ts @@ -0,0 +1,93 @@ +/** + * Error classification. + * + * Server `result_code`s are downstream-opaque (only `"ok"` is a stable + * contract — see `docs/ARCHITECTURE.md`), so the client cannot key UX off + * them. Instead every caught error is collapsed into a small, stable set + * of client-side kinds, derived from the transport-level signal that IS + * reliable: HTTP status (`AuthError`), the Connect `Code`, a fetch + * `TypeError`, or the live `navigator.onLine` flag. + */ + +import { ConnectError, Code } from "@connectrpc/connect"; +import { AuthError } from "../../api/auth"; + +/** A stable, user-facing classification of any caught error. */ +export type ErrorKind = + | "offline" + | "network" + | "auth" + | "forbidden" + | "conflict" + | "notFound" + | "rateLimit" + | "server" + | "unknown"; + +function fromHttpStatus(status: number): ErrorKind { + if (status === 401) return "auth"; + if (status === 403) return "forbidden"; + if (status === 404) return "notFound"; + if (status === 409) return "conflict"; + if (status === 429) return "rateLimit"; + if (status >= 500) return "server"; + return "unknown"; +} + +function fromConnectCode(code: Code): ErrorKind { + switch (code) { + case Code.Unauthenticated: + return "auth"; + case Code.PermissionDenied: + return "forbidden"; + case Code.NotFound: + return "notFound"; + case Code.AlreadyExists: + case Code.Aborted: + case Code.FailedPrecondition: + return "conflict"; + case Code.ResourceExhausted: + return "rateLimit"; + case Code.Unavailable: + return "network"; + case Code.Internal: + case Code.DataLoss: + case Code.Unknown: + return "server"; + default: + return "unknown"; + } +} + +function hasNumericStatus(err: unknown): err is { status: number } { + return ( + typeof err === "object" && + err !== null && + typeof (err as { status?: unknown }).status === "number" + ); +} + +/** Collapse any caught value into a stable {@link ErrorKind}. */ +export function classifyError(err: unknown): ErrorKind { + if (typeof navigator !== "undefined" && navigator.onLine === false) { + return "offline"; + } + if (err instanceof AuthError) return fromHttpStatus(err.status); + if (err instanceof ConnectError) return fromConnectCode(err.code); + if (err instanceof TypeError && /fetch|network|load failed/i.test(err.message)) { + return "network"; + } + if (hasNumericStatus(err)) return fromHttpStatus(err.status); + return "unknown"; +} + +const RETRYABLE: ReadonlySet = new Set([ + "offline", + "network", + "server", +]); + +/** Whether retrying the same operation could plausibly succeed. */ +export function isRetryable(kind: ErrorKind): boolean { + return RETRYABLE.has(kind); +} diff --git a/ui/frontend/src/lib/error/index.ts b/ui/frontend/src/lib/error/index.ts new file mode 100644 index 0000000..7183316 --- /dev/null +++ b/ui/frontend/src/lib/error/index.ts @@ -0,0 +1,8 @@ +/** + * Central error surface: classify caught errors into stable client kinds + * and turn them into translated, actionable feedback. See `classify.ts` + * for the taxonomy and `report.ts` for the toast/inline helpers. + */ + +export { classifyError, isRetryable, type ErrorKind } from "./classify"; +export { reportError, errorMessageKey, type ReportErrorOptions } from "./report"; diff --git a/ui/frontend/src/lib/error/report.ts b/ui/frontend/src/lib/error/report.ts new file mode 100644 index 0000000..dcb6fbb --- /dev/null +++ b/ui/frontend/src/lib/error/report.ts @@ -0,0 +1,57 @@ +/** + * Error reporting surface. + * + * Maps a classified {@link ErrorKind} to a translated, actionable message + * and either raises it as a toast (with a Retry action for retryable + * kinds) or hands the message key back for a view to render inline. + */ + +import { toast } from "../toast.svelte"; +import type { TranslationKey } from "../i18n/index.svelte"; +import { classifyError, isRetryable, type ErrorKind } from "./classify"; + +const MESSAGE_KEY: Record = { + offline: "error.offline", + network: "error.network", + auth: "error.auth", + forbidden: "error.forbidden", + conflict: "error.conflict", + notFound: "error.not_found", + rateLimit: "error.rate_limit", + server: "error.server", + unknown: "error.unknown", +}; + +/** The translated message key for an error, for inline rendering. */ +export function errorMessageKey(err: unknown): TranslationKey { + return MESSAGE_KEY[classifyError(err)]; +} + +export interface ReportErrorOptions { + /** + * Retry callback. When supplied and the error kind is retryable, the + * toast gains a Retry action and stays until dismissed; otherwise the + * toast auto-dismisses. + */ + onRetry?: () => void; +} + +/** + * Classify `err`, raise a translated toast, and return the kind. A + * retryable error with an `onRetry` handler shows a sticky toast with a + * Retry action; everything else auto-dismisses. + */ +export function reportError( + err: unknown, + options: ReportErrorOptions = {}, +): ErrorKind { + const kind = classifyError(err); + const withRetry = options.onRetry !== undefined && isRetryable(kind); + toast.show({ + messageKey: MESSAGE_KEY[kind], + actionLabelKey: withRetry ? "common.retry" : undefined, + onAction: withRetry ? options.onRetry : undefined, + durationMs: withRetry ? null : 8000, + }); + return kind; +} diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 4c503c9..6d68426 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -9,10 +9,23 @@ const en = { "common.loading": "loading…", "common.dismiss": "dismiss", "common.skip_to_content": "skip to main content", + "common.retry": "retry", "common.browser_not_supported_title": "browser not supported", "common.browser_not_supported_body": "Galaxy requires Ed25519 in WebCrypto. See supported browsers.", + "error.offline": + "You appear to be offline. Check your connection and try again.", + "error.network": "Couldn't reach the server. Please try again.", + "error.auth": "Your session has expired. Please sign in again.", + "error.forbidden": "You don't have permission to do that.", + "error.conflict": + "This changed since you loaded it. Refresh and try again.", + "error.not_found": "Not found — it may have been removed.", + "error.rate_limit": "Too many requests. Wait a moment and try again.", + "error.server": "The server ran into a problem. Please try again.", + "error.unknown": "Something went wrong.", + "game.events.turn_ready.message": "turn {turn} is ready", "game.events.turn_ready.action": "view now", "game.events.signature_failed": "verification failed, reconnecting…", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index c48bb41..7c71957 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -10,10 +10,23 @@ const ru: Record = { "common.loading": "загрузка…", "common.dismiss": "закрыть", "common.skip_to_content": "к основному содержимому", + "common.retry": "повторить", "common.browser_not_supported_title": "браузер не поддерживается", "common.browser_not_supported_body": "Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.", + "error.offline": + "Похоже, вы офлайн. Проверьте соединение и повторите попытку.", + "error.network": "Не удалось связаться с сервером. Повторите попытку.", + "error.auth": "Сессия истекла. Войдите снова.", + "error.forbidden": "Недостаточно прав для этого действия.", + "error.conflict": + "Данные изменились с момента загрузки. Обновите и повторите.", + "error.not_found": "Не найдено — возможно, удалено.", + "error.rate_limit": "Слишком много запросов. Подождите немного и повторите.", + "error.server": "На сервере произошла ошибка. Повторите попытку.", + "error.unknown": "Что-то пошло не так.", + "game.events.turn_ready.message": "ход {turn} готов", "game.events.turn_ready.action": "открыть", "game.events.signature_failed": "подпись повреждена, переподключение…", diff --git a/ui/frontend/src/lib/inspectors/planet-sheet.svelte b/ui/frontend/src/lib/inspectors/planet-sheet.svelte index 695cb18..3dbc780 100644 --- a/ui/frontend/src/lib/inspectors/planet-sheet.svelte +++ b/ui/frontend/src/lib/inspectors/planet-sheet.svelte @@ -20,6 +20,7 @@ dismiss from the IA section §6 land in Phase 35 polish. ShipClassSummary, } from "../../api/game-state"; import { i18n } from "$lib/i18n/index.svelte"; + import { sheetDismiss } from "$lib/ui/sheet-dismiss"; import Planet from "./planet.svelte"; type Props = { @@ -59,7 +60,9 @@ dismiss from the IA section §6 land in Phase 35 polish. class="sheet" aria-label={i18n.t("game.sidebar.tab.inspector")} data-testid="inspector-planet-sheet" + use:sheetDismiss={{ onDismiss: onClose }} > + + {/if} + + + diff --git a/ui/frontend/src/map/selection-ring.ts b/ui/frontend/src/map/selection-ring.ts new file mode 100644 index 0000000..641c72d --- /dev/null +++ b/ui/frontend/src/map/selection-ring.ts @@ -0,0 +1,47 @@ +// Selected-planet marker. When the SelectionStore holds a planet, the +// map draws one accent ring tight around it so the current selection is +// visible on the canvas itself (the inspector/sheet show the detail). +// Ship-group selection is intentionally not ringed here — groups are +// addressed by report index and have no single stable map coordinate. + +import type { CirclePrim } from "./world"; + +/** Planet marker radius in world units; mirrors `battle-markers.ts`. */ +const PLANET_RADIUS_WORLD = 6; +/** The ring sits just outside the marker (and the bombing ring at +3). */ +const SELECTION_RING_RADIUS = PLANET_RADIUS_WORLD + 4; + +export const SELECTION_RING_COLOR = 0x6d8cff; +/** High-bit prefix so the ring id never collides with planet numbers, + * route lines, reach rings (`0xb…`), or battle markers. */ +export const SELECTION_RING_ID = 0xc0000000; +/** Below interactive primitives so it never wins a click. */ +const SELECTION_RING_PRIORITY = 0; + +/** + * computeSelectionRing returns one ring primitive centred on the selected + * planet, or `null` when nothing (or a non-planet) is selected or the + * planet is absent from the current report. + */ +export function computeSelectionRing( + planets: ReadonlyArray<{ number: number; x: number; y: number }>, + selectedPlanetId: number | null, +): CirclePrim | null { + if (selectedPlanetId === null) return null; + const planet = planets.find((p) => p.number === selectedPlanetId); + if (planet === undefined) return null; + return { + kind: "circle", + id: SELECTION_RING_ID, + priority: SELECTION_RING_PRIORITY, + hitSlopPx: 0, + x: planet.x, + y: planet.y, + radius: SELECTION_RING_RADIUS, + style: { + strokeColor: SELECTION_RING_COLOR, + strokeAlpha: 0.95, + strokeWidthPx: 1.5, + }, + }; +} diff --git a/ui/frontend/tests/error.test.ts b/ui/frontend/tests/error.test.ts new file mode 100644 index 0000000..044c91f --- /dev/null +++ b/ui/frontend/tests/error.test.ts @@ -0,0 +1,114 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConnectError, Code } from "@connectrpc/connect"; + +import { AuthError } from "../src/api/auth"; +import { + classifyError, + isRetryable, + type ErrorKind, +} from "../src/lib/error/classify"; +import { errorMessageKey, reportError } from "../src/lib/error/report"; +import { toast } from "../src/lib/toast.svelte"; + +afterEach(() => { + toast.resetForTests(); + vi.restoreAllMocks(); +}); + +describe("classifyError", () => { + it("maps HTTP statuses (AuthError) to kinds", () => { + const at = (status: number) => + classifyError(new AuthError("x", "msg", status)); + expect(at(401)).toBe("auth"); + expect(at(403)).toBe("forbidden"); + expect(at(404)).toBe("notFound"); + expect(at(409)).toBe("conflict"); + expect(at(429)).toBe("rateLimit"); + expect(at(500)).toBe("server"); + expect(at(400)).toBe("unknown"); + }); + + it("maps Connect codes to kinds", () => { + const at = (code: Code) => classifyError(new ConnectError("msg", code)); + expect(at(Code.Unauthenticated)).toBe("auth"); + expect(at(Code.PermissionDenied)).toBe("forbidden"); + expect(at(Code.NotFound)).toBe("notFound"); + expect(at(Code.FailedPrecondition)).toBe("conflict"); + expect(at(Code.ResourceExhausted)).toBe("rateLimit"); + expect(at(Code.Unavailable)).toBe("network"); + expect(at(Code.Internal)).toBe("server"); + }); + + it("treats a fetch TypeError as a network error", () => { + expect(classifyError(new TypeError("Failed to fetch"))).toBe("network"); + }); + + it("duck-types any object with a numeric status", () => { + expect(classifyError({ status: 409 })).toBe("conflict"); + }); + + it("falls back to unknown for a plain error", () => { + expect(classifyError(new Error("boom"))).toBe("unknown"); + }); + + it("short-circuits to offline when the browser is offline", () => { + Object.defineProperty(navigator, "onLine", { + configurable: true, + value: false, + }); + try { + expect(classifyError(new AuthError("x", "m", 500))).toBe("offline"); + } finally { + Object.defineProperty(navigator, "onLine", { + configurable: true, + value: true, + }); + } + }); + + it("marks only transient kinds retryable", () => { + const retryable: ErrorKind[] = ["offline", "network", "server"]; + const notRetryable: ErrorKind[] = [ + "auth", + "forbidden", + "conflict", + "notFound", + "rateLimit", + "unknown", + ]; + expect(retryable.every(isRetryable)).toBe(true); + expect(notRetryable.some(isRetryable)).toBe(false); + }); +}); + +describe("reportError / errorMessageKey", () => { + it("returns the translated message key for inline use", () => { + expect(errorMessageKey(new AuthError("x", "m", 403))).toBe("error.forbidden"); + expect(errorMessageKey(new Error("?"))).toBe("error.unknown"); + }); + + it("raises an auto-dismiss toast for a non-retryable error", () => { + const kind = reportError(new AuthError("x", "m", 403)); + expect(kind).toBe("forbidden"); + expect(toast.current?.messageKey).toBe("error.forbidden"); + expect(toast.current?.actionLabelKey).toBeUndefined(); + expect(toast.current?.durationMs).toBe(8000); + }); + + it("raises a sticky Retry toast for a retryable error with onRetry", () => { + const onRetry = vi.fn(); + const kind = reportError(new TypeError("Failed to fetch"), { onRetry }); + expect(kind).toBe("network"); + expect(toast.current?.messageKey).toBe("error.network"); + expect(toast.current?.actionLabelKey).toBe("common.retry"); + expect(toast.current?.durationMs).toBeNull(); + toast.current?.onAction?.(); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("does not add a Retry action to a non-retryable error", () => { + reportError(new AuthError("x", "m", 403), { onRetry: vi.fn() }); + expect(toast.current?.actionLabelKey).toBeUndefined(); + }); +}); diff --git a/ui/frontend/tests/selection-ring.test.ts b/ui/frontend/tests/selection-ring.test.ts new file mode 100644 index 0000000..9a5819d --- /dev/null +++ b/ui/frontend/tests/selection-ring.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + computeSelectionRing, + SELECTION_RING_COLOR, + SELECTION_RING_ID, +} from "../src/map/selection-ring"; + +const planets = [ + { number: 1, x: 10, y: 20 }, + { number: 2, x: 30, y: 40 }, +]; + +describe("computeSelectionRing", () => { + it("returns null when nothing is selected", () => { + expect(computeSelectionRing(planets, null)).toBeNull(); + }); + + it("returns null when the selected planet is absent from the report", () => { + expect(computeSelectionRing(planets, 99)).toBeNull(); + }); + + it("rings the selected planet at its coordinates", () => { + const ring = computeSelectionRing(planets, 2); + expect(ring).toMatchObject({ + kind: "circle", + id: SELECTION_RING_ID, + x: 30, + y: 40, + hitSlopPx: 0, + }); + expect(ring?.style.strokeColor).toBe(SELECTION_RING_COLOR); + // Sits outside the planet marker (radius 6 world units). + expect(ring?.radius ?? 0).toBeGreaterThan(6); + }); +}); diff --git a/ui/frontend/tests/sheet-dismiss.test.ts b/ui/frontend/tests/sheet-dismiss.test.ts new file mode 100644 index 0000000..3c4ff66 --- /dev/null +++ b/ui/frontend/tests/sheet-dismiss.test.ts @@ -0,0 +1,52 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { sheetDismiss } from "../src/lib/ui/sheet-dismiss"; + +// Only the tap-outside path is unit-tested; the swipe-down drag is +// pointer-gesture behaviour covered by manual / e2e checks. +describe("sheetDismiss — tap outside", () => { + let cleanup: (() => void) | null = null; + + afterEach(() => { + cleanup?.(); + cleanup = null; + document.body.innerHTML = ""; + }); + + it("dismisses on a pointer-down outside the sheet", () => { + const node = document.createElement("div"); + const outside = document.createElement("button"); + document.body.append(node, outside); + const onDismiss = vi.fn(); + const action = sheetDismiss(node, { onDismiss }); + cleanup = action.destroy; + + outside.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("ignores a pointer-down inside the sheet", () => { + const node = document.createElement("div"); + const inner = document.createElement("button"); + node.append(inner); + document.body.append(node); + const onDismiss = vi.fn(); + const action = sheetDismiss(node, { onDismiss }); + cleanup = action.destroy; + + inner.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it("stops listening after destroy", () => { + const node = document.createElement("div"); + const outside = document.createElement("button"); + document.body.append(node, outside); + const onDismiss = vi.fn(); + sheetDismiss(node, { onDismiss }).destroy(); + + outside.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/frontend/tests/view-state.test.ts b/ui/frontend/tests/view-state.test.ts new file mode 100644 index 0000000..1d8689b --- /dev/null +++ b/ui/frontend/tests/view-state.test.ts @@ -0,0 +1,45 @@ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; + +import ViewState from "../src/lib/ui/view-state.svelte"; + +describe("ViewState", () => { + it("announces an error assertively (role=alert)", () => { + const ui = render(ViewState, { + props: { kind: "error", message: "it broke", testid: "vs" }, + }); + const el = ui.getByTestId("vs"); + expect(el).toHaveAttribute("role", "alert"); + expect(el).toHaveAttribute("data-kind", "error"); + expect(el).toHaveTextContent("it broke"); + }); + + it("announces loading politely (role=status)", () => { + const ui = render(ViewState, { + props: { kind: "loading", message: "loading…" }, + }); + expect(ui.getByRole("status")).toHaveTextContent("loading…"); + }); + + it("renders an action button that fires onAction", async () => { + const onAction = vi.fn(); + const ui = render(ViewState, { + props: { + kind: "error", + message: "failed", + actionLabel: "retry", + onAction, + }, + }); + await fireEvent.click(ui.getByText("retry")); + expect(onAction).toHaveBeenCalledOnce(); + }); + + it("omits the action button when no handler is given", () => { + const ui = render(ViewState, { + props: { kind: "empty", message: "nothing here" }, + }); + expect(ui.queryByRole("button")).toBeNull(); + }); +});