fix(*): 质量检查与版本发布 - 修复格式门禁和创作灵感兼容导出并发布 v1.6.2
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user