feat: add props system and refactor asset library architecture
This commit is contained in:
@@ -10,6 +10,7 @@ export type RouteBehaviorMatrixEntry = {
|
||||
const CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string> = {
|
||||
'llm-observe-routes': 'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||
'direct-submit-routes': 'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'crud-assets-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'crud-asset-hub-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'crud-novel-promotion-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type RouteCategory =
|
||||
| 'assets'
|
||||
| 'asset-hub'
|
||||
| 'novel-promotion'
|
||||
| 'projects'
|
||||
@@ -11,6 +12,7 @@ export type RouteCategory =
|
||||
export type RouteContractGroup =
|
||||
| 'llm-observe-routes'
|
||||
| 'direct-submit-routes'
|
||||
| 'crud-assets-routes'
|
||||
| 'crud-asset-hub-routes'
|
||||
| 'crud-novel-promotion-routes'
|
||||
| 'task-infra-routes'
|
||||
@@ -52,6 +54,15 @@ const ROUTE_FILES = [
|
||||
'src/app/api/asset-hub/voices/[id]/route.ts',
|
||||
'src/app/api/asset-hub/voices/route.ts',
|
||||
'src/app/api/asset-hub/voices/upload/route.ts',
|
||||
'src/app/api/assets/[assetId]/copy/route.ts',
|
||||
'src/app/api/assets/[assetId]/generate/route.ts',
|
||||
'src/app/api/assets/[assetId]/modify-render/route.ts',
|
||||
'src/app/api/assets/[assetId]/revert-render/route.ts',
|
||||
'src/app/api/assets/[assetId]/route.ts',
|
||||
'src/app/api/assets/[assetId]/select-render/route.ts',
|
||||
'src/app/api/assets/[assetId]/update-label/route.ts',
|
||||
'src/app/api/assets/[assetId]/variants/[variantId]/route.ts',
|
||||
'src/app/api/assets/route.ts',
|
||||
'src/app/api/auth/[...nextauth]/route.ts',
|
||||
'src/app/api/auth/register/route.ts',
|
||||
'src/app/api/cos/image/route.ts',
|
||||
@@ -157,6 +168,7 @@ const ROUTE_FILES = [
|
||||
] as const
|
||||
|
||||
function resolveCategory(routeFile: string): RouteCategory {
|
||||
if (routeFile.startsWith('src/app/api/assets/')) return 'assets'
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'asset-hub'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'novel-promotion'
|
||||
if (routeFile.startsWith('src/app/api/projects/')) return 'projects'
|
||||
@@ -191,7 +203,9 @@ function resolveContractGroup(routeFile: string): RouteContractGroup {
|
||||
if (
|
||||
routeFile.endsWith('/generate-image/route.ts')
|
||||
|| routeFile.endsWith('/generate-video/route.ts')
|
||||
|| routeFile.endsWith('/generate/route.ts')
|
||||
|| routeFile.endsWith('/modify-image/route.ts')
|
||||
|| routeFile.endsWith('/modify-render/route.ts')
|
||||
|| routeFile.endsWith('/voice-design/route.ts')
|
||||
|| routeFile.endsWith('/insert-panel/route.ts')
|
||||
|| routeFile.endsWith('/lip-sync/route.ts')
|
||||
@@ -206,6 +220,7 @@ function resolveContractGroup(routeFile: string): RouteContractGroup {
|
||||
) {
|
||||
return 'direct-submit-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/assets/')) return 'crud-assets-routes'
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'crud-asset-hub-routes'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'crud-novel-promotion-routes'
|
||||
if (
|
||||
|
||||
@@ -40,6 +40,16 @@ const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionClip: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
@@ -158,14 +168,18 @@ async function invokeRouteMethod(
|
||||
const req = buildMockRequest({
|
||||
path,
|
||||
method,
|
||||
...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),
|
||||
...(method === 'GET' ? {} : { body: buildGenericBody() }),
|
||||
})
|
||||
return await handler(req, { params: Promise.resolve(params) })
|
||||
}
|
||||
|
||||
describe('api contract - crud routes (behavior)', () => {
|
||||
const routes = ROUTE_CATALOG.filter(
|
||||
(entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',
|
||||
(entry) => (
|
||||
entry.contractGroup === 'crud-assets-routes'
|
||||
|| entry.contractGroup === 'crud-asset-hub-routes'
|
||||
|| entry.contractGroup === 'crud-novel-promotion-routes'
|
||||
),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -223,9 +237,36 @@ describe('api contract - crud routes (behavior)', () => {
|
||||
id: 'clip-1',
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
})
|
||||
prismaMock.novelPromotionStoryboard.findUnique.mockResolvedValue({
|
||||
id: 'storyboard-1',
|
||||
projectId: 'project-1',
|
||||
})
|
||||
prismaMock.novelPromotionStoryboard.update.mockResolvedValue({
|
||||
id: 'storyboard-1',
|
||||
panelCount: 1,
|
||||
})
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
})
|
||||
prismaMock.novelPromotionPanel.update.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
})
|
||||
prismaMock.novelPromotionPanel.create.mockResolvedValue({
|
||||
id: 'panel-2',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
})
|
||||
prismaMock.novelPromotionPanel.count.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
it('crud route group exists', () => {
|
||||
@@ -329,11 +370,9 @@ describe('api contract - crud routes (behavior)', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }
|
||||
const payload = await res.json() as { success: boolean }
|
||||
expect(payload).toEqual({
|
||||
success: true,
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'https://signed.example/cos/char-1.png',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,6 +413,7 @@ describe('api contract - crud routes (behavior)', () => {
|
||||
body: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
@@ -388,9 +428,42 @@ describe('api contract - crud routes (behavior)', () => {
|
||||
data: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('PUT /novel-promotion/[projectId]/panel writes provided props to prisma.novelPromotionPanel.update', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/panel/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/panel',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify(['Alice']),
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
description: 'panel description',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PUT(req, {
|
||||
params: Promise.resolve({ projectId: 'project-1' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify(['Alice']),
|
||||
props: JSON.stringify(['Bronze Dagger']),
|
||||
description: 'panel description',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -288,11 +288,12 @@ vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
function toApiPath(routeFile: string): string {
|
||||
function toApiPath(routeFile: string, params?: Record<string, string>): string {
|
||||
return routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
.replace('[projectId]', 'project-1')
|
||||
.replace('[projectId]', params?.projectId || 'project-1')
|
||||
.replace('[assetId]', params?.assetId || 'asset-1')
|
||||
}
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
@@ -321,6 +322,62 @@ const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
||||
expectedTargetType: 'GlobalCharacterAppearance',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/assets/[assetId]/generate/route.ts',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'character',
|
||||
appearanceIndex: 0,
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
params: { assetId: 'global-character-1' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/assets/[assetId]/generate/route.ts',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
},
|
||||
params: { assetId: 'character-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/assets/[assetId]/modify-render/route.ts',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'character',
|
||||
modifyPrompt: 'sharpen details',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 0,
|
||||
extraImageUrls: ['https://example.com/ref-a.png'],
|
||||
},
|
||||
params: { assetId: 'global-character-1' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,
|
||||
expectedTargetType: 'GlobalCharacterAppearance',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/assets/[assetId]/modify-render/route.ts',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'enhance texture',
|
||||
extraImageUrls: ['https://example.com/ref-b.png'],
|
||||
},
|
||||
params: { assetId: 'character-1' },
|
||||
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/voice-design/route.ts',
|
||||
body: { voicePrompt: 'female calm narrator', previewText: '你好世界' },
|
||||
@@ -466,7 +523,7 @@ async function invokePostRoute(routeCase: DirectRouteCase): Promise<Response> {
|
||||
const mod = await import(modulePath)
|
||||
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
||||
const req = buildMockRequest({
|
||||
path: toApiPath(routeCase.routeFile),
|
||||
path: toApiPath(routeCase.routeFile, routeCase.params),
|
||||
method: 'POST',
|
||||
body: routeCase.body,
|
||||
})
|
||||
@@ -486,7 +543,7 @@ describe('api contract - direct submit routes (behavior)', () => {
|
||||
})
|
||||
|
||||
it('keeps expected coverage size', () => {
|
||||
expect(DIRECT_CASES.length).toBe(16)
|
||||
expect(DIRECT_CASES.length).toBe(20)
|
||||
})
|
||||
|
||||
for (const routeCase of DIRECT_CASES) {
|
||||
|
||||
374
tests/integration/api/specific/assets-route.test.ts
Normal file
374
tests/integration/api/specific/assets-route.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const readAssetsMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetRenderLabelMock = vi.hoisted(() => vi.fn())
|
||||
const submitAssetGenerateTaskMock = vi.hoisted(() => vi.fn())
|
||||
const copyAssetFromGlobalMock = vi.hoisted(() => vi.fn())
|
||||
const createAssetMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetMock = vi.hoisted(() => vi.fn())
|
||||
const removeAssetMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetVariantMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/assets/services/read-assets', () => ({
|
||||
readAssets: readAssetsMock,
|
||||
}))
|
||||
vi.mock('@/lib/assets/services/asset-label', () => ({
|
||||
updateAssetRenderLabel: updateAssetRenderLabelMock,
|
||||
}))
|
||||
vi.mock('@/lib/assets/services/asset-actions', () => ({
|
||||
createAsset: createAssetMock,
|
||||
submitAssetGenerateTask: submitAssetGenerateTaskMock,
|
||||
copyAssetFromGlobal: copyAssetFromGlobalMock,
|
||||
updateAsset: updateAssetMock,
|
||||
removeAsset: removeAssetMock,
|
||||
updateAssetVariant: updateAssetVariantMock,
|
||||
submitAssetModifyTask: vi.fn(),
|
||||
selectAssetRender: vi.fn(),
|
||||
revertAssetRender: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('api specific - unified assets routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readAssetsMock.mockResolvedValue([{ id: 'asset-1', kind: 'character' }])
|
||||
updateAssetRenderLabelMock.mockResolvedValue(undefined)
|
||||
submitAssetGenerateTaskMock.mockResolvedValue({ success: true, taskId: 'task-1' })
|
||||
copyAssetFromGlobalMock.mockResolvedValue({ success: true })
|
||||
createAssetMock.mockResolvedValue({ success: true, assetId: 'prop-1' })
|
||||
updateAssetMock.mockResolvedValue({ success: true })
|
||||
removeAssetMock.mockResolvedValue({ success: true })
|
||||
updateAssetVariantMock.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('GET /api/assets reads global assets with the authenticated user scope', async () => {
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets?scope=global&kind=character',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireUserAuth).toHaveBeenCalled()
|
||||
expect(readAssetsMock).toHaveBeenCalledWith({
|
||||
scope: 'global',
|
||||
projectId: null,
|
||||
folderId: null,
|
||||
kind: 'character',
|
||||
}, {
|
||||
userId: 'user-1',
|
||||
})
|
||||
expect(body).toEqual({ assets: [{ id: 'asset-1', kind: 'character' }] })
|
||||
})
|
||||
|
||||
it('GET /api/assets reads prop assets through the unified filter contract', async () => {
|
||||
readAssetsMock.mockResolvedValue([{ id: 'prop-1', kind: 'prop' }])
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets?scope=project&projectId=project-1&kind=prop',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
|
||||
expect(readAssetsMock).toHaveBeenCalledWith({
|
||||
scope: 'project',
|
||||
projectId: 'project-1',
|
||||
folderId: null,
|
||||
kind: 'prop',
|
||||
})
|
||||
expect(body).toEqual({ assets: [{ id: 'prop-1', kind: 'prop' }] })
|
||||
})
|
||||
|
||||
it('POST /api/assets creates a project prop through the centralized asset action service', async () => {
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(createAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true, assetId: 'prop-1' })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/update-label forwards to the centralized label service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/update-label/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/asset-1/update-label',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
newName: '林夏',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'asset-1' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuth).toHaveBeenCalledWith('project-1')
|
||||
expect(updateAssetRenderLabelMock).toHaveBeenCalledWith({
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
assetId: 'asset-1',
|
||||
projectId: 'project-1',
|
||||
newName: '林夏',
|
||||
})
|
||||
})
|
||||
|
||||
it('PATCH /api/assets/[assetId] updates a global prop through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'prop',
|
||||
name: '青铜短刃',
|
||||
summary: '更锋利的版本',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireUserAuth).toHaveBeenCalled()
|
||||
expect(updateAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'prop',
|
||||
name: '青铜短刃',
|
||||
summary: '更锋利的版本',
|
||||
},
|
||||
access: {
|
||||
scope: 'global',
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('DELETE /api/assets/[assetId] removes a project prop through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1',
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.DELETE(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(removeAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/generate forwards project asset generation to the unified service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/asset-1/generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'asset-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
|
||||
expect(submitAssetGenerateTaskMock).toHaveBeenCalledWith({
|
||||
request: req,
|
||||
kind: 'character',
|
||||
assetId: 'asset-1',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 2,
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true, taskId: 'task-1' })
|
||||
})
|
||||
|
||||
it('PATCH /api/assets/[assetId]/variants/[variantId] updates a prop variant through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/variants/[variantId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1/variants/prop-image-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1', variantId: 'prop-image-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(updateAssetVariantMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
variantId: 'prop-image-1',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/novel-promotion/[projectId]/copy-from-global delegates to the centralized copy service', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/copy-from-global/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/copy-from-global',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'voice',
|
||||
targetId: 'character-1',
|
||||
globalAssetId: 'voice-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ projectId: 'project-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
|
||||
kind: 'voice',
|
||||
targetId: 'character-1',
|
||||
globalAssetId: 'voice-1',
|
||||
access: {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/copy delegates prop copy to the centralized copy service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/copy/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-target-1/copy',
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
globalAssetId: 'prop-global-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-target-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
targetId: 'prop-target-1',
|
||||
globalAssetId: 'prop-global-1',
|
||||
access: {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
47
tests/unit/assets/location-backed-assets.test.ts
Normal file
47
tests/unit/assets/location-backed-assets.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
describe('location-backed assets service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'location-1',
|
||||
novelPromotionProjectId: 'novel-project-1',
|
||||
name: 'Bronze Dagger',
|
||||
summary: 'Old bronze dagger',
|
||||
selectedImageId: null,
|
||||
sourceGlobalLocationId: null,
|
||||
assetKind: 'prop',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
})
|
||||
|
||||
it('queries project location-backed assets with real schema column names', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
await mod.listProjectLocationBackedAssets('novel-project-1', 'prop')
|
||||
|
||||
const assetQuery = prismaMock.$queryRaw.mock.calls[0]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
|
||||
const imageQuery = prismaMock.$queryRaw.mock.calls[1]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
|
||||
const assetSql = assetQuery.strings?.join(' ') ?? assetQuery.sql ?? ''
|
||||
const imageSql = imageQuery.strings?.join(' ') ?? imageQuery.sql ?? ''
|
||||
|
||||
expect(assetSql).toContain('FROM novel_promotion_locations')
|
||||
expect(assetSql).toContain('novelPromotionProjectId')
|
||||
expect(assetSql).not.toContain('projectId')
|
||||
expect(imageSql).toContain('FROM location_images')
|
||||
expect(imageSql).toContain('NULL AS previousImageMediaId')
|
||||
})
|
||||
})
|
||||
129
tests/unit/assets/mappers.test.ts
Normal file
129
tests/unit/assets/mappers.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapGlobalVoiceToAsset, mapProjectCharacterToAsset, mapProjectPropToAsset } from '@/lib/assets/mappers'
|
||||
import { groupAssetsByKind } from '@/lib/assets/grouping'
|
||||
|
||||
describe('asset mappers', () => {
|
||||
it('maps project characters into the unified character asset contract', () => {
|
||||
const asset = mapProjectCharacterToAsset({
|
||||
id: 'character-1',
|
||||
name: '林夏',
|
||||
introduction: '主角',
|
||||
voiceType: 'custom',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/voice.mp3',
|
||||
media: null,
|
||||
profileConfirmed: true,
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: '初始形象',
|
||||
description: '短发,风衣',
|
||||
imageUrl: 'https://example.com/char.jpg',
|
||||
media: null,
|
||||
imageUrls: ['https://example.com/char.jpg'],
|
||||
imageMedias: [],
|
||||
selectedIndex: 0,
|
||||
previousImageUrl: null,
|
||||
previousMedia: null,
|
||||
previousImageUrls: [],
|
||||
previousImageMedias: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(asset).toEqual(expect.objectContaining({
|
||||
id: 'character-1',
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
introduction: '主角',
|
||||
profileConfirmed: true,
|
||||
voice: expect.objectContaining({
|
||||
voiceType: 'custom',
|
||||
voiceId: 'voice-1',
|
||||
}),
|
||||
}))
|
||||
expect(asset.variants[0]).toEqual(expect.objectContaining({
|
||||
id: 'appearance-1',
|
||||
index: 0,
|
||||
label: '初始形象',
|
||||
}))
|
||||
})
|
||||
|
||||
it('maps global voices into the unified audio asset contract', () => {
|
||||
const asset = mapGlobalVoiceToAsset({
|
||||
id: 'voice-1',
|
||||
name: '旁白',
|
||||
description: '低沉稳重',
|
||||
voiceId: 'voice-provider-1',
|
||||
voiceType: 'designed',
|
||||
customVoiceUrl: 'https://example.com/voice.mp3',
|
||||
media: null,
|
||||
voicePrompt: '低沉稳重',
|
||||
gender: 'male',
|
||||
language: 'zh',
|
||||
folderId: 'folder-1',
|
||||
})
|
||||
|
||||
expect(asset).toEqual(expect.objectContaining({
|
||||
id: 'voice-1',
|
||||
scope: 'global',
|
||||
kind: 'voice',
|
||||
voiceMeta: expect.objectContaining({
|
||||
voiceType: 'designed',
|
||||
gender: 'male',
|
||||
language: 'zh',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('maps project props into the unified visual asset contract and groups them by kind', () => {
|
||||
const propAsset = mapProjectPropToAsset({
|
||||
id: 'prop-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
imageUrl: 'https://example.com/prop.jpg',
|
||||
media: null,
|
||||
previousImageUrl: null,
|
||||
previousMedia: null,
|
||||
isSelected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const voiceAsset = mapGlobalVoiceToAsset({
|
||||
id: 'voice-1',
|
||||
name: '旁白',
|
||||
description: '低沉稳重',
|
||||
voiceId: 'voice-provider-1',
|
||||
voiceType: 'designed',
|
||||
customVoiceUrl: 'https://example.com/voice.mp3',
|
||||
media: null,
|
||||
voicePrompt: '低沉稳重',
|
||||
gender: 'male',
|
||||
language: 'zh',
|
||||
folderId: 'folder-1',
|
||||
})
|
||||
|
||||
expect(propAsset).toEqual(expect.objectContaining({
|
||||
id: 'prop-1',
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
selectedVariantId: 'prop-image-1',
|
||||
}))
|
||||
expect(propAsset.variants[0]).toEqual(expect.objectContaining({
|
||||
id: 'prop-image-1',
|
||||
index: 0,
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
}))
|
||||
|
||||
const groups = groupAssetsByKind([propAsset, voiceAsset])
|
||||
expect(groups.prop.map((asset) => asset.id)).toEqual(['prop-1'])
|
||||
expect(groups.voice.map((asset) => asset.id)).toEqual(['voice-1'])
|
||||
})
|
||||
})
|
||||
50
tests/unit/assets/prompt-context.test.ts
Normal file
50
tests/unit/assets/prompt-context.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPromptAssetContext, compileAssetPromptFragments } from '@/lib/assets/services/asset-prompt-context'
|
||||
|
||||
describe('asset prompt context', () => {
|
||||
it('compiles subject, environment, and prop prompt fragments from the centralized asset context', () => {
|
||||
const context = buildPromptAssetContext({
|
||||
characters: [
|
||||
{
|
||||
name: '小雨/雨',
|
||||
appearances: [
|
||||
{
|
||||
changeReason: '初始形象',
|
||||
descriptions: ['黑色短发,校服,冷静表情'],
|
||||
selectedIndex: 0,
|
||||
description: 'fallback description',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
name: '天台',
|
||||
images: [
|
||||
{
|
||||
isSelected: true,
|
||||
description: '夜晚天台,冷风,霓虹远景',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
],
|
||||
clipCharacters: [{ name: '雨' }],
|
||||
clipLocation: '天台',
|
||||
clipProps: ['青铜匕首'],
|
||||
})
|
||||
|
||||
expect(compileAssetPromptFragments(context)).toEqual({
|
||||
appearanceListText: '小雨/雨: ["初始形象"]',
|
||||
fullDescriptionText: '【小雨/雨 - 初始形象】黑色短发,校服,冷静表情',
|
||||
locationDescriptionText: '夜晚天台,冷风,霓虹远景',
|
||||
propsDescriptionText: '【青铜匕首】古旧短刃,雕纹手柄',
|
||||
charactersIntroductionText: '暂无角色介绍',
|
||||
})
|
||||
})
|
||||
})
|
||||
44
tests/unit/assets/registry.test.ts
Normal file
44
tests/unit/assets/registry.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assetKindRegistry, getAssetKindRegistration } from '@/lib/assets/kinds/registry'
|
||||
|
||||
describe('asset kind registry', () => {
|
||||
it('declares the supported asset kinds with stable capability contracts', () => {
|
||||
expect(Object.keys(assetKindRegistry)).toEqual(['character', 'location', 'prop', 'voice'])
|
||||
expect(getAssetKindRegistration('character')).toEqual(expect.objectContaining({
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
supportsMultipleVariants: true,
|
||||
supportsVoiceBinding: true,
|
||||
capabilities: expect.objectContaining({
|
||||
canGenerate: true,
|
||||
canBindVoice: true,
|
||||
}),
|
||||
}))
|
||||
expect(getAssetKindRegistration('location')).toEqual(expect.objectContaining({
|
||||
kind: 'location',
|
||||
family: 'visual',
|
||||
supportsMultipleVariants: true,
|
||||
supportsVoiceBinding: false,
|
||||
}))
|
||||
expect(getAssetKindRegistration('prop')).toEqual(expect.objectContaining({
|
||||
kind: 'prop',
|
||||
family: 'visual',
|
||||
supportsMultipleVariants: true,
|
||||
supportsVoiceBinding: false,
|
||||
capabilities: expect.objectContaining({
|
||||
canGenerate: true,
|
||||
canSelectRender: true,
|
||||
canCopyFromGlobal: true,
|
||||
}),
|
||||
}))
|
||||
expect(getAssetKindRegistration('voice')).toEqual(expect.objectContaining({
|
||||
kind: 'voice',
|
||||
family: 'audio',
|
||||
supportsMultipleVariants: false,
|
||||
capabilities: expect.objectContaining({
|
||||
canGenerate: false,
|
||||
canSelectRender: false,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -57,12 +57,14 @@ describe('project character voice mutations', () => {
|
||||
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledWith(
|
||||
'/api/novel-promotion/project-1/character-voice',
|
||||
'/api/assets/character-1',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
characterId: 'character-1',
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/audio.wav',
|
||||
|
||||
@@ -77,6 +77,7 @@ function buildAssets(selectedIndex: number | null): ProjectAssetsData {
|
||||
return {
|
||||
characters: [buildCharacter(selectedIndex)],
|
||||
locations: [] as Location[],
|
||||
props: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ function buildProject(selectedIndex: number | null): Project {
|
||||
novelPromotionData: {
|
||||
characters: [buildCharacter(selectedIndex)],
|
||||
locations: [],
|
||||
props: [],
|
||||
},
|
||||
} as unknown as Project
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { buildProjectLocationGenerateImageBody } from '@/lib/query/mutations/loc
|
||||
describe('buildProjectLocationGenerateImageBody', () => {
|
||||
it('includes artStyle when generating a project location image', () => {
|
||||
expect(buildProjectLocationGenerateImageBody({
|
||||
projectId: 'project-1',
|
||||
locationId: 'location-1',
|
||||
count: 1,
|
||||
artStyle: 'japanese-anime',
|
||||
})).toEqual({
|
||||
type: 'location',
|
||||
id: 'location-1',
|
||||
scope: 'project',
|
||||
kind: 'location',
|
||||
projectId: 'project-1',
|
||||
imageIndex: undefined,
|
||||
count: 1,
|
||||
artStyle: 'japanese-anime',
|
||||
|
||||
23
tests/unit/script-view/clip-asset-utils.test.ts
Normal file
23
tests/unit/script-view/clip-asset-utils.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAllClipsAssets, parseClipAssets } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils'
|
||||
|
||||
describe('clip asset utils', () => {
|
||||
it('parses prop names from clip JSON payloads', () => {
|
||||
const parsed = parseClipAssets({
|
||||
characters: '[{"name":"小雨","appearance":"初始形象"}]',
|
||||
location: '天台',
|
||||
props: '["青铜匕首","录音笔"]',
|
||||
})
|
||||
|
||||
expect(Array.from(parsed.propNames)).toEqual(['青铜匕首', '录音笔'])
|
||||
})
|
||||
|
||||
it('aggregates prop names across clips', () => {
|
||||
const all = getAllClipsAssets([
|
||||
{ props: '["青铜匕首"]' },
|
||||
{ props: '["录音笔","红绳手链"]' },
|
||||
])
|
||||
|
||||
expect(Array.from(all.allPropNames)).toEqual(['青铜匕首', '录音笔', '红绳手链'])
|
||||
})
|
||||
})
|
||||
43
tests/unit/script-view/selection-sync.test.ts
Normal file
43
tests/unit/script-view/selection-sync.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
reuseStringArrayIfEqual,
|
||||
reuseStringSetIfEqual,
|
||||
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync'
|
||||
|
||||
describe('script view selection sync', () => {
|
||||
it('reuses the previous array reference when ids are unchanged', () => {
|
||||
const previous = ['char-1', 'char-2']
|
||||
const next = ['char-1', 'char-2']
|
||||
|
||||
const result = reuseStringArrayIfEqual(previous, next)
|
||||
|
||||
expect(result).toBe(previous)
|
||||
})
|
||||
|
||||
it('returns the next array when ids changed', () => {
|
||||
const previous = ['char-1', 'char-2']
|
||||
const next = ['char-1', 'prop-1']
|
||||
|
||||
const result = reuseStringArrayIfEqual(previous, next)
|
||||
|
||||
expect(result).toBe(next)
|
||||
})
|
||||
|
||||
it('reuses the previous set reference when selected appearance keys are unchanged', () => {
|
||||
const previous = new Set(['char-1::Base', 'char-2::Alt'])
|
||||
const next = new Set(['char-2::Alt', 'char-1::Base'])
|
||||
|
||||
const result = reuseStringSetIfEqual(previous, next)
|
||||
|
||||
expect(result).toBe(previous)
|
||||
})
|
||||
|
||||
it('returns the next set when selected appearance keys changed', () => {
|
||||
const previous = new Set(['char-1::Base'])
|
||||
const next = new Set(['char-1::Base', 'char-2::Alt'])
|
||||
|
||||
const result = reuseStringSetIfEqual(previous, next)
|
||||
|
||||
expect(result).toBe(next)
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ const parseMock = vi.hoisted(() => ({
|
||||
chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),
|
||||
safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),
|
||||
safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),
|
||||
safeParsePropsResponse: vi.fn(() => ({ props: [] })),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
@@ -30,12 +31,15 @@ const persistMock = vi.hoisted(() => ({
|
||||
newCharacters: 0,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 0,
|
||||
newProps: 0,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
skippedProps: 0,
|
||||
})),
|
||||
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number } }) => {
|
||||
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number; newProps: number } }) => {
|
||||
args.stats.newCharacters += 1
|
||||
args.stats.newLocations += 1
|
||||
args.stats.newProps += 1
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -63,12 +67,14 @@ vi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({
|
||||
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
safeParseCharactersResponse: parseMock.safeParseCharactersResponse,
|
||||
safeParseLocationsResponse: parseMock.safeParseLocationsResponse,
|
||||
safeParsePropsResponse: parseMock.safeParsePropsResponse,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({
|
||||
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l' })),
|
||||
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l', propTemplate: 'p' })),
|
||||
buildAnalyzeGlobalPrompts: vi.fn(() => ({
|
||||
characterPrompt: 'character prompt',
|
||||
locationPrompt: 'location prompt',
|
||||
propPrompt: 'prop prompt',
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({
|
||||
@@ -105,7 +111,7 @@ describe('worker analyze-global behavior', () => {
|
||||
analysisModel: 'llm::analysis-1',
|
||||
globalAssetText: '全局设定',
|
||||
characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary', assetKind: 'location' }],
|
||||
episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],
|
||||
})
|
||||
})
|
||||
@@ -136,10 +142,13 @@ describe('worker analyze-global behavior', () => {
|
||||
newCharacters: 2,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 2,
|
||||
newProps: 2,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
skippedProps: 0,
|
||||
totalCharacters: 1,
|
||||
totalLocations: 1,
|
||||
totalProps: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,8 +137,10 @@ describe('worker analyze-novel behavior', () => {
|
||||
success: true,
|
||||
characters: [{ id: 'char-new-1' }],
|
||||
locations: [{ id: 'loc-new-1' }],
|
||||
props: [],
|
||||
characterCount: 1,
|
||||
locationCount: 1,
|
||||
propCount: 0,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('worker clips-build behavior', () => {
|
||||
summary: 'first clip',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify(['Hero']),
|
||||
props: null,
|
||||
content: 'START one END',
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('story-to-script orchestrator retry', () => {
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
@@ -44,6 +47,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
@@ -78,6 +82,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
@@ -109,6 +114,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
@@ -133,6 +139,12 @@ describe('story-to-script orchestrator retry', () => {
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return {
|
||||
text: '{"props":[]}\n{"extra":"ignored"}',
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: '[{"start":"甲在门口","end":"乙回答","summary":"片段摘要","location":"地点A","characters":["甲"]}]\n{"extra":"ignored"}',
|
||||
@@ -156,6 +168,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
@@ -182,6 +195,9 @@ describe('story-to-script orchestrator retry', () => {
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
@@ -213,6 +229,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
@@ -239,6 +256,9 @@ describe('story-to-script orchestrator retry', () => {
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_props') {
|
||||
return { text: JSON.stringify({ props: [] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
@@ -268,6 +288,7 @@ describe('story-to-script orchestrator retry', () => {
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
propPromptTemplate: '{input} {props_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ const orchestratorMock = vi.hoisted(() => ({
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),
|
||||
persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),
|
||||
persistAnalyzedProps: vi.fn(async () => [{ id: 'prop-new-1' }]),
|
||||
persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),
|
||||
}))
|
||||
const workflowLeaseMock = vi.hoisted(() => ({
|
||||
@@ -68,8 +69,9 @@ vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'a',
|
||||
NP_SELECT_LOCATION: 'b',
|
||||
NP_AGENT_CLIP: 'c',
|
||||
NP_SCREENPLAY_CONVERSION: 'd',
|
||||
NP_SELECT_PROP: 'c',
|
||||
NP_AGENT_CLIP: 'd',
|
||||
NP_SCREENPLAY_CONVERSION: 'e',
|
||||
},
|
||||
getPromptTemplate: vi.fn(() => 'prompt-template'),
|
||||
}))
|
||||
@@ -79,6 +81,7 @@ vi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({
|
||||
parseTemperature: vi.fn(() => 0.7),
|
||||
persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,
|
||||
persistAnalyzedLocations: helperMock.persistAnalyzedLocations,
|
||||
persistAnalyzedProps: helperMock.persistAnalyzedProps,
|
||||
persistClips: helperMock.persistClips,
|
||||
resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,
|
||||
}))
|
||||
@@ -128,7 +131,7 @@ describe('worker story-to-script behavior', () => {
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town', assetKind: 'location' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
@@ -140,7 +143,9 @@ describe('worker story-to-script behavior', () => {
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({
|
||||
analyzedCharacters: [{ name: 'New Hero' }],
|
||||
analyzedLocations: [{ name: 'Market' }],
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
|
||||
analyzedProps: [{ name: 'Knife', summary: 'bronze dagger' }],
|
||||
propsObject: { props: [{ name: 'Knife', summary: 'bronze dagger' }] },
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
|
||||
screenplayResults: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
@@ -152,6 +157,7 @@ describe('worker story-to-script behavior', () => {
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 1,
|
||||
screenplayFailedCount: 0,
|
||||
propCount: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -172,12 +178,13 @@ describe('worker story-to-script behavior', () => {
|
||||
screenplayFailedCount: 0,
|
||||
persistedCharacters: 1,
|
||||
persistedLocations: 1,
|
||||
persistedProps: 1,
|
||||
persistedClips: 1,
|
||||
})
|
||||
|
||||
expect(helperMock.persistClips).toHaveBeenCalledWith({
|
||||
episodeId: 'episode-1',
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
@@ -192,6 +199,8 @@ describe('worker story-to-script behavior', () => {
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({
|
||||
analyzedCharacters: [],
|
||||
analyzedLocations: [],
|
||||
analyzedProps: [],
|
||||
propsObject: { props: [] },
|
||||
clipList: [],
|
||||
screenplayResults: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user