feat: mii edit UX, a11y fixes, and frontend unit tests

- API: return success message on mii edit
- FE: flash banner + helpers, submit response helper, Vitest
- A11y: labels, alt text, banner close; css checkerboard class
- DX: root tsconfig refs, strict/tsconfig, next dev --webpack

Made-with: Cursor
This commit is contained in:
Lepre-CHAU-n 2026-04-29 00:25:17 -07:00
parent 7e10cef0b8
commit 13b2bef574
21 changed files with 1417 additions and 53 deletions

View file

@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
deriveEditSuccessFlashFromLocationState,
errorMessageAfterJsonFailure,
interpretMiiEditResponse,
} from "./mii-edit-notifications";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("interpretMiiEditResponse", () => {
it("returns success navigation with server message for the green banner", () => {
const body = { success: true, message: "Mii updated successfully." };
const response = jsonResponse(200, body);
const out = interpretMiiEditResponse(response, body, 42);
expect(out).toEqual({
kind: "success",
navigate: {
path: "/mii/42",
state: { miiEdited: true, miiEditedMessage: "Mii updated successfully." },
},
});
});
it("returns success without miiEditedMessage when message is missing (default copy on Mii page)", () => {
const body = { success: true };
const response = jsonResponse(200, body);
const out = interpretMiiEditResponse(response, body, 7);
expect(out).toEqual({
kind: "success",
navigate: {
path: "/mii/7",
state: { miiEdited: true },
},
});
});
it("returns API error message on non-OK response", () => {
const body = { error: "Nothing was changed" };
const response = jsonResponse(400, body);
const out = interpretMiiEditResponse(response, body, 1);
expect(out).toEqual({ kind: "failure", error: "Nothing was changed" });
});
it("uses status fallback when OK but success is false", () => {
const response = jsonResponse(200, { success: false, error: "bad" });
const out = interpretMiiEditResponse(response, { success: false, error: "bad" }, 2);
expect(out).toEqual({ kind: "failure", error: "bad" });
});
it("uses status fallback when OK, success false, and error is not a string", () => {
const response = jsonResponse(200, { success: false });
const out = interpretMiiEditResponse(response, { success: false }, 3);
expect(out).toEqual({ kind: "failure", error: "Could not save changes. Please try again." });
});
it("formats error when non-OK and error field is missing", () => {
const response = jsonResponse(500, {});
const out = interpretMiiEditResponse(response, {}, 9);
expect(out).toEqual({ kind: "failure", error: "Could not save changes (500). Please try again." });
});
it("429 rate limit preserves string message from API", () => {
const body = { error: "Rate limit exceeded. Please try again later." };
const response = jsonResponse(429, body);
expect(interpretMiiEditResponse(response, body, 1)).toEqual({
kind: "failure",
error: "Rate limit exceeded. Please try again later.",
});
});
it("non-OK converts non-string error via String()", () => {
const payload = { error: { nest: true } };
const response = jsonResponse(400, payload);
expect(interpretMiiEditResponse(response, payload as unknown, 1)).toEqual({
kind: "failure",
error: "[object Object]",
});
});
it("OK with success missing is treated like success false", () => {
const response = jsonResponse(200, {});
expect(interpretMiiEditResponse(response, {}, 55)).toEqual({
kind: "failure",
error: "Could not save changes. Please try again.",
});
});
it('empty-string message keys are kept (shows empty banner vs default — contract test)', () => {
const body = { success: true, message: "" };
const response = jsonResponse(200, body);
expect(interpretMiiEditResponse(response, body, 2)).toEqual({
kind: "success",
navigate: {
path: "/mii/2",
state: { miiEdited: true, miiEditedMessage: "" },
},
});
});
it("numeric message field is ignored (not a banner string)", () => {
const body = { success: true, message: 12345 };
const response = jsonResponse(200, body as unknown as object);
expect(interpretMiiEditResponse(response, body, 3)).toEqual({
kind: "success",
navigate: { path: "/mii/3", state: { miiEdited: true } },
});
});
it("uses large mii id in path without mangling", () => {
const body = { success: true };
const response = jsonResponse(200, body);
expect(interpretMiiEditResponse(response, body, 9_876_543)).toEqual({
kind: "success",
navigate: { path: "/mii/9876543", state: { miiEdited: true } },
});
});
});
describe("errorMessageAfterJsonFailure", () => {
it("invalid JSON when response was OK", () => {
const r = new Response("not json", { status: 200 });
expect(errorMessageAfterJsonFailure(r)).toBe("Invalid response from server.");
});
it("invalid JSON when response was error", () => {
const r = new Response("<html>", { status: 502 });
expect(errorMessageAfterJsonFailure(r)).toBe("Something went wrong (502). Please try again.");
});
});
describe("deriveEditSuccessFlashFromLocationState", () => {
it("shows banner with custom message after navigate from edit", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: true,
miiEditedMessage: "Mii updated successfully.",
}),
).toEqual({ show: true, message: "Mii updated successfully." });
});
it("shows banner with undefined message (UI uses default string)", () => {
expect(deriveEditSuccessFlashFromLocationState({ miiEdited: true })).toEqual({
show: true,
message: undefined,
});
});
it("hides banner when navigation has no edit flag", () => {
expect(deriveEditSuccessFlashFromLocationState(null)).toEqual({ show: false });
expect(deriveEditSuccessFlashFromLocationState(undefined)).toEqual({ show: false });
expect(deriveEditSuccessFlashFromLocationState({})).toEqual({ show: false });
});
it("hides banner when miiEdited is explicitly false", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: false,
miiEditedMessage: "ignored",
}),
).toEqual({ show: false });
});
it("ignores non-string miiEditedMessage in location state", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: true,
miiEditedMessage: 123 as unknown as string,
}),
).toEqual({ show: true, message: undefined });
});
});

View file

@ -0,0 +1,74 @@
/**
* Pure helpers for POST /api/mii/:id/edit responses and Router state for the edit-success banner.
* Kept dependency-free so they are easy to unit test.
*/
export type MiiEditApiBody = {
success?: boolean;
error?: unknown;
message?: unknown;
};
export type NavigateAfterEditState = {
miiEdited: boolean;
miiEditedMessage?: string;
};
/** What to render on the Mii page from Router location.state (edit success navigation). */
export function deriveEditSuccessFlashFromLocationState(locationState: unknown): { show: boolean; message?: string } {
const s = locationState as { miiEdited?: boolean; miiEditedMessage?: string } | null;
return s?.miiEdited ? { show: true, message: typeof s.miiEditedMessage === "string" ? s.miiEditedMessage : undefined } : { show: false };
}
/** Error string when fetch().json() throws but we already received a Response. */
export function errorMessageAfterJsonFailure(response: Response): string {
return response.ok ? "Invalid response from server." : `Something went wrong (${response.status}). Please try again.`;
}
/** Result after a successful JSON body parse — either show error on edit page or navigate with flash state. */
export type InterpretMiiEditOutcome =
| { kind: "failure"; error: string }
| {
kind: "success";
navigate: {
path: string;
state: NavigateAfterEditState;
};
};
/**
* Maps HTTP status + parsed JSON from the edit endpoint to either an inline error string
* or the navigation payload used after a successful save (including Router state for the green banner).
*/
export function interpretMiiEditResponse(response: Response, data: unknown, miiId: number): InterpretMiiEditOutcome {
const body = data as MiiEditApiBody;
if (!response.ok) {
const msg =
typeof body.error === "string"
? body.error
: body.error !== undefined
? String(body.error)
: `Could not save changes (${response.status}). Please try again.`;
return { kind: "failure", error: msg };
}
if (!body.success) {
const err = typeof body.error === "string" ? body.error : "Could not save changes. Please try again.";
return { kind: "failure", error: err };
}
const message = typeof body.message === "string" ? body.message : undefined;
const state: NavigateAfterEditState = {
miiEdited: true,
...(message !== undefined ? { miiEditedMessage: message } : {}),
};
return {
kind: "success",
navigate: {
path: `/mii/${miiId}`,
state,
},
};
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { interpretSubmitResponse } from "./submit-response";
describe("interpretSubmitResponse", () => {
it("success returns numeric mii id", () => {
const response = jsonResponse(200, { id: 99 });
expect(interpretSubmitResponse(response, { id: 99 })).toEqual({ kind: "success", miiId: 99 });
});
it("failure forwards string error when not ok", () => {
const response = jsonResponse(503, { error: "Submissions are temporarily disabled" });
expect(interpretSubmitResponse(response, { error: "Submissions are temporarily disabled" })).toEqual({
kind: "failure",
error: "Submissions are temporarily disabled",
});
});
it("failure mirrors String(undefined) when not ok and error missing", () => {
const response = jsonResponse(400, {});
expect(interpretSubmitResponse(response, {}).error).toBe("undefined");
});
it("invalid id shape on OK response is rejected", () => {
const response = jsonResponse(200, { id: "not-a-number" });
expect(interpretSubmitResponse(response, { id: "not-a-number" })).toEqual({
kind: "failure",
error: "Invalid response from server.",
});
});
it("non-OK with object error matches String semantics", () => {
const errObj = { field: "x" };
const response = jsonResponse(422, { error: errObj });
expect(interpretSubmitResponse(response, { error: errObj })).toEqual({
kind: "failure",
error: String(errObj),
});
});
it("truncates float id when server returned a whole number coerced oddly", () => {
const response = jsonResponse(200, { id: 12.0 });
expect(interpretSubmitResponse(response, { id: 12 })).toEqual({ kind: "success", miiId: 12 });
});
});
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

View file

@ -0,0 +1,30 @@
/**
* Pure helper for POST /api/submit responses (new Mii ID or error).
* Unit-testable without running the Next server.
*/
export type SubmitApiBody = {
id?: unknown;
error?: unknown;
};
export type InterpretSubmitOutcome =
| { kind: "failure"; error: string }
| { kind: "success"; miiId: number };
/**
* Mirrors the submit page: `error` destructured from JSON `String(error)` includes `"undefined"` if missing.
*/
export function interpretSubmitResponse(response: Response, data: unknown): InterpretSubmitOutcome {
const body = data as SubmitApiBody;
if (!response.ok) {
return { kind: "failure", error: String(body.error) };
}
if (typeof body.id !== "number" || !Number.isFinite(body.id)) {
return { kind: "failure", error: "Invalid response from server." };
}
return { kind: "success", miiId: Math.trunc(body.id) };
}