feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
- lib/error/: classify any caught error into a stable ErrorKind from the transport signal (HTTP status / Connect Code / fetch TypeError / navigator.onLine); map to translated error.* messages via reportError (sticky Retry toast for retryable kinds) or errorMessageKey (inline). Mail compose now surfaces the translated 403/error inline. - lib/ui/view-state.svelte: shared loading/empty/error placeholder with the right live-region role + optional action; entity tables (races/sciences/ship-classes) migrated, rest adopt incrementally. - map/selection-ring.ts: accent ring around the selected planet, fed into the map buildExtras alongside the reach circles. - lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal for the planet/ship-group bottom-sheets (hand-rolled pointer events). Tests: error, view-state, selection-ring, sheet-dismiss (761 total). Docs: ui/docs/error-state-ux.md (+ index); F4 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user