refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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;");
|
||||
|
||||
31
web/test/director-actor-material.test.ts
Normal file
31
web/test/director-actor-material.test.ts
Normal 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>");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
64
web/test/image-operation-plan.test.ts
Normal file
64
web/test/image-operation-plan.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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*;/);
|
||||
}
|
||||
});
|
||||
|
||||
65
web/test/node-resize-geometry.test.ts
Normal file
65
web/test/node-resize-geometry.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
131
web/test/provider-response-stream.test.ts
Normal file
131
web/test/provider-response-stream.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
web/test/source-replacement-contracts.test.ts
Normal file
49
web/test/source-replacement-contracts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user