feat(ui): error & state UX (F4) #29
+12
-1
@@ -91,7 +91,18 @@ Acceptance: no untranslated visible strings; missing-translation test is
|
|||||||
green; locale persists. Tests: Vitest i18n bundle-structure +
|
green; locale persists. Tests: Vitest i18n bundle-structure +
|
||||||
missing-key detection.
|
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,
|
(From Phase 35, plus the deferred Phase 13/35 items.) Goal: consistent,
|
||||||
actionable feedback everywhere.
|
actionable feedback everywhere.
|
||||||
|
|||||||
@@ -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
|
- [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
|
gates, the shared a11y primitives, coverage by area, and the map-canvas
|
||||||
alternative.
|
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
|
- [navigation.md](navigation.md) — routes, the sidebar tabs, and the
|
||||||
state-preservation rules across view/tab switches.
|
state-preservation rules across view/tab switches.
|
||||||
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
|
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
`<ViewState kind="loading|empty|error" message=… [actionLabel onAction] />`
|
||||||
|
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.
|
||||||
@@ -11,6 +11,7 @@ surfaces the resulting 403 inline.
|
|||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { trapFocus } from "$lib/a11y/focus-trap";
|
import { trapFocus } from "$lib/a11y/focus-trap";
|
||||||
|
import { errorMessageKey } from "$lib/error";
|
||||||
import { mailStore } from "$lib/mail-store.svelte";
|
import { mailStore } from "$lib/mail-store.svelte";
|
||||||
import {
|
import {
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
@@ -82,7 +83,7 @@ surfaces the resulting 403 inline.
|
|||||||
await mailStore.composeBroadcast({ subject, body: bodyText });
|
await mailStore.composeBroadcast({ subject, body: bodyText });
|
||||||
onSent(null);
|
onSent(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = i18n.t(errorMessageKey(err));
|
||||||
} finally {
|
} finally {
|
||||||
sending = false;
|
sending = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ preference the store already manages.
|
|||||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||||
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||||
import { computeReachCircles } from "../../map/reach-circles";
|
import { computeReachCircles } from "../../map/reach-circles";
|
||||||
|
import { computeSelectionRing } from "../../map/selection-ring";
|
||||||
import { reachStore } from "$lib/calculator/reach.svelte";
|
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||||
import {
|
import {
|
||||||
reportToWorld,
|
reportToWorld,
|
||||||
@@ -202,6 +203,8 @@ preference the store already manages.
|
|||||||
// redraw as the design or the selected planet changes.
|
// redraw as the design or the selected planet changes.
|
||||||
void reachStore.origin;
|
void reachStore.origin;
|
||||||
void reachStore.speedPerTurn;
|
void reachStore.speedPerTurn;
|
||||||
|
// Redraw the selected-planet ring when the selection changes.
|
||||||
|
void selection?.selected;
|
||||||
|
|
||||||
// Phase 29 visibility derivation. Cargo routes and pending-
|
// Phase 29 visibility derivation. Cargo routes and pending-
|
||||||
// Send overlay are extras (no Pixi remount on flip); the
|
// Send overlay are extras (no Pixi remount on flip); the
|
||||||
@@ -231,9 +234,11 @@ preference the store already manages.
|
|||||||
reachOrigin === null
|
reachOrigin === null
|
||||||
? ""
|
? ""
|
||||||
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
||||||
|
const selectedPlanetId =
|
||||||
|
selection?.selected?.kind === "planet" ? selection.selected.id : null;
|
||||||
const extrasFingerprint =
|
const extrasFingerprint =
|
||||||
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
||||||
`reach=${reachFingerprint}|` +
|
`reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` +
|
||||||
computeRoutesFingerprint(report.routes) +
|
computeRoutesFingerprint(report.routes) +
|
||||||
"|" +
|
"|" +
|
||||||
computePendingSendFingerprint(draftCommands, draftStatuses);
|
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||||
@@ -329,7 +334,15 @@ preference the store already manages.
|
|||||||
mode,
|
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(
|
function applyVisibilityState(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ data fetching is performed here — the layout is responsible.
|
|||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
import type { Relation } from "../../sync/order-types";
|
import type { Relation } from "../../sync/order-types";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
|
||||||
type SortColumn =
|
type SortColumn =
|
||||||
| "name"
|
| "name"
|
||||||
@@ -227,13 +228,17 @@ data fetching is performed here — the layout is responsible.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !reportLoaded}
|
{#if !reportLoaded}
|
||||||
<p class="status" data-testid="races-loading">
|
<ViewState
|
||||||
{i18n.t("game.table.races.loading")}
|
kind="loading"
|
||||||
</p>
|
testid="races-loading"
|
||||||
|
message={i18n.t("game.table.races.loading")}
|
||||||
|
/>
|
||||||
{:else if races.length === 0}
|
{:else if races.length === 0}
|
||||||
<p class="status" data-testid="races-empty">
|
<ViewState
|
||||||
{i18n.t("game.table.races.empty")}
|
kind="empty"
|
||||||
</p>
|
testid="races-empty"
|
||||||
|
message={i18n.t("game.table.races.empty")}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="grid" data-testid="races-table">
|
<table class="grid" data-testid="races-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -388,11 +393,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
.status {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ data fetching is performed here — the layout is responsible.
|
|||||||
|
|
||||||
import type { ScienceSummary } from "../../api/game-state";
|
import type { ScienceSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
import {
|
import {
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
type RenderedReportSource,
|
type RenderedReportSource,
|
||||||
@@ -160,13 +161,17 @@ data fetching is performed here — the layout is responsible.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !reportLoaded}
|
{#if !reportLoaded}
|
||||||
<p class="status" data-testid="sciences-loading">
|
<ViewState
|
||||||
{i18n.t("game.table.sciences.loading")}
|
kind="loading"
|
||||||
</p>
|
testid="sciences-loading"
|
||||||
|
message={i18n.t("game.table.sciences.loading")}
|
||||||
|
/>
|
||||||
{:else if localScience.length === 0}
|
{:else if localScience.length === 0}
|
||||||
<p class="status" data-testid="sciences-empty">
|
<ViewState
|
||||||
{i18n.t("game.table.sciences.empty")}
|
kind="empty"
|
||||||
</p>
|
testid="sciences-empty"
|
||||||
|
message={i18n.t("game.table.sciences.empty")}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="grid" data-testid="sciences-table">
|
<table class="grid" data-testid="sciences-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -271,11 +276,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.status {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ data fetching is performed here — the layout is responsible.
|
|||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
|
||||||
type SortColumn =
|
type SortColumn =
|
||||||
| "name"
|
| "name"
|
||||||
@@ -154,13 +155,17 @@ data fetching is performed here — the layout is responsible.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !reportLoaded}
|
{#if !reportLoaded}
|
||||||
<p class="status" data-testid="ship-classes-loading">
|
<ViewState
|
||||||
{i18n.t("game.table.ship_classes.loading")}
|
kind="loading"
|
||||||
</p>
|
testid="ship-classes-loading"
|
||||||
|
message={i18n.t("game.table.ship_classes.loading")}
|
||||||
|
/>
|
||||||
{:else if localShipClass.length === 0}
|
{:else if localShipClass.length === 0}
|
||||||
<p class="status" data-testid="ship-classes-empty">
|
<ViewState
|
||||||
{i18n.t("game.table.ship_classes.empty")}
|
kind="empty"
|
||||||
</p>
|
testid="ship-classes-empty"
|
||||||
|
message={i18n.t("game.table.ship_classes.empty")}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="grid" data-testid="ship-classes-table">
|
<table class="grid" data-testid="ship-classes-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -262,11 +267,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.status {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -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<ErrorKind> = new Set<ErrorKind>([
|
||||||
|
"offline",
|
||||||
|
"network",
|
||||||
|
"server",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Whether retrying the same operation could plausibly succeed. */
|
||||||
|
export function isRetryable(kind: ErrorKind): boolean {
|
||||||
|
return RETRYABLE.has(kind);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<ErrorKind, TranslationKey> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -9,10 +9,23 @@ const en = {
|
|||||||
"common.loading": "loading…",
|
"common.loading": "loading…",
|
||||||
"common.dismiss": "dismiss",
|
"common.dismiss": "dismiss",
|
||||||
"common.skip_to_content": "skip to main content",
|
"common.skip_to_content": "skip to main content",
|
||||||
|
"common.retry": "retry",
|
||||||
"common.browser_not_supported_title": "browser not supported",
|
"common.browser_not_supported_title": "browser not supported",
|
||||||
"common.browser_not_supported_body":
|
"common.browser_not_supported_body":
|
||||||
"Galaxy requires Ed25519 in WebCrypto. See supported browsers.",
|
"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.message": "turn {turn} is ready",
|
||||||
"game.events.turn_ready.action": "view now",
|
"game.events.turn_ready.action": "view now",
|
||||||
"game.events.signature_failed": "verification failed, reconnecting…",
|
"game.events.signature_failed": "verification failed, reconnecting…",
|
||||||
|
|||||||
@@ -10,10 +10,23 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"common.loading": "загрузка…",
|
"common.loading": "загрузка…",
|
||||||
"common.dismiss": "закрыть",
|
"common.dismiss": "закрыть",
|
||||||
"common.skip_to_content": "к основному содержимому",
|
"common.skip_to_content": "к основному содержимому",
|
||||||
|
"common.retry": "повторить",
|
||||||
"common.browser_not_supported_title": "браузер не поддерживается",
|
"common.browser_not_supported_title": "браузер не поддерживается",
|
||||||
"common.browser_not_supported_body":
|
"common.browser_not_supported_body":
|
||||||
"Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.",
|
"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.message": "ход {turn} готов",
|
||||||
"game.events.turn_ready.action": "открыть",
|
"game.events.turn_ready.action": "открыть",
|
||||||
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
} from "../../api/game-state";
|
} from "../../api/game-state";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { sheetDismiss } from "$lib/ui/sheet-dismiss";
|
||||||
import Planet from "./planet.svelte";
|
import Planet from "./planet.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -59,7 +60,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
class="sheet"
|
class="sheet"
|
||||||
aria-label={i18n.t("game.sidebar.tab.inspector")}
|
aria-label={i18n.t("game.sidebar.tab.inspector")}
|
||||||
data-testid="inspector-planet-sheet"
|
data-testid="inspector-planet-sheet"
|
||||||
|
use:sheetDismiss={{ onDismiss: onClose }}
|
||||||
>
|
>
|
||||||
|
<div class="grabber" data-sheet-handle aria-hidden="true"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="close"
|
class="close"
|
||||||
@@ -105,6 +108,16 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.grabber {
|
||||||
|
display: block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
margin: 0.4rem auto 0.2rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-border-strong);
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
.close {
|
.close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.4rem;
|
top: 0.4rem;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mounted by the in-game shell layout only while the active tool is
|
|||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
} from "../../api/game-state";
|
} from "../../api/game-state";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { sheetDismiss } from "$lib/ui/sheet-dismiss";
|
||||||
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
|
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -51,7 +52,9 @@ mounted by the in-game shell layout only while the active tool is
|
|||||||
class="sheet"
|
class="sheet"
|
||||||
aria-label={i18n.t("game.sidebar.tab.inspector")}
|
aria-label={i18n.t("game.sidebar.tab.inspector")}
|
||||||
data-testid="inspector-ship-group-sheet"
|
data-testid="inspector-ship-group-sheet"
|
||||||
|
use:sheetDismiss={{ onDismiss: onClose }}
|
||||||
>
|
>
|
||||||
|
<div class="grabber" data-sheet-handle aria-hidden="true"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="close"
|
class="close"
|
||||||
@@ -97,6 +100,16 @@ mounted by the in-game shell layout only while the active tool is
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.grabber {
|
||||||
|
display: block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
margin: 0.4rem auto 0.2rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-border-strong);
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
.close {
|
.close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.4rem;
|
top: 0.4rem;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Mobile bottom-sheet dismissal gestures, hand-rolled on pointer events
|
||||||
|
* (no dependency). A Svelte action for the sheet element:
|
||||||
|
*
|
||||||
|
* - **tap-outside** — a pointer-down anywhere outside the sheet dismisses
|
||||||
|
* it (the sheet is non-modal, so this is a convenience, not a guard);
|
||||||
|
* - **swipe-down** — dragging the sheet's `[data-sheet-handle]` grabber
|
||||||
|
* down past a threshold dismisses it. The swipe starts only from the
|
||||||
|
* handle, so it never fights the sheet's own content scrolling.
|
||||||
|
*
|
||||||
|
* `onDismiss` is re-read on `update`, so a changing handler stays current.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SheetDismissOptions {
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downward drag distance (px) past which release dismisses the sheet. */
|
||||||
|
const SWIPE_DISMISS_PX = 80;
|
||||||
|
|
||||||
|
export function sheetDismiss(
|
||||||
|
node: HTMLElement,
|
||||||
|
options: SheetDismissOptions,
|
||||||
|
): { update(next: SheetDismissOptions): void; destroy(): void } {
|
||||||
|
let opts = options;
|
||||||
|
const handle = node.querySelector<HTMLElement>("[data-sheet-handle]") ?? node;
|
||||||
|
|
||||||
|
function onDocumentPointerDown(event: PointerEvent): void {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && node.contains(target)) return;
|
||||||
|
opts.onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let startY = 0;
|
||||||
|
let pointerId = -1;
|
||||||
|
|
||||||
|
function onHandleDown(event: PointerEvent): void {
|
||||||
|
dragging = true;
|
||||||
|
startY = event.clientY;
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent): void {
|
||||||
|
if (!dragging || event.pointerId !== pointerId) return;
|
||||||
|
const dy = Math.max(0, event.clientY - startY);
|
||||||
|
node.style.transform = dy > 0 ? `translateY(${dy}px)` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent): void {
|
||||||
|
if (!dragging || event.pointerId !== pointerId) return;
|
||||||
|
const dy = event.clientY - startY;
|
||||||
|
dragging = false;
|
||||||
|
node.style.transform = "";
|
||||||
|
if (dy > SWIPE_DISMISS_PX) opts.onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
||||||
|
handle.addEventListener("pointerdown", onHandleDown);
|
||||||
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
window.addEventListener("pointercancel", onPointerUp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: SheetDismissOptions): void {
|
||||||
|
opts = next;
|
||||||
|
},
|
||||||
|
destroy(): void {
|
||||||
|
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
|
||||||
|
handle.removeEventListener("pointerdown", onHandleDown);
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
|
window.removeEventListener("pointercancel", onPointerUp);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!--
|
||||||
|
Shared loading / empty / error placeholder for a view or panel. Gives
|
||||||
|
every surface one consistent treatment and a11y semantics:
|
||||||
|
|
||||||
|
- `loading` and `empty` announce politely (`role="status"`);
|
||||||
|
- `error` announces assertively (`role="alert"`) and may carry a retry /
|
||||||
|
recovery action.
|
||||||
|
|
||||||
|
Callers pass an already-translated `message` (and `actionLabel`); the
|
||||||
|
component owns layout, the spinner, and the live-region role only.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
kind: "loading" | "empty" | "error";
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
testid?: string;
|
||||||
|
};
|
||||||
|
let { kind, message, actionLabel, onAction, testid }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="view-state {kind}"
|
||||||
|
data-testid={testid}
|
||||||
|
data-kind={kind}
|
||||||
|
role={kind === "error" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
{#if kind === "loading"}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
{#if actionLabel !== undefined && onAction !== undefined}
|
||||||
|
<button type="button" class="action" onclick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.view-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 32rem;
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
.view-state.error .message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
font: inherit;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
animation: view-state-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.spinner {
|
||||||
|
animation-duration: 2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes view-state-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user