refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证

This commit is contained in:
ddcat666
2026-10-08 19:38:57 +08:00
parent 9be3aa8235
commit 87ecb3000c
724 changed files with 3046 additions and 3885 deletions

View File

@@ -23,6 +23,7 @@ test("Agent 对话和设置复用创作页模型选择器,并且只展示文
const twoPane = pickerCss.match(/\.creation-model-picker-menu\.is-model-list \.canvas-model-picker-two-pane \{[^}]+\}/)?.[0] || "";
expect(twoPane).toContain("min-height: 0");
expect(twoPane).toContain("align-items: start");
// 双栏继承共享样式的拉伸布局,不能恢复按内容撑高的顶部对齐。
expect(twoPane).not.toMatch(/align-items:\s*(?:start|flex-start)/);
expect(twoPane).not.toContain("min-height: 300px");
});

View File

@@ -180,14 +180,14 @@ describe("canvas custom appearance", () => {
});
test("stores defaults locally with the active account scope", () => {
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-A");
window.localStorage.setItem("yingce:active-user-scope", "account-A");
const value = { appearance: customCanvasAppearanceFromTheme("dark"), backgroundMode: "lines" as const };
writeCanvasAppearanceDefault(value);
expect(values.has("infinite-canvas:canvas-appearance-default:user:account-A")).toBe(true);
expect(values.has("yingce:canvas-appearance-default:user:account-A")).toBe(true);
expect(readCanvasAppearanceDefault()).toEqual(value);
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-B");
window.localStorage.setItem("yingce:active-user-scope", "account-B");
expect(readCanvasAppearanceDefault()).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { InfiniteCanvas } from "../src/components/canvas/infinite-canvas";
import { CanvasViewport } from "../src/components/canvas/canvas-viewport";
import { canvasAppearanceForTheme } from "../src/lib/canvas/canvas-appearance";
import { applyCanvasLiveViewport, CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, CANVAS_VIEWPORT_PREVIEW_EVENT } from "../src/lib/canvas/canvas-live-viewport";
import type { CanvasBackgroundMode } from "../src/lib/canvas-theme";
@@ -20,9 +20,9 @@ const viewports: ViewportTransform[] = [
function renderGrid(mode: CanvasBackgroundMode, viewport: ViewportTransform, theme: "light" | "dark" = "dark") {
const markup = renderToStaticMarkup(
<InfiniteCanvas containerRef={createRef<HTMLDivElement>()} viewport={viewport} onViewportChange={() => {}} backgroundMode={mode} appearance={canvasAppearanceForTheme(theme)}>
<CanvasViewport containerRef={createRef<HTMLDivElement>()} viewport={viewport} onViewportChange={() => {}} backgroundMode={mode} appearance={canvasAppearanceForTheme(theme)}>
<div data-test-node />
</InfiniteCanvas>,
</CanvasViewport>,
);
return markup.match(/<div data-canvas-grid-layer[^>]*>/)?.[0] ?? null;
}

View File

@@ -66,8 +66,9 @@ describe("canvas resource mention editor", () => {
expect(panel).toContain("<LoaderCircle className=");
expect(panel).toContain("animate-spin motion-reduce:animate-none");
expect(panel).not.toContain("isRunning ? theme.accent.danger");
expect(configComposer).toContain("wrapper.dataset.referenceToken");
expect(configComposer).not.toContain("result += `@[node:");
expect(configComposer).toContain("CanvasResourceMentionTextarea");
expect(configComposer).toContain("composerReferences(inputs, skillReferences)");
expect(configComposer).not.toContain("contentEditable");
expect(project).not.toContain("removeCanvasResourceMention");
});

View File

@@ -92,7 +92,7 @@ describe("canvas selection semantics", () => {
test("uses region selection as the page default and commits selection outside pointer-move", async () => {
const projectSource = await Promise.resolve(moduleGroupSource("pages/canvas/project.tsx"));
const controllerSource = await Bun.file(new URL("../src/pages/canvas/use-canvas-selection-controller.ts", import.meta.url)).text();
const canvasSource = await Bun.file(new URL("../src/components/canvas/infinite-canvas.tsx", import.meta.url)).text();
const canvasSource = await Bun.file(new URL("../src/components/canvas/canvas-viewport.tsx", import.meta.url)).text();
const graphicsSource = await Bun.file(new URL("../src/components/canvas/canvas-leafer-graphics-layer.tsx", import.meta.url)).text();
const globalStyles = await Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text();

View File

@@ -35,7 +35,7 @@ describe("canvas visual contrast", () => {
expect(canvasThemes.dark.canvas.dot).toBe("#000000");
expect(canvasThemes.dark.canvas.line).toBe("#000000");
const source = await Bun.file(new URL("../src/components/canvas/infinite-canvas.tsx", import.meta.url)).text();
const source = await Bun.file(new URL("../src/components/canvas/canvas-viewport.tsx", import.meta.url)).text();
expect(source).toContain('opacity: mode === "dots" ? 0.34 : 0.46');
});

View File

@@ -93,7 +93,7 @@ test("editor rail and expanded panel share one restrained glass surface without
const page = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8");
const panel = component("canvas-workspace-panel.tsx");
const styles = component("canvas-workspace-panel.css");
expect(page).toContain('<InfiniteCanvas');
expect(page).toContain('<CanvasViewport');
expect(page).not.toContain("canvas-workspace-backdrop");
expect(page).toContain("canvas-main-with-workspace");
expect(styles).toContain(".canvas-main-with-workspace .canvas-editor-shell {\n z-index: 0;");

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { BufferGeometry, Float32BufferAttribute, Mesh, MeshStandardMaterial, SkinnedMesh } from "three";
import { applyActorReferenceMaterial, updateActorReferenceColor } from "../src/components/canvas/previs/previs-viewport-rig";
describe("预演台角色参考材质", () => {
test("自定义着色器应用骨骼蒙皮后的顶点与法线", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([0, 0, 0], 3));
geometry.setAttribute("normal", new Float32BufferAttribute([0, 1, 0], 3));
const mesh = new SkinnedMesh(geometry, new MeshStandardMaterial());
applyActorReferenceMaterial(mesh, "#2f7de1");
expect(mesh.material.vertexShader).toContain("#include <skinning_pars_vertex>");
expect(mesh.material.vertexShader).toContain("#include <skinning_vertex>");
expect(mesh.material.vertexShader).toContain("normalize(transformedNormal)");
expect(mesh.material.vertexShader).toContain("vec4(transformed, 1.0)");
expect(mesh.userData.previsActor).toBe(true);
updateActorReferenceColor(mesh, "#ff0000");
expect(mesh.material.uniforms.uBaseColor.value.getHexString()).toBe("ff0000");
});
test("普通网格也可使用参考材质", () => {
const mesh = new Mesh(new BufferGeometry(), new MeshStandardMaterial());
applyActorReferenceMaterial(mesh, "#2f7de1");
expect(mesh.material.vertexShader).toContain("#include <skinning_vertex>");
});
});

View File

@@ -279,7 +279,7 @@ test("browser generation asset writes use the storage-level Web Locks boundary",
try {
await withGenerationAssetStorageLock("account-A", async () => undefined);
assert.deepEqual(lockNames, ["infinite-canvas:generation-asset-storage-lock:account-A"]);
assert.deepEqual(lockNames, ["yingce:generation-asset-storage-lock:account-A"]);
} finally {
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });

View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { planImageTiles, resolveCropPixels, resolveUpscaleSize } from "../src/lib/canvas/image-operation-plan";
import { resizeSelection, selectAspect, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection";
import { invertSelectionAlpha } from "../src/lib/canvas/image-mask-document";
describe("图片工具像素合同", () => {
test("奇数尺寸网格完整覆盖原图且像素不重叠", () => {
const size = { width: 17, height: 13 };
const tiles = planImageTiles(size, { rows: 3, columns: 4 });
const coverage = new Uint8Array(size.width * size.height);
for (const tile of tiles) {
for (let y = tile.y; y < tile.y + tile.height; y++) {
for (let x = tile.x; x < tile.x + tile.width; x++) coverage[y * size.width + x]++;
}
}
expect(tiles).toHaveLength(12);
expect(coverage.every((count) => count === 1)).toBe(true);
expect(tiles.map(({ row, column }) => [row, column])).toEqual(Array.from({ length: 12 }, (_, i) => [Math.floor(i / 4), i % 4]));
});
test("拒绝零面积、非有限参数及超像素网格", () => {
for (const value of [0, NaN, Infinity, -1, 13, 1.5]) expect(() => planImageTiles({ width: 20, height: 20 }, { rows: value, columns: 2 })).toThrow();
expect(() => planImageTiles({ width: 1, height: 1 }, { rows: 2, columns: 1 })).toThrow();
expect(() => resolveUpscaleSize(0, 100, 1000)).toThrow();
expect(() => resolveUpscaleSize(100, 100, Infinity)).toThrow();
});
test("裁剪夹取图内像素,默认居中方形", () => {
expect(resolveCropPixels({ width: 9, height: 4 })).toEqual({ x: 2, y: 0, width: 4, height: 4 });
expect(resolveCropPixels({ width: 10, height: 10 }, { x: -0.1, y: 0.8, width: 0.3, height: 0.5 })).toEqual({ x: 0, y: 8, width: 2, height: 2 });
expect(() => resolveCropPixels({ width: 10, height: 10 }, { x: 2, y: 0, width: 0.1, height: 1 })).toThrow();
});
test("缩放保持宽高比并限制长边", () => {
expect(resolveUpscaleSize(300, 200, 9000)).toEqual({ width: 4096, height: 2731 });
expect(resolveUpscaleSize(200, 300, 1024)).toEqual({ width: 683, height: 1024 });
});
test("锁定比例时八个手柄在各种拖动下都保持比例与图内边界", () => {
const box = { x: 0.1, y: 0.2, width: 0.6, height: 0.4 };
for (const x of [-1, 0, 1] as const) for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
for (const dx of [-3, -0.1, 0, 0.1, 3]) for (const dy of [-3, -0.1, 0, 0.1, 3]) {
const next = resizeSelection(box, [x, y] as CropGrip, dx, dy, true);
expect(next.width / next.height).toBeCloseTo(1.5, 8);
expect(next.x).toBeGreaterThanOrEqual(-1e-12);
expect(next.y).toBeGreaterThanOrEqual(-1e-12);
expect(next.x + next.width).toBeLessThanOrEqual(1 + 1e-12);
expect(next.y + next.height).toBeLessThanOrEqual(1 + 1e-12);
}
}
});
test("极窄比例切换后仍可自由拖动手柄", () => {
const box = selectAspect({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 }, 0.02);
expect(box.width).toBeLessThan(0.06);
const resized = resizeSelection(box, [-1, 0], 0, 0, false);
expect(resized.x).toBeGreaterThanOrEqual(0);
expect(resized.width).toBeGreaterThan(0);
expect(resized.x + resized.width).toBeLessThanOrEqual(1);
expect(translateSelection(box, -100, 100).y + box.height).toBeCloseTo(1);
});
test("遮罩选区转换为透明,未选区保持白色不透明,空选区拒绝", () => {
const input = new Uint8ClampedArray([0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 255]);
expect([...invertSelectionAlpha(input)]).toEqual([255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 255, 0]);
expect(input[7]).toBe(12);
expect(() => invertSelectionAlpha(new Uint8ClampedArray(8))).toThrow();
});
});

View File

@@ -44,28 +44,52 @@ test("选择模型保留菜单及行内焦点,仍可通过 Escape 和外部点
expect(component).toContain('window.addEventListener("pointerdown", closeOnOutsidePointer, true)');
});
test("ModelPicker 样式独立加载,并保留模型列表的视口边界", async () => {
const [application, globals, pickerStyles] = await Promise.all([
test("ModelPicker 样式独立加载,所有入口继承可用高度约束", async () => {
const [application, globals, pickerStyles, component] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text(),
Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text(),
]);
expect(application).toContain('import "./styles/shared/model-picker.css";');
expect(globals).not.toContain("canvas-model-picker");
expect(pickerStyles).toContain(".canvas-model-picker-menu {");
expect(pickerStyles).toContain("max-height: min(420px, calc(100vh - 32px));");
const creationMenu = pickerStyles.match(/\.creation-model-picker-menu \{([\s\S]*?)\}/)?.[1] || "";
expect(creationMenu).toContain("max-height: min(460px, calc(100vh - 24px));");
expect(creationMenu).toContain("overflow-y: auto;");
expect(pickerStyles).toContain(".app-user-workspace .creation-model-picker-menu.is-brand-list");
expect(pickerStyles).toContain(".creation-model-picker-surface .creation-model-picker-menu.is-model-list");
expect(pickerStyles).toContain(".creation-model-picker-surface .creation-model-picker-menu.is-brand-list");
const menu = pickerStyles.match(/\.canvas-model-picker-menu\s*\{([^}]+)\}/)?.[1] || "";
expect(menu).toMatch(/display:\s*flex\s*;/);
expect(menu).toMatch(/flex-direction:\s*column\s*;/);
const maxHeight = menu.match(/max-height:\s*([^;]+);/)?.[1] || "";
expect(maxHeight).toContain("min(");
expect(maxHeight).toContain("100dvh");
expect(maxHeight).toContain("var(--canvas-model-picker-available-height)");
expect(menu).toMatch(/overflow-y:\s*auto\s*;/);
expect(component).toContain('"--canvas-model-picker-available-height": `${availableHeight}px`');
const modelList = pickerStyles.match(/\.creation-model-picker-surface \.creation-model-picker-menu\.is-model-list \{([\s\S]*?)\}/)?.[1] || "";
expect(modelList).toContain("max-height: min(460px, calc(100vh - 24px)) !important;");
expect(modelList).toContain("overflow-y: auto !important;");
expect(pickerStyles).not.toContain(".app-user-workspace .creation-model-picker-menu {");
expect(pickerStyles).not.toContain(".creation-model-picker-surface .creation-model-picker-menu {");
// portal 和工作区覆盖只负责外观,不能重新取消共享菜单的高度或滚动限制。
const overrides = [...pickerStyles.matchAll(/[^{}]*\.creation-model-picker-menu(?:\.is-(?:brand|model)-list)?\s*\{([^}]+)\}/g)];
expect(overrides.length).toBeGreaterThan(0);
for (const [, declarations] of overrides) {
expect(declarations).not.toMatch(/(?:max-)?height\s*:/);
expect(declarations).not.toMatch(/overflow(?:-y)?\s*:\s*visible/);
}
});
test("模型和渠道分别滚动,外层双栏不被长列表撑高", async () => {
const pickerStyles = await Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text();
const modelList = pickerStyles.match(/\.canvas-model-picker-menu\.is-model-list\s*\{([^}]+)\}/)?.[1] || "";
expect(modelList).toMatch(/overflow:\s*hidden\s*;/);
const twoPane = pickerStyles.match(/\.canvas-model-picker-two-pane\s*\{([^}]+)\}/)?.[1] || "";
expect(twoPane).toMatch(/grid-template-rows:\s*minmax\(0,\s*1fr\)\s*;/);
expect(twoPane).toMatch(/min-height:\s*0\s*;/);
expect(twoPane).toMatch(/overflow:\s*hidden\s*;/);
expect(twoPane).not.toMatch(/align-items:\s*(?:start|flex-start)/);
const brandRail = pickerStyles.match(/\.canvas-model-picker-menu\.is-model-list\s+\.canvas-model-picker-brand-rail\s*\{([^}]+)\}/)?.[1] || "";
const modelPane = pickerStyles.match(/\.canvas-model-picker-model-pane\s*\{([^}]+)\}/)?.[1] || "";
for (const pane of [brandRail, modelPane]) {
expect(pane).toMatch(/min-height:\s*0\s*;/);
expect(pane).toMatch(/overflow-x:\s*hidden\s*;/);
expect(pane).toMatch(/overflow-y:\s*auto\s*;/);
}
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { solveNodeResize, type NodeResizeCorner, type NodeResizeSnapshot } from "../src/lib/canvas/node-resize-geometry";
const initial: NodeResizeSnapshot = {
position: { x: -30, y: 70 }, width: 400, height: 200,
minimumWidth: 100, minimumHeight: 80, corner: "bottom-right",
};
const corners: NodeResizeCorner[] = ["top-left", "top-right", "bottom-left", "bottom-right"];
function opposite(box: { position: { x: number; y: number }; width: number; height: number }, corner: NodeResizeCorner) {
return {
x: box.position.x + (corner.endsWith("left") ? box.width : 0),
y: box.position.y + (corner.startsWith("top") ? box.height : 0),
};
}
describe("node resize geometry", () => {
test("all corners retain the opposite anchor at minimum sizes and across the anchor", () => {
for (const corner of corners) for (const x of [-2000, -50, 0, 75, 2000]) for (const y of [-2000, -50, 0, 75, 2000]) {
const start = { ...initial, corner };
const next = solveNodeResize(start, { x, y });
expect(opposite(next, corner)).toEqual(opposite(start, corner));
expect(next.width).toBeGreaterThanOrEqual(100);
expect(next.height).toBeGreaterThanOrEqual(80);
}
});
test("free resize grows the dragged corner in world coordinates", () => {
expect(solveNodeResize({ ...initial, corner: "top-left" }, { x: -60, y: -30 })).toEqual({
position: { x: -90, y: 40 }, width: 460, height: 230,
});
expect(solveNodeResize(initial, { x: 60, y: 30 })).toEqual({
position: initial.position, width: 460, height: 230,
});
});
test("media preserves ratio, both minimums and the fixed anchor", () => {
for (const ratio of [0.1, 0.5, 1, 2, 10]) for (const corner of corners) for (const delta of [{ x: -5000, y: 3000 }, { x: 50, y: -60 }, { x: 3000, y: -5000 }]) {
const start = { ...initial, corner, ratio };
const next = solveNodeResize(start, delta);
expect(next.width / next.height).toBeCloseTo(ratio, 10);
expect(next.width).toBeGreaterThanOrEqual(100);
expect(next.height).toBeGreaterThanOrEqual(80);
expect(opposite(next, corner).x).toBeCloseTo(opposite(start, corner).x, 8);
expect(opposite(next, corner).y).toBeCloseTo(opposite(start, corner).y, 8);
}
});
test("dominant pointer axis and horizontal ties preserve the existing gesture", () => {
const start = { ...initial, ratio: 2 };
expect(solveNodeResize(start, { x: 80, y: 20 })).toMatchObject({ width: 480, height: 240 });
expect(solveNodeResize(start, { x: 20, y: 80 })).toMatchObject({ width: 560, height: 280 });
expect(solveNodeResize(start, { x: 80, y: -80 })).toMatchObject({ width: 480, height: 240 });
});
test("storyboard content can override the registry minimum height", () => {
expect(solveNodeResize({ ...initial, minimumHeight: 360 }, { x: -1000, y: -1000 })).toMatchObject({ width: 100, height: 360 });
});
test("invalid aspect metadata falls back to finite independent dimensions", () => {
for (const ratio of [NaN, Infinity, 0, -2]) {
expect(solveNodeResize({ ...initial, ratio }, { x: 40, y: 10 })).toMatchObject({ width: 440, height: 210 });
}
});
});

View File

@@ -0,0 +1,131 @@
import { describe, expect, spyOn, test } from "bun:test";
import { consumeJsonEvents } from "../src/lib/json-event-stream";
import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseGeminiImagePayload, parseGeminiToolResponse, parseToolResponse, readFetchError, readJsonPayload, responseErrorMessage } from "../src/services/api/image-response";
import { blobToDataUrl, delay } from "../src/services/api/video-response";
import type { ChatCompletionStreamState, GeminiStreamState, ResponseStreamState } from "../src/services/api/image-contracts";
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\r\n\r\n`;
describe("provider response replacement contracts", () => {
test("JSON event framing survives every chunk boundary, comments, multiple data lines and EOF", () => {
const stream = ': heartbeat\r\nevent: message\r\ndata: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\r\n\r\ndata: {"last":true}';
for (let split = 0; split <= stream.length; split++) {
const state = { buffer: "" };
const result: unknown[] = [];
consumeJsonEvents(state, stream.slice(0, split), (value) => result.push(value));
consumeJsonEvents(state, stream.slice(split), (value) => result.push(value), true);
expect(result).toEqual([{ text: "你好" }, { last: true }]);
expect(state.buffer).toBe("");
}
});
test("invalid JSON fails without dropping the failed frame or its unprocessed tail", () => {
const state = { buffer: "" };
const result: unknown[] = [];
const invalid = 'data: not-json\n\ndata: {"after":true}\n\n';
expect(() => consumeJsonEvents(state, frame({ before: true }) + invalid, (value) => result.push(value))).toThrow();
expect(result).toEqual([{ before: true }]);
expect(state.buffer).toBe(invalid);
});
test("Responses delta, done and completion preserve separate channels for every split", () => {
const payload = { output_text: "AB" };
const stream = [
{ type: "response.output_text.delta", delta: "A" },
{ type: "response.reasoning_summary_text.delta", delta: "why" },
{ type: "response.output_text.delta", delta: "B" },
{ type: "response.output_text.done", text: "AB" },
{ type: "response.reasoning_summary_text.done", text: "why" },
{ type: "constructor.delta", delta: "ignore" },
{ type: "response.completed", response: payload },
].map(frame).join("");
for (let split = 0; split <= stream.length; split++) {
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
const visible: string[] = [], reasoning: string[] = [];
consumeResponseStreamText(state, stream.slice(0, split), (value) => visible.push(value), (value) => reasoning.push(value));
consumeResponseStreamText(state, stream.slice(split), (value) => visible.push(value), (value) => reasoning.push(value), true);
expect(visible).toEqual(["A", "AB"]);
expect(reasoning).toEqual(["why"]);
expect(state.payload).toEqual(payload);
expect(state.buffer).toBe("");
}
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
consumeResponseStreamText(state, frame({ type: "response.output_text.done", text: "complete" }) + frame({ type: "response.reasoning.done", text: "reason" }));
expect(state).toMatchObject({ text: "complete", reasoning: "reason" });
consumeResponseStreamText(state, frame({ type: "response.failed", response: { error: { message: "quota" } } }));
expect(state.error).toBe("quota");
});
test("Chat Completions interleaves indexed tool fragments and reasoning aliases", () => {
const stream = [
{ choices: [{ delta: { content: "OK", reasoning: "r", tool_calls: [{ index: 3, id: "three", function: { name: "search", arguments: '{"q":' } }, { index: 1, id: "one", function: { name: "read", arguments: '{"id":' } }] } }] },
{ choices: [{ delta: { reasoning_text: "s", tool_calls: [{ index: 1, function: { arguments: '1}' } }, { index: 3, function: { arguments: '"画布"}' } }] } }] },
].map(frame).join("");
for (let split = 0; split <= stream.length; split++) {
const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
consumeChatCompletionStreamText(state, stream.slice(0, split));
consumeChatCompletionStreamText(state, stream.slice(split), undefined, undefined, true);
expect(state.text).toBe("OK");
expect(state.reasoning).toBe("rs");
expect(state.toolCalls.get(1)).toEqual({ id: "one", name: "read", arguments: '{"id":1}' });
expect(state.toolCalls.get(3)).toEqual({ id: "three", name: "search", arguments: '{"q":"画布"}' });
}
});
test("Gemini preserves thought signatures and tool order without mixing thought text", () => {
const payload = { candidates: [{ content: { parts: [{ thought: true, text: "plan" }, { text: "answer" }, { functionCall: { id: "a", name: "read", args: { id: 1 } }, thought_signature: "sig-a" }] } }, { content: { parts: [{ functionCall: { id: "b", name: "search" }, thoughtSignature: "sig-b" }] } }] };
const result = parseGeminiToolResponse(payload);
expect(result).toEqual({ content: "answer", reasoning: "plan", toolCalls: [
{ id: "a", type: "function", function: { name: "read", arguments: '{"id":1}' }, thoughtSignature: "sig-a" },
{ id: "b", type: "function", function: { name: "search", arguments: '{}' }, thoughtSignature: "sig-b" },
] });
const stream = frame(payload);
const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
for (const character of stream) consumeGeminiStreamText(state, character);
expect(state).toMatchObject({ text: "answer", reasoning: "plan", toolCalls: result.toolCalls });
expect(() => parseGeminiToolResponse({ promptFeedback: { blockReason: "BLOCKED" } })).toThrow("Gemini 拒绝了本次请求:BLOCKED");
});
test("image sources preserve MIME and source order; Responses chooses explicit output text", () => {
expect(parseGeminiImagePayload({ candidates: [{ content: { parts: [
{ inlineData: { mimeType: "image/jpeg", data: "AAA=" } },
{ inline_data: { mime_type: "image/webp", data: "BBB=" } },
{ fileData: { fileUri: "https://example.test/image" } },
{ text: "ignored" },
] } }] }).map((image) => image.dataUrl)).toEqual(["data:image/jpeg;base64,AAA=", "data:image/webp;base64,BBB=", "https://example.test/image"]);
expect(() => parseGeminiImagePayload({ candidates: [] })).toThrow("Gemini 接口没有返回图片");
expect(parseToolResponse({ output_text: "explicit", output: [{ type: "message", content: [{ text: "fallback" }] }, { type: "function_call", id: "item", call_id: "call", name: "read" }] })).toEqual({
content: "explicit", toolCalls: [{ id: "call", type: "function", function: { name: "read", arguments: "{}" } }],
});
});
test("HTTP and JSON errors retain precedence and distinguish proxy HTML", async () => {
expect(responseErrorMessage({ msg: "first", error: { message: "second" }, response: { error: { message: "third" } } })).toBe("first");
expect(await readFetchError(new Response('{"error":{"message":"denied"}}', { status: 403 }), "失败")).toBe("denied");
expect(await readFetchError(new Response('{}', { status: 429 }), "失败")).toBe("请求被限流或额度不足,请稍后重试");
expect(await readFetchError(new Response('x'.repeat(400), { status: 500 }), "失败")).toHaveLength(300);
await expect(readJsonPayload(new Response('<!doctype html><html>'), "失败")).rejects.toThrow("后端代理返回了前端网页");
await expect(readJsonPayload(new Response('not-json'), "失败")).rejects.toThrow("接口没有返回有效 JSON");
});
test("video polling removes abort listeners on completion and cancellation", async () => {
const controller = new AbortController();
const remove = spyOn(controller.signal, "removeEventListener");
try {
await delay(0, controller.signal);
expect(remove).toHaveBeenCalledTimes(1);
const pending = delay(60_000, controller.signal);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(remove).toHaveBeenCalledTimes(2);
await expect(delay(0, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
} finally { remove.mockRestore(); }
});
test("video data URLs preserve raw bytes and MIME without FileReader", async () => {
const data = Uint8Array.from({ length: 256 }, (_, i) => i);
const url = await blobToDataUrl(new Blob([data], { type: "video/webm" }));
expect(url.startsWith("data:video/webm;base64,")).toBe(true);
expect(new Uint8Array(await (await fetch(url)).arrayBuffer())).toEqual(data);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { composerReferences, insertComposerPreset } from "../src/components/canvas/canvas-composer-references";
import { splitMentionText } from "../src/components/canvas/canvas-mention-editable";
import { dataUrlToFile, getDataUrlByteSize, readFileAsDataUrl } from "../src/lib/image-utils";
import { packageSegment, referencedStorageKeys, MediaPackage } from "../src/lib/media-package";
import { readZip } from "../src/lib/zip";
// Behavior checks for replacement boundaries; no provider calls or account storage writes.
describe("替换模块的互操作合同", () => {
test("提示词素材引用保持生成标签、预览与未知文本", () => {
const inputs = [{ nodeId: "a", title: "参考图", type: "image" as const, image: { id: "image-a", name: "a.png", type: "image/png", dataUrl: "data:image/png;base64,AQID", bytes: 3 } }];
const refs = composerReferences(inputs, []);
expect(refs[0].mentionToken).toBe("@图片1");
expect(refs[0].previewUrl).toBe(inputs[0].image.dataUrl);
const parts = splitMentionText("参考 @图片1 ,保留 @图片10", refs);
expect(parts.filter((part) => part.type === "mention")).toHaveLength(1);
expect(parts.at(-1)).toEqual({ type: "text", text: " ,保留 @图片10" });
});
test("预设替换活动斜线和当前选区,保留两侧正文", () => {
expect(insertComposerPreset("前文 /镜头 后文", 6, 6, "推进")).toEqual({ text: "前文 推进 后文", caret: 6 });
expect(insertComposerPreset("abcXYZdef", 3, 6, "镜头")).toEqual({ text: "abc镜头 def", caret: 6 });
});
test("文件数据地址往返保留所有字节和 MIME", async () => {
const file = new File([new Uint8Array([0, 255, 128, 31, 42])], "test.png", { type: "image/png" });
const url = await readFileAsDataUrl(file);
expect(getDataUrlByteSize(url)).toBe(5);
const decoded = dataUrlToFile({ id: "a", name: file.name, type: file.type, bytes: 5, dataUrl: url });
expect([...new Uint8Array(await decoded.arrayBuffer())]).toEqual([0, 255, 128, 31, 42]);
expect(decoded.type).toBe("image/png");
const encoded = dataUrlToFile({ id: "b", name: "b.svg", type: "", dataUrl: "data:image/svg+xml,%3Csvg%3E%E5%9B%BE%3C%2Fsvg%3E", bytes: 0 });
expect(await encoded.text()).toBe("<svg>图</svg>");
expect(() => dataUrlToFile({ id: "c", name: "c", type: "", bytes: 0, dataUrl: "bad" })).toThrow();
});
test("归档资源名无碰撞,循环引用不会导致遍历溢出", () => {
expect(new Set(["a:b", "a/b", "a_b", "a%2Fb", "../a"].map(packageSegment)).size).toBe(5);
expect(packageSegment("../a")).not.toContain("../");
const root: Record<string, unknown> = { image: { storageKey: "image:user:a" }, media: [{ storageKey: "video:user:b" }] };
root.self = root;
expect([...referencedStorageKeys(root)].sort()).toEqual(["image:user:a", "video:user:b"]);
});
test("归档保留文件与清单,重复路径显式失败", async () => {
const archive = new MediaPackage();
archive.put("files/a", new Uint8Array([5, 7]));
expect(() => archive.put("files/a", "duplicate")).toThrow();
const zip = await readZip(await archive.finish("assets.json", { app: "yingce", files: [{ path: "files/a" }] }));
expect([...new Uint8Array(await zip.get("files/a")!.arrayBuffer())]).toEqual([5, 7]);
expect(JSON.parse(await zip.get("assets.json")!.text()).app).toBe("yingce");
});
});