fix(*): 质量检查与版本发布 - 修复格式门禁和创作灵感兼容导出并发布 v1.6.2

This commit is contained in:
ddcat666
2026-10-08 20:43:15 +08:00
parent 04689257d2
commit 9174aae013
86 changed files with 22712 additions and 635 deletions

View File

@@ -34,17 +34,19 @@ describe("图片工具像素合同", () => {
});
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);
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);

View File

@@ -2,8 +2,12 @@ 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",
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"];
@@ -16,34 +20,46 @@ function opposite(box: { position: { x: number; y: number }; width: number; heig
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);
}
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,
position: { x: -90, y: 40 },
width: 460,
height: 230,
});
expect(solveNodeResize(initial, { x: 60, y: 30 })).toEqual({
position: initial.position, width: 460, height: 230,
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);
}
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", () => {

View File

@@ -1,6 +1,16 @@
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 {
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";
@@ -38,12 +48,26 @@ describe("provider response replacement contracts", () => {
{ type: "response.reasoning_summary_text.done", text: "why" },
{ type: "constructor.delta", delta: "ignore" },
{ type: "response.completed", response: payload },
].map(frame).join("");
]
.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);
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);
@@ -58,9 +82,36 @@ describe("provider response replacement contracts", () => {
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("");
{
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));
@@ -73,12 +124,21 @@ describe("provider response replacement contracts", () => {
});
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 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" },
] });
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);
@@ -87,25 +147,33 @@ describe("provider response replacement contracts", () => {
});
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: [{ 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: "{}" } }],
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");
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 () => {
@@ -119,7 +187,9 @@ describe("provider response replacement contracts", () => {
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(remove).toHaveBeenCalledTimes(2);
await expect(delay(0, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
} finally { remove.mockRestore(); }
} finally {
remove.mockRestore();
}
});
test("video data URLs preserve raw bytes and MIME without FileReader", async () => {