feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复
This commit is contained in:
33
web/test/asset-storage-revision.test.ts
Normal file
33
web/test/asset-storage-revision.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalizeAssetRecord, parseAssetStorageDocument } from "@/lib/asset-storage-revision";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
const legacyAsset = {
|
||||
id: "legacy-image",
|
||||
kind: "image",
|
||||
title: "旧素材",
|
||||
coverUrl: "",
|
||||
createdAt: "2026-08-29T00:00:00.000Z",
|
||||
updatedAt: "2026-08-29T00:00:00.000Z",
|
||||
data: { dataUrl: "opaque://legacy", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
|
||||
} as Asset;
|
||||
|
||||
describe("asset storage revision", () => {
|
||||
test("normalizes missing and malformed tags on legacy assets", () => {
|
||||
expect(normalizeAssetRecord(legacyAsset).tags).toEqual([]);
|
||||
expect(normalizeAssetRecord({ ...legacyAsset, tags: ["角色", 1, null] } as unknown as Asset).tags).toEqual(["角色"]);
|
||||
});
|
||||
|
||||
test("normalizes legacy assets while parsing persisted storage", () => {
|
||||
const document = parseAssetStorageDocument(JSON.stringify({
|
||||
state: { assets: [legacyAsset] },
|
||||
version: 1,
|
||||
storageRevision: 3,
|
||||
tombstones: { assets: {} },
|
||||
}));
|
||||
|
||||
expect(document.state.assets[0]?.tags).toEqual([]);
|
||||
expect(() => document.state.assets[0]?.tags.join(" ")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -54,4 +54,26 @@ describe("canvas resource mention editor", () => {
|
||||
expect(configComposer).not.toContain("result += `@[node:");
|
||||
expect(project).not.toContain("removeCanvasResourceMention");
|
||||
});
|
||||
|
||||
test("anchors the mention menu to the caret instead of the textarea edge", () => {
|
||||
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
|
||||
|
||||
expect(component).toContain("cursorOffset={mention.end}");
|
||||
expect(component).toContain("mentionCaretRect(anchor, cursorOffset)");
|
||||
expect(component).toContain("textareaCaretRect(anchor, cursorOffset)");
|
||||
expect(component).toContain('transform: position.showAbove ? "translateY(-100%)" : undefined');
|
||||
expect(component).toContain('window.addEventListener("scroll", updatePosition, true)');
|
||||
});
|
||||
|
||||
test("renders skill references as descriptive workflow rows", () => {
|
||||
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
|
||||
const css = source("../src/styles/globals.css");
|
||||
|
||||
expect(component).toContain('reference.kind === "skill" ? "is-skill" : ""');
|
||||
expect(component).toContain("reference.skill?.description");
|
||||
expect(component).toContain("reference.skill?.file_count");
|
||||
expect(component).toContain("<Workflow aria-hidden />");
|
||||
expect(css).toContain(".canvas-resource-mention-item.is-skill");
|
||||
expect(css).toContain(".canvas-resource-mention-meta");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,11 @@ test("task display does not claim a submitted Dreamina receipt is actively gener
|
||||
expect(generationTaskStageLabel(officialCompleted)).toBe("官方返回状态:completed");
|
||||
expect(generationTaskStatusLabel(generating)).toBe("生成中");
|
||||
expect(generationTaskShowsProgress(generating)).toBe(true);
|
||||
|
||||
for (const stage of ["等待队列调度", "后端接管任务", "正在连接上游", "调用生成模型"]) {
|
||||
expect(generationTaskShowsProgress({ ...generating, provider: "managed", stage })).toBe(false);
|
||||
}
|
||||
expect(generationTaskShowsProgress({ ...generating, provider: "managed", stage: "上游生成中" })).toBe(true);
|
||||
expect(generationTaskStatusLabel(remoteRunning)).toBe("生成中");
|
||||
});
|
||||
|
||||
@@ -185,6 +190,14 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
|
||||
expect(createSource).not.toContain('generationStage === "submission_unknown" ? "cancelled"');
|
||||
});
|
||||
|
||||
test("Canvas fullscreen video restores large controls instead of keeping the compact node layout", async () => {
|
||||
const playerCSS = await Bun.file(new URL("../src/components/video-player.css", import.meta.url)).text();
|
||||
expect(playerCSS).toContain(".canvas-video-player-compact:not([data-fullscreen])");
|
||||
expect(playerCSS).toContain("--media-fullscreen-button-size: 52px");
|
||||
expect(playerCSS).toContain("--media-slider-track-height: 7px");
|
||||
expect(playerCSS).toContain("min-height: 64px");
|
||||
});
|
||||
|
||||
test("task center deletion accepts only local Dreamina records", async () => {
|
||||
await expect(deleteGenerationTask("backend-task-0001")).rejects.toThrow("当前任务不支持删除");
|
||||
const source = await Bun.file(new URL("../src/pages/tasks/index.tsx", import.meta.url)).text();
|
||||
|
||||
32
web/test/project-asset-linking.test.ts
Normal file
32
web/test/project-asset-linking.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { linkSelectedProjectAssets } from "@/pages/projects/detail/project-asset-linking";
|
||||
|
||||
describe("project asset batch linking", () => {
|
||||
test("links every selected asset instead of only the first one", async () => {
|
||||
const visited: string[] = [];
|
||||
const result = await linkSelectedProjectAssets(["asset-1", "asset-2", "asset-3"], async (id) => {
|
||||
visited.push(id);
|
||||
return { id };
|
||||
});
|
||||
|
||||
expect(visited).toEqual(["asset-1", "asset-2", "asset-3"]);
|
||||
expect(result.linked.map((item) => item.id)).toEqual(["asset-1", "asset-2", "asset-3"]);
|
||||
expect(result.failedCount).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps successful links and reports partial failures", async () => {
|
||||
const result = await linkSelectedProjectAssets(["asset-1", "asset-2"], async (id) => {
|
||||
if (id === "asset-2") throw new Error("不可用");
|
||||
return id;
|
||||
});
|
||||
|
||||
expect(result).toEqual({ linked: ["asset-1"], failedCount: 1 });
|
||||
});
|
||||
|
||||
test("surfaces the failure when no asset can be linked", async () => {
|
||||
await expect(linkSelectedProjectAssets(["asset-1"], async () => {
|
||||
throw new Error("素材已失效");
|
||||
})).rejects.toThrow("素材已失效");
|
||||
});
|
||||
});
|
||||
38
web/test/project-chapter-skill-runtime.test.ts
Normal file
38
web/test/project-chapter-skill-runtime.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("章节页直接生成到分镜制作,并通过共享选择器执行技能", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/projects/detail/chapters.tsx", import.meta.url)).text();
|
||||
expect(source).not.toContain("导入分镜");
|
||||
expect(source).not.toContain("importStoryboardToCanvas");
|
||||
expect(source).not.toContain("在画布中分镜");
|
||||
expect(source).toContain("生成到分镜制作");
|
||||
expect(source).toContain("replaceProjectUnitShots");
|
||||
expect(source).toContain("/storyboard`");
|
||||
expect(source).toContain('<SkillRuntimePicker profile="shortDrama"');
|
||||
expect(source.match(/<ModelPicker config=\{effectiveConfig\} capability="text"/g)).toHaveLength(2);
|
||||
expect(source).toContain("chapterAnalysisInput(selectedTextModel)");
|
||||
expect(source).toContain("const textModel = selectedTextModel");
|
||||
expect(source).toContain("generateChapterStoryboard");
|
||||
});
|
||||
|
||||
test("镜头画面使用已绑定资产的 @ 引用编辑器", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('name="plotDescription"');
|
||||
expect(source).toContain('<ShotAssetMentionTextarea variant="scene"');
|
||||
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext)");
|
||||
expect(source).toContain("<Image.PreviewGroup>");
|
||||
});
|
||||
|
||||
test("技能选择器占满表单宽度", async () => {
|
||||
const source = await Bun.file(new URL("../src/components/skills/skill-runtime-picker.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('style={{ width: "100%" }}');
|
||||
});
|
||||
|
||||
test("画布分镜生成统一经过 Skill Runtime", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-storyboard.ts", import.meta.url)).text();
|
||||
expect(source).toContain('profile: "shortDrama"');
|
||||
expect(source).toContain("skillRuntime.prepare");
|
||||
expect(source).toContain("...skillExecution.metadata");
|
||||
expect(source).not.toContain("getSkillFile");
|
||||
expect(source).not.toContain("getSkillBundle");
|
||||
});
|
||||
93
web/test/project-chapter-storyboard.test.ts
Normal file
93
web/test/project-chapter-storyboard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
chapterStoryboardAssets,
|
||||
chapterStoryboardCharacters,
|
||||
chapterStoryboardReplaceImpact,
|
||||
storyboardRowsToProjectShots,
|
||||
} from "@/pages/projects/detail/chapter-storyboard-production";
|
||||
import type { ProjectDetail } from "@/services/api/projects";
|
||||
import type { StoryboardRow } from "@/types/canvas";
|
||||
|
||||
describe("章节分镜写入分镜制作", () => {
|
||||
test("将分镜任务结果完整映射为镜头脚本", () => {
|
||||
const shots = storyboardRowsToProjectShots([storyboardRow()], projectDetail());
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0]).toMatchObject({
|
||||
title: "SC.01",
|
||||
description: "林默推门进入雨夜的旧屋。",
|
||||
durationMs: 5000,
|
||||
revision: {
|
||||
plotDescription: "林默推门进入雨夜的旧屋。",
|
||||
dialogue: "林默:有人吗?",
|
||||
shotSize: "中景",
|
||||
cameraAngle: "平视,35mm",
|
||||
cameraMovement: "缓慢推进",
|
||||
imagePrompt: "雨夜旧屋中的林默",
|
||||
videoPrompt: "林默推门,镜头缓慢推进",
|
||||
continuityNotes: "林默停在门内,右手仍扶着门把",
|
||||
actionBeats: [{ description: "0-2 秒推门;2-5 秒环顾" }],
|
||||
},
|
||||
});
|
||||
expect(shots[0].assetVersionIds).toEqual(expect.arrayContaining(["asset-version-1", "asset-version-2"]));
|
||||
expect(shots[0].revision.action).toContain("表演调度:林默谨慎推门并环顾室内");
|
||||
expect(shots[0].revision.action).toContain("声音:雨声与木门摩擦声");
|
||||
});
|
||||
|
||||
test("把已确认角色和本章待确认角色交给分镜任务", () => {
|
||||
const detail = projectDetail();
|
||||
|
||||
expect(chapterStoryboardCharacters(detail, "chapter-1")).toEqual([
|
||||
{ assetId: "character-1", versionId: "character-version-1", name: "林默", definition: { appearance: "黑色风衣" } },
|
||||
{ name: "张天昊", definition: { role: "主角" } },
|
||||
]);
|
||||
expect(chapterStoryboardAssets(detail).map((asset) => ({ id: asset.id, type: asset.type, characterVersionId: asset.characterVersionId }))).toEqual([
|
||||
{ id: "character-1", type: "character", characterVersionId: "character-version-1" },
|
||||
{ id: "prop-1", type: "image", characterVersionId: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("替换确认统计该章会被删除的关联数据", () => {
|
||||
expect(chapterStoryboardReplaceImpact(projectDetail(), "chapter-1")).toEqual({
|
||||
shotCount: 1,
|
||||
revisionCount: 1,
|
||||
referenceCount: 1,
|
||||
artifactCount: 1,
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function storyboardRow(): StoryboardRow {
|
||||
return {
|
||||
id: "row-1", shotNumber: 1, durationSeconds: 5,
|
||||
plotDescription: "林默推门进入雨夜的旧屋。", dialogue: "林默:有人吗?", characters: [{ characterName: "林默", characterAssetId: "character-1", characterVersionId: "character-version-1" }],
|
||||
narrativeIntent: "建立悬念", viewerPOV: "跟随林默", performanceBlocking: "林默谨慎推门并环顾室内",
|
||||
shotSize: "中景", emotion: "戒备", lightingAndAtmosphere: "冷色月光与室内暖光交界", audioEffects: "雨声与木门摩擦声",
|
||||
camera: "平视,35mm", motion: "缓慢推进", timeBeats: "0-2 秒推门;2-5 秒环顾",
|
||||
imageGenerationPrompt: "雨夜旧屋中的林默", videoMotionPrompt: "林默推门,镜头缓慢推进",
|
||||
mustHave: [], optionalDetails: [], continuityOut: "林默停在门内,右手仍扶着门把", negativePrompt: "画面文字", assetBindings: [{ nodeId: "prop-1", role: "prop", priority: 80 }],
|
||||
};
|
||||
}
|
||||
|
||||
function projectDetail(): ProjectDetail {
|
||||
return {
|
||||
project: { id: "project-1", userId: "user-1", name: "雨夜", type: "short_drama", aspectRatio: "16:9", sourceType: "novel", description: "", stylePresetId: "urban-live-action", status: "active", revision: 1, createdAt: "", updatedAt: "" },
|
||||
units: [], canvases: [], canvasUnitLinks: [], assetFolders: [], workflows: [],
|
||||
assets: [
|
||||
{ id: "character-1", title: "林默", mediaType: "image", category: "character", status: "ready", primaryVersionId: "asset-version-1", versionCount: 1, usages: [], position: 0, updatedAt: "", character: { versionId: "character-version-1", version: 1, definition: { appearance: "黑色风衣" }, representations: [], visualStatus: "ready", voiceStatus: "missing" } },
|
||||
{ id: "prop-1", title: "旧信封", mediaType: "image", category: "prop", status: "ready", primaryVersionId: "asset-version-2", versionCount: 1, usages: [], position: 1, previewText: "泛黄的旧信封", updatedAt: "" },
|
||||
{ id: "draft-prop", title: "未完成道具", mediaType: "image", category: "prop", status: "draft", versionCount: 0, usages: [], position: 2, updatedAt: "" },
|
||||
],
|
||||
shots: [{ id: "shot-1", projectId: "project-1", unitId: "chapter-1", currentRevisionId: "revision-1", title: "SC.01", description: "旧分镜", position: 0, durationMs: 3000, status: "draft", createdAt: "", updatedAt: "" }],
|
||||
shotRevisions: [{ id: "revision-1", shotId: "shot-1", version: 1, plotDescription: "旧分镜", action: "", dialogue: "", shotSize: "", cameraAngle: "", cameraMovement: "", durationMs: 3000, imagePrompt: "", videoPrompt: "", negativePrompt: "", continuityNotes: "", actionBeatsJson: "[]", createdAt: "" }],
|
||||
shotArtifacts: [{ id: "artifact-1", projectId: "project-1", unitId: "chapter-1", shotId: "shot-1", type: "storyboard", version: 1, status: "ready", selected: true, metadataJson: "{}", createdAt: "", updatedAt: "" }],
|
||||
shotReferences: [{ id: "reference-1", shotId: "shot-1", assetVersionId: "asset-version-1", role: "reference", status: "linked", createdAt: "" }],
|
||||
assetCandidates: [
|
||||
{ id: "candidate-1", projectId: "project-1", unitId: "chapter-1", shotId: "shot-1", name: "雨伞", category: "prop", status: "pending_confirmation", detailsJson: "{}", createdAt: "", updatedAt: "" },
|
||||
{ id: "candidate-character-1", projectId: "project-1", unitId: "chapter-1", name: "张天昊", category: "character", status: "pending_confirmation", detailsJson: JSON.stringify({ role: "主角" }), createdAt: "", updatedAt: "" },
|
||||
{ id: "candidate-character-other", projectId: "project-1", unitId: "chapter-2", name: "另一章角色", category: "character", status: "pending_confirmation", detailsJson: "{}", createdAt: "", updatedAt: "" },
|
||||
],
|
||||
};
|
||||
}
|
||||
47
web/test/project-chapter-task-recovery.test.ts
Normal file
47
web/test/project-chapter-task-recovery.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { chapterTaskIdentity } from "@/pages/projects/detail/project-chapter-ai";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
|
||||
describe("章节生成任务刷新恢复", () => {
|
||||
test("优先从任务列表的安全客户端上下文识别章节与操作", () => {
|
||||
expect(chapterTaskIdentity(task({
|
||||
clientContext: {
|
||||
domainProjectId: "project-1",
|
||||
chapterId: "chapter-1",
|
||||
chapterOperation: "characters",
|
||||
},
|
||||
}))).toEqual({ chapterId: "chapter-1", kind: "characters" });
|
||||
});
|
||||
|
||||
test("任务详情可从脱敏输入 metadata 恢复分镜操作", () => {
|
||||
expect(chapterTaskIdentity(task({
|
||||
inputJson: JSON.stringify({
|
||||
metadata: {
|
||||
domainProjectId: "project-1",
|
||||
chapterId: "chapter-2",
|
||||
source: "short-drama-chapter-storyboard",
|
||||
},
|
||||
}),
|
||||
}))).toEqual({ chapterId: "chapter-2", kind: "storyboard" });
|
||||
});
|
||||
|
||||
test("无关、缺少章节或损坏的任务输入不会关联章节按钮", () => {
|
||||
expect(chapterTaskIdentity(task({ inputJson: "{" }))).toBeNull();
|
||||
expect(chapterTaskIdentity(task({ inputJson: JSON.stringify({ metadata: { operation: "chapter_character_breakdown" } }) }))).toBeNull();
|
||||
expect(chapterTaskIdentity(task({ inputJson: JSON.stringify({ metadata: { chapterId: "chapter-1", operation: "other" } }) }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function task(overrides: Partial<GenerationTask>): GenerationTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
type: "text",
|
||||
status: "running",
|
||||
prompt: "",
|
||||
attempts: 1,
|
||||
createdAt: "2026-08-29T00:00:00.000Z",
|
||||
updatedAt: "2026-08-29T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
31
web/test/project-character-voice-formats.test.ts
Normal file
31
web/test/project-character-voice-formats.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { audioFileExtension, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
|
||||
|
||||
describe("角色声音格式", () => {
|
||||
test("接受浏览器可试听的常用音频格式", () => {
|
||||
for (const [name, type] of [
|
||||
["voice.mp3", "audio/mpeg"],
|
||||
["voice.wav", "audio/x-wav"],
|
||||
["voice.m4a", "audio/mp4"],
|
||||
["voice.aac", "audio/aac"],
|
||||
["voice.flac", "audio/flac"],
|
||||
["voice.ogg", "audio/ogg"],
|
||||
["voice.opus", "audio/opus"],
|
||||
["voice.weba", "audio/webm"],
|
||||
]) expect(isSupportedCharacterVoiceFile({ name, type })).toBe(true);
|
||||
});
|
||||
|
||||
test("仅在 MIME 未知时使用扩展名兜底", () => {
|
||||
expect(isSupportedCharacterVoiceFile({ name: "voice.wav", type: "" })).toBe(true);
|
||||
expect(isSupportedCharacterVoiceFile({ name: "video.webm", type: "video/webm" })).toBe(false);
|
||||
expect(isSupportedCharacterVoiceFile({ name: "voice.wma", type: "audio/x-ms-wma" })).toBe(false);
|
||||
});
|
||||
|
||||
test("生成正确的标题和参考音频扩展名", () => {
|
||||
expect(characterVoiceTitleFromFileName("张振天原声.FLAC")).toBe("张振天原声");
|
||||
expect(audioFileExtension("audio/mp4", "sample.m4a")).toBe("m4a");
|
||||
expect(audioFileExtension("audio/ogg", "sample.opus")).toBe("opus");
|
||||
expect(audioFileExtension("audio/wav")).toBe("wav");
|
||||
});
|
||||
});
|
||||
141
web/test/skill-runtime.test.ts
Normal file
141
web/test/skill-runtime.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { Skill, SkillPackageFile, SkillPackageFileContent } from "../src/services/api/skills";
|
||||
import { createSkillRuntime, resolveSkillMentions } from "../src/services/skill-runtime";
|
||||
|
||||
function skill(overrides: Partial<Skill> = {}): Skill {
|
||||
return {
|
||||
skill_id: "director",
|
||||
skill_name: "AI导演",
|
||||
description: "导演工作流",
|
||||
version_id: "version-2",
|
||||
version: "2.0.0",
|
||||
content_hash: "hash",
|
||||
file_count: 5,
|
||||
total_bytes: 1024,
|
||||
source_type: "zip",
|
||||
source_url: "",
|
||||
source_ref: "",
|
||||
source_subdir: "",
|
||||
source_commit: "",
|
||||
sync_status: "synced",
|
||||
auto_update: false,
|
||||
last_checked_at: 0,
|
||||
last_synced_at: 0,
|
||||
status: 1,
|
||||
markdown_url: "",
|
||||
create_time: 0,
|
||||
update_time: 0,
|
||||
source: 0,
|
||||
tag: "影视",
|
||||
sort_weight: 0,
|
||||
is_private: true,
|
||||
like_count: 0,
|
||||
is_like: false,
|
||||
owner_uid: "user",
|
||||
effective_user: { name: "用户", avatar_url: "", uid: "user" },
|
||||
original_skill_id: null,
|
||||
showcase_media: [],
|
||||
added_count: 1,
|
||||
is_test: false,
|
||||
extra_info: "",
|
||||
is_added: true,
|
||||
is_owner: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function file(path: string, content: string, kind: SkillPackageFile["kind"] = "markdown"): SkillPackageFileContent {
|
||||
return {
|
||||
file: { path, kind, mime_type: "text/markdown", size: content.length, sha256: `sha-${path}` },
|
||||
content,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("skill runtime", () => {
|
||||
test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => {
|
||||
const director = skill();
|
||||
const storyboard = skill({ skill_id: "storyboard", skill_name: "小说转分镜" });
|
||||
|
||||
expect(resolveSkillMentions("用 @[skill:director] 处理", [director, storyboard]).map((item) => item.skill_id)).toEqual(["director"]);
|
||||
expect(resolveSkillMentions("请用 @小说转分镜。", [director, storyboard]).map((item) => item.skill_id)).toEqual(["storyboard"]);
|
||||
expect(resolveSkillMentions("@AI导演增强版", [director])).toEqual([]);
|
||||
});
|
||||
|
||||
test("普通生成只加载入口和与当前任务最相关的直接引用文本", async () => {
|
||||
const entry = [
|
||||
"# AI导演",
|
||||
"视频提示词读取 `references/prompt_templates.md`。",
|
||||
"角色资产读取 [角色规则](references/character_assets.md)。",
|
||||
"维护脚本见 `scripts/audit_skill.py`。",
|
||||
"项目模板见 `assets/project-ledger-template.md`。",
|
||||
].join("\n");
|
||||
const files: SkillPackageFile[] = [
|
||||
file("SKILL.md", entry).file,
|
||||
file("references/prompt_templates.md", "视频提示词模板正文").file,
|
||||
file("references/character_assets.md", "角色资产正文").file,
|
||||
file("scripts/audit_skill.py", "print('audit')", "code").file,
|
||||
file("assets/project-ledger-template.md", "台账模板").file,
|
||||
];
|
||||
const readPaths: string[] = [];
|
||||
const runtime = createSkillRuntime({
|
||||
getFile: async (_id, path) => {
|
||||
readPaths.push(path);
|
||||
if (path === "SKILL.md") return { file: file(path, entry) };
|
||||
return { file: file(path, path.includes("prompt_templates") ? "视频提示词模板正文" : "角色资产正文") };
|
||||
},
|
||||
listFiles: async () => ({ files }),
|
||||
searchFiles: async () => ({ results: [] }),
|
||||
getBundle: async () => { throw new Error("不应读取完整包"); },
|
||||
});
|
||||
|
||||
const result = await runtime.prepare({ profile: "canvas", prompt: "@[skill:director] 帮我生成视频提示词", skills: [skill()] });
|
||||
|
||||
expect(result.prompt).toContain('<skill-file path="SKILL.md">');
|
||||
expect(result.prompt).toContain('<skill-file path="references/prompt_templates.md">');
|
||||
expect(readPaths).not.toContain("references/character_assets.md");
|
||||
expect(readPaths).not.toContain("scripts/audit_skill.py");
|
||||
expect(readPaths).not.toContain("assets/project-ledger-template.md");
|
||||
expect(result.prompt).toContain("【用户任务】\n@AI导演 帮我生成视频提示词");
|
||||
expect(result.metadata.skillIds).toEqual(["director"]);
|
||||
});
|
||||
|
||||
test("本地 Agent 通过同一 Runtime 投递完整原生技能包", async () => {
|
||||
const runtime = createSkillRuntime({
|
||||
getFile: async () => { throw new Error("不应读取单文件"); },
|
||||
listFiles: async () => ({ files: [] }),
|
||||
searchFiles: async () => ({ results: [] }),
|
||||
getBundle: async () => ({
|
||||
bundle: {
|
||||
skill_id: "director",
|
||||
name: "AI导演",
|
||||
description: "导演工作流",
|
||||
version_id: "version-2",
|
||||
version: "2.0.0",
|
||||
content_hash: "hash",
|
||||
files: [{ path: "SKILL.md", mime_type: "text/markdown", content_base64: "IyBBSuWvv+a8lA==" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runtime.prepare({ profile: "localAgent", prompt: "@[skill:director] 开始", skills: [skill()] });
|
||||
|
||||
expect(result.prompt).toBe("@AI导演 开始");
|
||||
expect(result.skills).toEqual([{ skillId: "director", name: "AI导演", description: "导演工作流", version: "2.0.0", files: [{ path: "SKILL.md", mimeType: "text/markdown", contentBase64: "IyBBSuWvv+a8lA==" }] }]);
|
||||
});
|
||||
|
||||
test("在线 Agent 的技能工具由 Runtime 注册表统一执行", async () => {
|
||||
const runtime = createSkillRuntime({
|
||||
getFile: async () => ({ file: file("SKILL.md", "# AI导演") }),
|
||||
listFiles: async () => ({ files: [] }),
|
||||
searchFiles: async () => ({ results: [] }),
|
||||
getBundle: async () => { throw new Error("不应读取完整包"); },
|
||||
});
|
||||
|
||||
expect(runtime.agentToolNames("onlineAgent").has("canvas_get_skill")).toBe(true);
|
||||
const result = await runtime.executeAgentTool("onlineAgent", "canvas_get_skill", { skillId: "director" }, [skill()]);
|
||||
expect(result?.ok).toBe(true);
|
||||
expect(result && "data" in result ? result.data : null).toMatchObject({ skillId: "director", version: "2.0.0" });
|
||||
});
|
||||
});
|
||||
19
web/test/workflow-shot-delete.test.ts
Normal file
19
web/test/workflow-shot-delete.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("镜头编辑器在保存按钮左侧提供带确认的删除操作", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
|
||||
const deleteButton = source.indexOf(">删除镜头</Button>");
|
||||
const saveButton = source.indexOf(">保存脚本</Button>");
|
||||
|
||||
expect(source).toContain("deleteProjectShot(projectId, shotId)");
|
||||
expect(source).toContain("脚本版本、资产引用和生成产物都会被删除");
|
||||
expect(source).toContain("okButtonProps: { danger: true }");
|
||||
expect(deleteButton).toBeGreaterThan(-1);
|
||||
expect(saveButton).toBeGreaterThan(deleteButton);
|
||||
});
|
||||
|
||||
test("镜头删除 API 使用项目和镜头双重路径作用域", async () => {
|
||||
const source = await Bun.file(new URL("../src/services/api/projects.ts", import.meta.url)).text();
|
||||
|
||||
expect(source).toContain("api.delete(`/projects/${encodeURIComponent(projectId)}/shots/${encodeURIComponent(shotId)}`)");
|
||||
});
|
||||
87
web/test/workflow-shot-references.test.ts
Normal file
87
web/test/workflow-shot-references.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
|
||||
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "@/pages/projects/detail/workflow-shot-references";
|
||||
|
||||
describe("workflow shot asset references", () => {
|
||||
test("builds ordered image references for bound media and characters", () => {
|
||||
const context = buildShotAssetReferenceContext(detail(), "shot-1");
|
||||
|
||||
expect(context.mentionReferences.map((reference) => reference.label)).toEqual(["雨夜街道", "林默"]);
|
||||
expect(context.referenceImages.map((image) => image.id)).toEqual(["asset-scene", "asset-character"]);
|
||||
expect(context.referenceImages[1]?.storageKey).toBe("resource:character-cover");
|
||||
});
|
||||
|
||||
test("resolves asset tokens to the submitted reference image order", () => {
|
||||
const context = buildShotAssetReferenceContext(detail(), "shot-1");
|
||||
const [scene, character] = context.mentionReferences;
|
||||
const prompt = `让 ${canvasResourceMentionToken(character)} 从 ${canvasResourceMentionToken(scene)} 走近镜头`;
|
||||
|
||||
expect(resolveShotAssetMentionPrompt(prompt, context)).toBe("让 图片2 从 图片1 走近镜头");
|
||||
});
|
||||
|
||||
test("rejects stale mentions after an asset is unbound", () => {
|
||||
const context = buildShotAssetReferenceContext(detail(), "shot-1");
|
||||
|
||||
expect(() => resolveShotAssetMentionPrompt("跟随 @[asset:missing]", context)).toThrow("未绑定到当前镜头");
|
||||
});
|
||||
});
|
||||
|
||||
function detail(): ProjectDetail {
|
||||
const scene = {
|
||||
id: "asset-scene",
|
||||
title: "雨夜街道",
|
||||
mediaType: "image",
|
||||
category: "environment",
|
||||
status: "confirmed",
|
||||
primaryVersionId: "version-scene",
|
||||
versionCount: 1,
|
||||
usages: [],
|
||||
position: 0,
|
||||
storageKey: "resource:scene-image",
|
||||
updatedAt: "2026-08-29T00:00:00Z",
|
||||
} satisfies ProjectAsset;
|
||||
const character = {
|
||||
id: "asset-character",
|
||||
title: "林默",
|
||||
mediaType: "entity",
|
||||
category: "character",
|
||||
status: "confirmed",
|
||||
primaryVersionId: "version-character",
|
||||
versionCount: 1,
|
||||
usages: [],
|
||||
position: 1,
|
||||
updatedAt: "2026-08-29T00:00:00Z",
|
||||
character: {
|
||||
versionId: "version-character",
|
||||
version: 1,
|
||||
definition: {},
|
||||
representations: [{ id: "representation-1", resourceId: "character-cover", mediaType: "image", role: "primary" }],
|
||||
visualStatus: "ready",
|
||||
voiceStatus: "missing",
|
||||
},
|
||||
} satisfies ProjectAsset;
|
||||
const audio = {
|
||||
id: "asset-audio",
|
||||
title: "雨声",
|
||||
mediaType: "audio",
|
||||
category: "other",
|
||||
status: "confirmed",
|
||||
primaryVersionId: "version-audio",
|
||||
versionCount: 1,
|
||||
usages: [],
|
||||
position: 2,
|
||||
storageKey: "resource:rain-audio",
|
||||
updatedAt: "2026-08-29T00:00:00Z",
|
||||
} satisfies ProjectAsset;
|
||||
return {
|
||||
assets: [scene, character, audio],
|
||||
shotReferences: [
|
||||
{ id: "reference-scene", shotId: "shot-1", assetVersionId: "version-scene", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:00Z" },
|
||||
{ id: "reference-character", shotId: "shot-1", assetVersionId: "version-character", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:01Z" },
|
||||
{ id: "reference-audio", shotId: "shot-1", assetVersionId: "version-audio", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:02Z" },
|
||||
{ id: "reference-other-shot", shotId: "shot-2", assetVersionId: "version-scene", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:03Z" },
|
||||
],
|
||||
} as ProjectDetail;
|
||||
}
|
||||
Reference in New Issue
Block a user