feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -108,36 +108,36 @@ describe("character actor route providers", () => {
});
it("recreates chat and chat payment actors when the character changes", () => {
renderChat("character_elio");
renderChat("elio");
const firstChatInstance = instanceId("chat-provider");
const firstPaymentInstance = instanceId("payment-provider");
renderChat("character_maya");
renderChat("maya-tan");
expect(instanceId("chat-provider")).not.toBe(firstChatInstance);
expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance);
expect(testNode("chat-provider").dataset.characterId).toBe(
"character_maya",
"maya-tan",
);
});
it("recreates the private-room actor when the character changes", () => {
renderPrivateRoom("character_elio");
renderPrivateRoom("elio");
const firstInstance = instanceId("private-room-provider");
renderPrivateRoom("character_maya");
renderPrivateRoom("maya-tan");
expect(instanceId("private-room-provider")).not.toBe(firstInstance);
expect(testNode("private-room-provider").dataset.characterId).toBe(
"character_maya",
"maya-tan",
);
});
it("recreates the route payment actor when its character scope changes", () => {
renderPayment("character_elio");
renderPayment("elio");
const firstInstance = instanceId("payment-provider");
renderPayment("character_maya");
renderPayment("maya-tan");
expect(instanceId("payment-provider")).not.toBe(firstInstance);
});
@@ -0,0 +1,83 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CHARACTERS, createCharacterCatalog } from "@/data/constants/character";
const mocks = vi.hoisted(() => ({
replace: vi.fn(),
catalog: null as unknown as ReturnType<
typeof import("@/providers/character-catalog-provider").useCharacterCatalog
>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mocks.replace }),
}));
vi.mock("@/providers/character-catalog-provider", () => ({
useCharacterCatalog: () => mocks.catalog,
}));
import { CharacterRouteBoundary } from "../character-route-boundary";
describe("CharacterRouteBoundary", () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mocks.replace.mockReset();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("waits for the catalog before mounting character content", () => {
mocks.catalog = catalogValue("loading", CHARACTERS);
act(() => {
root.render(
<CharacterRouteBoundary character={CHARACTERS[0]}>
<span>Chat actor mounted</span>
</CharacterRouteBoundary>,
);
});
expect(container.textContent).not.toContain("Chat actor mounted");
expect(container.querySelector('[role="status"]')).not.toBeNull();
});
it("redirects an unavailable character to the default splash", () => {
mocks.catalog = catalogValue("ready", [CHARACTERS[0]]);
act(() => {
root.render(
<CharacterRouteBoundary character={CHARACTERS[1]}>
<span>Maya</span>
</CharacterRouteBoundary>,
);
});
expect(mocks.replace).toHaveBeenCalledWith("/characters/elio/splash");
expect(container.textContent).not.toContain("Maya");
});
});
function catalogValue(
status: "loading" | "ready" | "refreshing",
characters: typeof CHARACTERS,
) {
const catalog = createCharacterCatalog(characters);
return {
...catalog,
status,
defaultCharacter: CHARACTERS[0],
isFallback: false,
refresh: async () => undefined,
};
}
+81 -8
View File
@@ -2,19 +2,36 @@
import {
createContext,
useCallback,
useEffect,
type ReactNode,
useContext,
useMemo,
useState,
} from "react";
import {
CHARACTERS,
createCharacterCatalog,
DEFAULT_CHARACTER,
mergeRemoteCharacterCatalog,
type CharacterCatalog,
type CharacterProfile,
} from "@/data/constants/character";
import { getCharacterRepository } from "@/data/repositories/character_repository";
import { AppEnvUtil } from "@/utils/app-env";
import { Result } from "@/utils/result";
const CharacterCatalogContext = createContext<CharacterCatalog | null>(null);
export type CharacterCatalogStatus = "loading" | "ready" | "refreshing";
export interface CharacterCatalogContextValue extends CharacterCatalog {
readonly status: CharacterCatalogStatus;
readonly defaultCharacter: CharacterProfile;
readonly isFallback: boolean;
refresh(): Promise<void>;
}
const CharacterCatalogContext =
createContext<CharacterCatalogContextValue | null>(null);
export interface CharacterCatalogProviderProps {
children: ReactNode;
@@ -25,18 +42,74 @@ export function CharacterCatalogProvider({
children,
characters = CHARACTERS,
}: CharacterCatalogProviderProps) {
const catalog = useMemo(
() => createCharacterCatalog(characters),
[characters],
);
const fallbackCharacters = AppEnvUtil.isProduction()
? characters.filter((character) => character.id === DEFAULT_CHARACTER.id)
: characters;
const [state, setState] = useState(() => ({
catalog: createCharacterCatalog(fallbackCharacters),
defaultCharacter: DEFAULT_CHARACTER,
status: "loading" as CharacterCatalogStatus,
isFallback: true,
hasRemoteSnapshot: false,
}));
const refresh = useCallback(async () => {
setState((current) => ({
...current,
status: current.hasRemoteSnapshot ? "refreshing" : "loading",
}));
const result = await getCharacterRepository().getChatCharacters();
if (Result.isErr(result)) {
setState((current) => ({ ...current, status: "ready" }));
return;
}
const snapshot = mergeRemoteCharacterCatalog(result.data, characters);
setState({
...snapshot,
status: "ready",
isFallback: false,
hasRemoteSnapshot: true,
});
}, [characters]);
useEffect(() => {
let cancelled = false;
void getCharacterRepository()
.getChatCharacters()
.then((result) => {
if (cancelled) return;
if (Result.isErr(result)) {
setState((current) => ({ ...current, status: "ready" }));
return;
}
const snapshot = mergeRemoteCharacterCatalog(result.data, characters);
setState({
...snapshot,
status: "ready",
isFallback: false,
hasRemoteSnapshot: true,
});
});
return () => {
cancelled = true;
};
}, [characters]);
const value: CharacterCatalogContextValue = {
...state.catalog,
status: state.status,
defaultCharacter: state.defaultCharacter,
isFallback: state.isFallback,
refresh,
};
return (
<CharacterCatalogContext.Provider value={catalog}>
<CharacterCatalogContext.Provider value={value}>
{children}
</CharacterCatalogContext.Provider>
);
}
export function useCharacterCatalog(): CharacterCatalog {
export function useCharacterCatalog(): CharacterCatalogContextValue {
const catalog = useContext(CharacterCatalogContext);
if (!catalog) {
throw new Error(
@@ -0,0 +1,45 @@
"use client";
import { type ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { CharacterProfile } from "@/data/constants/character";
import { getCharacterRoutes } from "@/router/routes";
import { useCharacterCatalog } from "./character-catalog-provider";
import { CharacterProvider } from "./character-provider";
export function CharacterRouteBoundary({
character,
children,
}: {
character: CharacterProfile;
children: ReactNode;
}) {
const catalog = useCharacterCatalog();
const router = useRouter();
const activeCharacter = catalog.getById(character.id);
useEffect(() => {
if (catalog.status === "loading" || activeCharacter) return;
router.replace(getCharacterRoutes(catalog.defaultCharacter.slug).splash);
}, [activeCharacter, catalog.defaultCharacter.slug, catalog.status, router]);
if (catalog.status === "loading" || !activeCharacter) {
return (
<div
className="flex min-h-dvh flex-1 items-center justify-center bg-[#fbf1f2]"
aria-label="Loading character"
role="status"
>
<div className="size-9 animate-spin rounded-full border-4 border-[#f657a0] border-t-transparent" />
</div>
);
}
return (
<CharacterProvider character={activeCharacter}>
{children}
</CharacterProvider>
);
}