修复了一点bug,然后增加了图层拆分更多的功能
This commit is contained in:
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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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("不会再次提交任务或计费");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user