feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程

This commit is contained in:
ddcat666 committed 2026-10-10 04:40:20 +08:00
1 parent a20df86cbd
commit 982021be93
39 files changed
+735 -242

No files matched your search

@@ -78,6 +78,35 @@ function videoConfig() {
}
describe("canvas character reference delivery", () => {
test("saved resource references stay as storage keys without browser materialization", async () => {
const { images } = mockResources([]);
const context = contextFor([node("scene", CanvasNodeType.Image, { storageKey: "resource:scene" })], "@图片1");
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
expect(hydrated.referenceImages).toEqual([expect.objectContaining({ storageKey: "resource:scene", dataUrl: "" })]);
expect(images).not.toHaveBeenCalled();
});
test("character resource references also skip browser materialization", async () => {
const { images } = mockResources([detail("a")]);
const context = contextFor([characterNode("a")], "@角色1");
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
expect(hydrated.referenceImages.map((image) => image.storageKey)).toEqual(["resource:a-main"]);
expect(images).not.toHaveBeenCalled();
});
test("local browser-only references still materialize for generation", async () => {
const { images } = mockResources([]);
const context = contextFor([node("scene", CanvasNodeType.Image, { storageKey: "image:local-scene" })], "@图片1");
await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
expect(images).toHaveBeenCalledTimes(1);
});
test("mixed references preserve primary slots through hydration and backend task preparation", async () => {
const a = detail("a", [
["primary", "a-main"],
+12
View File
@@ -14,6 +14,7 @@ import {
resetRemoteUserDataSync,
saveRemoteUserDataNow,
syncRemoteUserData,
overwriteRemoteCanvasProject,
} from "../src/services/user-data-sync";
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
@@ -216,6 +217,17 @@ test("stale viewport, no-op restore and open do not submit old content", async (
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
});
test("explicit local overwrite replaces only the selected cloud canvas after a revision conflict", async () => {
remote.set("canvas", { ...canvas(), title: "cloud edit", revision: 2 });
useCanvasStore.getState().renameProject("canvas", "local edit");
await overwriteRemoteCanvasProject("canvas");
expect(remote.get("canvas")).toMatchObject({ title: "local edit", revision: 3 });
expect(remote.get("other")).toMatchObject({ title: "other", revision: 1 });
expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", cloudRevision: 3, pendingChanges: 0 });
});
test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => {
remote.set("canvas", { ...canvas(), revision: 9 });
await loadCanvasProjectForEditing("canvas", { latest: true });
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { createGalleryWheelGesture } from "../src/pages/create/creation-gallery-gesture";
describe("创作轮播连续滚动", () => {
test("一次触摸板滑动的加速及惯性尾巴只切换一张", () => {
const gesture = createGalleryWheelGesture();
const deltas = [2, 6, 18, 42, 70, 55, 32, 18, 8, 4, 2, 1];
expect(deltas.map((delta, index) => gesture(delta, index * 30)).filter(Boolean)).toEqual([1]);
});
test("惯性尚未结束时再次同方向发力仍能翻页", () => {
const gesture = createGalleryWheelGesture();
const deltas = [20, 65, 50, 30, 15, 7, 3, 1, 4, 18, 45, 30, 15, 5];
expect(deltas.map((delta, index) => gesture(delta, index * 30)).filter(Boolean)).toEqual([1, 1]);
});
test("短暂停顿后的轻滑不受上一次大力度峰值限制", () => {
const gesture = createGalleryWheelGesture();
expect(gesture(160, 0)).toBe(1);
expect(gesture(20, 40)).toBe(0);
expect(gesture(4, 220)).toBe(0);
expect(gesture(5, 240)).toBe(1);
expect(gesture(8, 440)).toBe(1);
});
test("反向手势立即生效,微小抖动不触发", () => {
const gesture = createGalleryWheelGesture();
expect(gesture(20, 0)).toBe(1);
expect(gesture(-0.2, 20)).toBe(0);
expect(gesture(-12, 40)).toBe(-1);
expect(gesture(-6, 60)).toBe(0);
expect(gesture(12, 80)).toBe(1);
});
test("离散滚轮每次滚动均可连续切换", () => {
const gesture = createGalleryWheelGesture();
expect([0, 200, 400, 600].map((now) => gesture(48, now))).toEqual([1, 1, 1, 1]);
});
});
+6
View File
@@ -6,11 +6,17 @@ import { ImageSizePresetsEditor } from "../src/components/image-size-presets-edi
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
import { defaultImageCapabilityConfig, normalizeImageValue, normalizeModelCapabilityConfig } from "../src/lib/model-capabilities";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "../src/lib/image-size-presets";
import { imageQualityLabel } from "../src/lib/image-quality";
import { buildImageResolutionOptions } from "../src/lib/image-resolution-tiers";
import { resolveImageRequestSize, validateImageSize } from "../src/services/api/image-validation";
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
describe("统一图片分辨率与宽高比", () => {
test("画质公共映射覆盖创作与画布使用的 xhigh、max", () => {
expect(imageQualityLabel("xhigh")).toBe("超高");
expect(imageQualityLabel("MAX")).toBe("最高");
});
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
for (const model of ["midjourney-1k", "midjourney-2k"]) {
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);
+12
View File
@@ -140,6 +140,18 @@ describe("model request pricing", () => {
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000);
});
test("匹配独立图片画质与像素分辨率,并兼容存量 2K 价格档", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "medium", resolution: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
],
});
const requirements: ModelRequirements = { capability: "image", options: { quality: "medium", size: "2048x2048" } };
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(3);
expect(priceTiersForCurrentSelection(resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!, "image", config, requirements)[0]?.unitPriceMicrocredits).toBe(3_000_000);
});
test("preserves provider-specific resolution enums when matching price tiers", () => {
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_CANVAS_AGENT_SKILL_ID, ensureDefaultCanvasAgentSkill } from "@/services/skill-defaults";
function skill(skillId: string, isAdded = true) {
return { skillId, skillName: skillId, isAdded } as never;
}
describe("ensureDefaultCanvasAgentSkill", () => {
test("keeps an already added default skill without writing", async () => {
let added = 0;
const result = await ensureDefaultCanvasAgentSkill({
listAdded: async () => ({ skills: [skill(DEFAULT_CANVAS_AGENT_SKILL_ID)] }),
get: async () => { throw new Error("must not read catalog"); },
add: async () => { added += 1; return { skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID) }; },
});
expect(result.skills[0].skillId).toBe(DEFAULT_CANVAS_AGENT_SKILL_ID);
expect(added).toBe(0);
});
test("installs the default skill once when it is not added", async () => {
let listCalls = 0;
let added = 0;
const result = await ensureDefaultCanvasAgentSkill({
listAdded: async () => ({ skills: listCalls++ === 0 ? [] : [skill(DEFAULT_CANVAS_AGENT_SKILL_ID)] }),
get: async () => ({ skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID, false) }),
add: async () => { added += 1; return { skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID) }; },
});
expect(result.skills[0].skillId).toBe(DEFAULT_CANVAS_AGENT_SKILL_ID);
expect(added).toBe(1);
expect(listCalls).toBe(2);
});
});