修复了一点bug,然后增加了图层拆分更多的功能

This commit is contained in:
andy committed 2026-09-02 16:06:45 +08:00
1 parent 1305a9cdd8
commit ce8372e878
31 files changed
+2567 -391

No files matched your search

@@ -11,6 +11,7 @@ describe("PostgreSQL readiness contract", () => {
for (const table of [
"assets",
"generation_jobs",
"seedream_layer_compositions",
"usage_events",
"projects",
"image_templates",
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const styles = readFileSync(
fileURLToPath(new URL("../app/globals.css", import.meta.url)),
"utf8"
).replace(/\r\n/g, "\n");
function cssRule(selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = styles.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
if (!match) throw new Error(`CSS rule not found: ${selector}`);
return match[1];
}
describe("modal compositing contract", () => {
it.each([".task-detail-backdrop", ".seedream-layer-edit-backdrop"])(
"%s avoids full-screen backdrop blur",
(selector) => {
const rule = cssRule(selector);
expect(rule).not.toMatch(/(?:^|[;\s])(?:-webkit-)?backdrop-filter\s*:/);
expect(rule).toMatch(/background\s*:\s*rgba\(/);
}
);
});
@@ -7,6 +7,7 @@ describe("PostgreSQL application-role privilege contract", () => {
expect(source).toContain('["billing_wallets", "SELECT, INSERT, UPDATE"]');
expect(source).toContain('["billing_ledger", "SELECT, INSERT"]');
expect(source).toContain('["platform_runtime_settings", "SELECT, INSERT, UPDATE"]');
expect(source).toContain('["seedream_layer_compositions", "SELECT, INSERT, UPDATE, DELETE"]');
expect(source).toContain("applicationRoleTablePrivileges().map");
expect(source).toContain("REVOKE ALL ON TABLE");
expect(source).not.toContain("ALTER DEFAULT PRIVILEGES");
+10 -2
View File
@@ -16,12 +16,16 @@ describe("Seedream layer composition download", () => {
});
const close = vi.fn();
const drawImage = vi.fn();
const save = vi.fn();
const translate = vi.fn();
const rotate = vi.fn();
const restore = vi.fn();
const click = vi.fn();
const anchor = { href: "", download: "", click, remove: vi.fn() };
const canvas = {
width: 0,
height: 0,
getContext: () => ({ clearRect: vi.fn(), drawImage }),
getContext: () => ({ clearRect: vi.fn(), drawImage, save, translate, rotate, restore }),
toBlob: (callback: BlobCallback) => callback(new Blob(["png"], { type: "image/png" }))
};
@@ -35,7 +39,7 @@ describe("Seedream layer composition download", () => {
vi.stubGlobal("URL", { createObjectURL: () => "blob:composition", revokeObjectURL: vi.fn() });
await expect(downloadSeedreamLayerComposition({
items: [{ url: "/api/assets/private-layer/download?inline=1", visible: true, box: [0, 0, 1000, 1000] }],
items: [{ url: "/api/assets/private-layer/download?inline=1", visible: true, box: [0, 0, 1000, 1000], rotation: 90 }],
width: 100,
height: 100,
fileName: "composition.png"
@@ -43,6 +47,10 @@ describe("Seedream layer composition download", () => {
expect(fetchMock).toHaveBeenCalledWith("/api/assets/private-layer/download", { credentials: "same-origin" });
expect(drawImage).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledOnce();
expect(translate).toHaveBeenCalledWith(50, 50);
expect(rotate).toHaveBeenCalledWith(Math.PI / 2);
expect(restore).toHaveBeenCalledOnce();
expect(click).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
+28
View File
@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("Seedream layer composition persistence contract", () => {
it("ships a versioned PostgreSQL document table that follows generation-job deletion", async () => {
const migration = await readFile(new URL("../database/migrations/0005_seedream_layer_compositions.sql", import.meta.url), "utf8");
expect(migration).toContain("create table if not exists public.seedream_layer_compositions");
expect(migration).toContain("references public.generation_jobs(id) on delete cascade");
expect(migration).toContain("layers jsonb not null");
expect(migration).toContain("version bigint not null");
expect(migration).toContain("jsonb_typeof(layers) = 'array'");
});
it("exposes owner-scoped GET/PUT routes and provisions application-role privileges", async () => {
const [surface, privileges, handler] = await Promise.all([
readFile(new URL("../contracts/http/route-surface.v1.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/migrate-postgres.mjs", import.meta.url), "utf8"),
readFile(new URL("../backend/internal/httpapi/layer_compositions.go", import.meta.url), "utf8")
]);
expect(surface).toContain('"GET", "path": "/api/layer-compositions/{id}"');
expect(surface).toContain('"PUT", "path": "/api/layer-compositions/{id}"');
expect(privileges).toContain('["seedream_layer_compositions", "SELECT, INSERT, UPDATE, DELETE"]');
expect(handler).toContain("handler.platform.Authorize");
expect(handler).toContain("jobs.Scope{OwnerID: session.User.ID}");
});
});
@@ -0,0 +1,94 @@
import { readFile } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForJobOutputAssets } from "../lib/client/generation-result-sync";
import type { Asset, GenerationJob } from "../lib/types";
const resultAsset: Asset = {
id: "asset-result",
ownerId: "owner-1",
kind: "image",
name: "编辑结果.png",
url: "/generated-results/asset-result.png",
source: "generated",
tags: [],
metadata: {},
createdAt: "2026-09-02T00:00:00.000Z",
updatedAt: "2026-09-02T00:00:00.000Z"
};
const succeededBeforeOutputSync: GenerationJob = {
id: "job-edit-1",
ownerId: "owner-1",
capability: "image.generate",
provider: "seedream",
reqKey: "doubao-seedream-5-0-pro-260628",
status: "succeeded",
inputAssetIds: ["asset-source"],
inputUrls: [],
outputAssetIds: [],
requestPayload: {},
createdAt: "2026-09-02T00:00:00.000Z",
updatedAt: "2026-09-02T00:00:00.000Z"
};
describe("Seedream layer edit result synchronization", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("refreshes a succeeded job whose output IDs arrive after the terminal status", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setTimeout, clearTimeout });
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/generations/image/job-edit-1") {
return Response.json({ job: { ...succeededBeforeOutputSync, outputAssetIds: [resultAsset.id] } });
}
if (url === "/api/assets/asset-result") return Response.json({ asset: resultAsset });
return Response.json({ error: "unexpected request" }, { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
const pending = waitForJobOutputAssets(succeededBeforeOutputSync, new AbortController().signal);
await vi.runAllTimersAsync();
await expect(pending).resolves.toEqual([resultAsset]);
expect(fetchMock).toHaveBeenCalledWith("/api/generations/image/job-edit-1", expect.objectContaining({ cache: "no-store" }));
expect(fetchMock).toHaveBeenCalledWith("/api/assets/asset-result", expect.objectContaining({ cache: "no-store" }));
});
it("retries the direct asset lookup while the registered result is not readable yet", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setTimeout, clearTimeout });
let assetReads = 0;
const job = { ...succeededBeforeOutputSync, outputAssetIds: [resultAsset.id] };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/generations/image/job-edit-1") return Response.json({ job });
if (url === "/api/assets/asset-result") {
assetReads += 1;
return assetReads === 1
? Response.json({ error: "资产不存在" }, { status: 404 })
: Response.json({ asset: resultAsset });
}
return Response.json({ error: "unexpected request" }, { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
const pending = waitForJobOutputAssets(job, new AbortController().signal);
await vi.runAllTimersAsync();
await expect(pending).resolves.toEqual([resultAsset]);
expect(assetReads).toBe(2);
});
it("offers result-only retry instead of submitting and billing another edit task", async () => {
const viewer = await readFile(new URL("../components/seedream-layer-result-viewer.tsx", import.meta.url), "utf8");
expect(viewer).toContain("retryLayerEditResultSync");
expect(viewer).toContain("重新同步结果");
expect(viewer).toContain("不会再次提交任务或计费");
});
});