feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持

This commit is contained in:
ddCat
2026-09-21 14:11:29 +08:00
parent b8eefdad70
commit c5b81f696c
40 changed files with 1668 additions and 59 deletions

View File

@@ -5,6 +5,13 @@ import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosi
const viewport = { width: 1280, height: 800 };
const start = { right: 20, bottom: 20 };
test("Live2D bounds use actual rectangular dimensions when restoring and dragging", () => {
const size = { width: 180, height: 240 };
const small = { width: 320, height: 400 };
expect(restoreAgentLauncherPosition('{"right":999,"bottom":999}', small, size)).toEqual({ right: 120, bottom: 140 });
expect(moveAgentLauncher({ start, x: 0, y: 0, dragged: true }, -999, -999, small, size).position).toEqual({ right: 120, bottom: 140 });
});
test("launcher restores a valid position and defaults safely for corrupt preferences", () => {
expect(restoreAgentLauncherPosition('{"right":240,"bottom":180}', viewport)).toEqual({ right: 240, bottom: 180 });
for (const raw of [null, "invalid", "null", "{}", '{"right":"20","bottom":20}', '{"right":1e999,"bottom":20}']) {

View File

@@ -4,11 +4,12 @@ import { AgentWelcome } from "@/components/canvas/canvas-agent-welcome";
import { AgentChatComposer } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { agentPermissionVisual } from "@/components/canvas/canvas-cloud-agent-settings";
import { canvasThemes } from "@/lib/canvas-theme";
import { DEFAULT_CANVAS_APPEARANCE, agentCopy } from "@/lib/canvas/agent-appearance";
const noop = () => {};
test("welcome uses the configured brand and escapes it as text", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="我的<工作室>" nodeCount={2} onChooseSkill={noop} onDraftPrompt={noop} />);
test("welcome uses the independent assistant name and escapes it as text", () => {
const html = renderToStaticMarkup(<AgentWelcome appearance={{ ...DEFAULT_CANVAS_APPEARANCE, agentName: "我的<工作室>" }} nodeCount={2} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html).toContain("在这里,和我的&lt;工作室&gt;让灵感,慢慢成形");
expect(html).toContain("2 个节点");
expect(html.match(/type="button"/g)).toHaveLength(3);
@@ -16,12 +17,16 @@ test("welcome uses the configured brand and escapes it as text", () => {
});
test("empty canvas disables only the canvas-analysis shortcut", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="影策" nodeCount={0} onChooseSkill={noop} onDraftPrompt={noop} />);
const html = renderToStaticMarkup(<AgentWelcome appearance={DEFAULT_CANVAS_APPEARANCE} nodeCount={0} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html.match(/disabled=""/g)).toHaveLength(1);
expect(html).toContain("添加节点后,一起梳理创作思路");
expect(html).not.toContain("生成前由你确认");
});
test("assistant template replacement treats names literally", () => {
expect(agentCopy("{agentName} / {agentName}", "$&小鱼")).toBe("$&小鱼 / $&小鱼");
});
test("permission modes have distinct icons, not just distinct colors", () => {
const icons = (["read_only", "request_approval", "auto"] as const).map((mode) => {
const Icon = agentPermissionVisual(mode).icon;

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { adaptLive2DRenderOrders } from "../src/lib/canvas/live2d-core-adapter";
describe("Live2D Core render orders", () => {
test("preserves the legacy Core interface", () => {
const orders = new Int32Array([0]);
const original = () => orders;
const model = { getModel: () => ({ drawables: { count: 1, renderOrders: orders } }), getDrawableRenderOrders: original };
adaptLive2DRenderOrders(model);
expect(model.getDrawableRenderOrders).toBe(original);
});
test("reads current orders from the new Core on every frame", () => {
let orders = new Int32Array([1, 0]);
const model = { getModel: () => ({ drawables: { count: 2 }, offscreens: { count: 0 }, getRenderOrders: () => orders }), getDrawableRenderOrders: () => new Int32Array() };
adaptLive2DRenderOrders(model);
expect(model.getDrawableRenderOrders()).toBe(orders);
orders = new Int32Array([0, 1]);
expect(model.getDrawableRenderOrders()).toBe(orders);
});
test("rejects unsupported offscreens", () => {
const model = { getModel: () => ({ drawables: { count: 1 }, offscreens: { count: 1 } }), getDrawableRenderOrders: () => new Int32Array() };
expect(() => adaptLive2DRenderOrders(model)).toThrow("离屏渲染");
});
test("rejects missing or inconsistent orders", () => {
expect(() => adaptLive2DRenderOrders({ getModel: () => ({ drawables: { count: 1 } }), getDrawableRenderOrders: () => new Int32Array() })).toThrow("缺少");
expect(() => adaptLive2DRenderOrders({ getModel: () => ({ drawables: { count: 1 }, getRenderOrders: () => new Int32Array() }), getDrawableRenderOrders: () => new Int32Array() })).toThrow("不匹配");
});
});

View File

@@ -0,0 +1,47 @@
import { afterEach, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { installLive2DLoader } from "../src/services/live2d-loader";
import type { Live2DLoader, Live2DLoaderContext } from "pixi-live2d-display/cubism4";
const originalFetch = globalThis.fetch;
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else Reflect.deleteProperty(globalThis, "window");
});
test("Live2D loader restricts credentialed URLs, propagates failures and aborts on destroy", async () => {
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://canvas.example/admin" } } });
const loader = { middlewares: [] } as unknown as typeof Live2DLoader;
installLive2DLoader(loader);
installLive2DLoader(loader);
expect(loader.middlewares).toHaveLength(1);
const run = (context: Live2DLoaderContext) => loader.middlewares[0](context, async () => {});
const url = "/api/admin/settings/appearance/live2d/id/model.model3.json";
let requests = 0;
globalThis.fetch = (async (_url: unknown, options: RequestInit) => {
requests++;
expect(options.credentials).toBe("include");
return new Response('{"Version":3}', { status: 200 });
}) as typeof fetch;
const context = { url, type: "json" } as Live2DLoaderContext;
await run(context);
expect(context.result).toEqual({ Version: 3 });
for (const unsafe of ["https://evil.example" + url, "/api/admin/users", "//evil.example/model.json"]) {
await expect(run({ url: unsafe, type: "json" })).rejects.toThrow("本站模型接口");
}
expect(requests).toBe(1);
globalThis.fetch = (async () => new Response("denied", { status: 403 })) as typeof fetch;
await expect(run({ url, type: "json" })).rejects.toThrow("403");
const target = new EventEmitter();
globalThis.fetch = ((_url: unknown, options: RequestInit) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener("abort", () => reject(new Error("aborted")));
})) as typeof fetch;
const pending = run({ url, type: "json", target: target as unknown as Live2DLoaderContext["target"] });
target.emit("destroy");
await expect(pending).rejects.toThrow("aborted");
expect(target.listenerCount("destroy")).toBe(0);
});