feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user