first commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
locationImage: { createMany: vi.fn() },
|
||||
globalLocationImage: { createMany: 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')
|
||||
})
|
||||
|
||||
it('seeds an initial project image slot when creating a prop asset', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
const result = await mod.createProjectLocationBackedAsset({
|
||||
novelPromotionProjectId: 'novel-project-1',
|
||||
name: 'Bronze Dagger',
|
||||
summary: 'Old bronze dagger',
|
||||
initialDescription: 'A bronze dagger with a carved handle and weathered blade',
|
||||
kind: 'prop',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: result.id,
|
||||
imageIndex: 0,
|
||||
description: 'A bronze dagger with a carved handle and weathered blade',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds multiple project image slots when explicit descriptions are provided', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
await mod.seedProjectLocationBackedImageSlots({
|
||||
locationId: 'location-1',
|
||||
descriptions: ['Night street', 'Rainy alley'],
|
||||
fallbackDescription: 'Night street',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: 'Night street',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 1,
|
||||
description: 'Rainy alley',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
|
||||
|
||||
describe('location-backed asset generation rules', () => {
|
||||
it('requires props to have a visual description before generation', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'prop-1',
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
images: [],
|
||||
}, 'prop')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows locations to generate from seeded image descriptions', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'location-1',
|
||||
name: '雨夜街道',
|
||||
summary: null,
|
||||
images: [
|
||||
{
|
||||
id: 'image-1',
|
||||
imageIndex: 0,
|
||||
description: '潮湿反光的老街',
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
previousDescription: null,
|
||||
isSelected: false,
|
||||
},
|
||||
],
|
||||
}, 'location')).toBe(true)
|
||||
})
|
||||
|
||||
it('routes prop generation through the prop branch', () => {
|
||||
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
|
||||
expect(resolveLocationBackedGenerateType('location')).toBe('location')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
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: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
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: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const deleteObjectMock = vi.hoisted(() => vi.fn())
|
||||
const resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn())
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionLocation: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
locationImage: {
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
deleteObject: deleteObjectMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/media/service', () => ({
|
||||
resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,
|
||||
}))
|
||||
|
||||
describe('project location-backed selection service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$transaction.mockImplementation(async (
|
||||
callback: (tx: {
|
||||
locationImage: {
|
||||
update: typeof prismaMock.locationImage.update
|
||||
deleteMany: typeof prismaMock.locationImage.deleteMany
|
||||
}
|
||||
novelPromotionLocation: {
|
||||
update: typeof prismaMock.novelPromotionLocation.update
|
||||
}
|
||||
}) => Promise<void>,
|
||||
) => callback({
|
||||
locationImage: prismaMock.locationImage,
|
||||
novelPromotionLocation: prismaMock.novelPromotionLocation,
|
||||
}))
|
||||
resolveStorageKeyFromMediaValueMock.mockImplementation(async (value: string) => `key:${value}`)
|
||||
deleteObjectMock.mockResolvedValue(undefined)
|
||||
prismaMock.locationImage.deleteMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.locationImage.update.mockResolvedValue(undefined)
|
||||
prismaMock.novelPromotionLocation.update.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('confirms a prop selection by keeping only the selected render', async () => {
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'prop-1',
|
||||
selectedImageId: 'prop-image-2',
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
imageUrl: 'https://example.com/prop-1.png',
|
||||
isSelected: false,
|
||||
},
|
||||
{
|
||||
id: 'prop-image-2',
|
||||
imageIndex: 1,
|
||||
imageUrl: 'https://example.com/prop-2.png',
|
||||
isSelected: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('@/lib/assets/services/project-location-backed-selection')
|
||||
|
||||
const result = await mod.confirmProjectLocationBackedSelection('prop-1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('https://example.com/prop-1.png')
|
||||
expect(deleteObjectMock).toHaveBeenCalledWith('key:https://example.com/prop-1.png')
|
||||
expect(prismaMock.locationImage.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
locationId: 'prop-1',
|
||||
id: { not: 'prop-image-2' },
|
||||
},
|
||||
})
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'prop-image-2' },
|
||||
data: {
|
||||
imageIndex: 0,
|
||||
isSelected: true,
|
||||
},
|
||||
})
|
||||
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'prop-1' },
|
||||
data: { selectedImageId: 'prop-image-2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails explicitly when confirming without a selected prop render', async () => {
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'prop-1',
|
||||
selectedImageId: null,
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
imageUrl: 'https://example.com/prop-1.png',
|
||||
isSelected: false,
|
||||
},
|
||||
{
|
||||
id: 'prop-image-2',
|
||||
imageIndex: 1,
|
||||
imageUrl: 'https://example.com/prop-2.png',
|
||||
isSelected: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('@/lib/assets/services/project-location-backed-selection')
|
||||
|
||||
await expect(mod.confirmProjectLocationBackedSelection('prop-1')).rejects.toMatchObject({
|
||||
code: 'INVALID_PARAMS',
|
||||
})
|
||||
expect(prismaMock.locationImage.deleteMany).not.toHaveBeenCalled()
|
||||
expect(deleteObjectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
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: '夜晚天台,冷风,霓虹远景',
|
||||
availableSlots: JSON.stringify([
|
||||
'天台栏杆左侧靠近边缘的位置',
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
],
|
||||
clipCharacters: [{ name: '雨' }],
|
||||
clipLocation: '天台',
|
||||
clipProps: ['青铜匕首'],
|
||||
})
|
||||
|
||||
expect(compileAssetPromptFragments(context)).toEqual({
|
||||
appearanceListText: '小雨/雨: ["初始形象"]',
|
||||
fullDescriptionText: '【小雨/雨 - 初始形象】黑色短发,校服,冷静表情',
|
||||
locationDescriptionText: '夜晚天台,冷风,霓虹远景\n\n可站位置:\n- 天台栏杆左侧靠近边缘的位置',
|
||||
propsDescriptionText: '【青铜匕首】古旧短刃,雕纹手柄',
|
||||
charactersIntroductionText: '暂无角色介绍',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user