Fix prop confirmation bug, add Wan 2.7 model, refine multiple UI details, improve prop generation quality and aspect ratio, remove text overlays from Asset Center created images, and optimize prop filtering logic
This commit is contained in:
@@ -50,6 +50,7 @@ describe('api-config preset coming soon', () => {
|
||||
.map((entry) => entry.modelId)
|
||||
|
||||
expect(modelIds).toEqual(expect.arrayContaining([
|
||||
'wan2.7-i2v',
|
||||
'wan2.6-i2v-flash',
|
||||
'wan2.6-i2v',
|
||||
'wan2.5-i2v-preview',
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('location-backed assets service', () => {
|
||||
novelPromotionProjectId: 'novel-project-1',
|
||||
name: 'Bronze Dagger',
|
||||
summary: 'Old bronze dagger',
|
||||
initialDescription: 'A bronze dagger with a carved handle and weathered blade',
|
||||
kind: 'prop',
|
||||
})
|
||||
|
||||
@@ -62,7 +63,7 @@ describe('location-backed assets service', () => {
|
||||
{
|
||||
locationId: result.id,
|
||||
imageIndex: 0,
|
||||
description: 'Old bronze dagger',
|
||||
description: 'A bronze dagger with a carved handle and weathered blade',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,13 +2,13 @@ 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('allows props to generate from summary even before any image slot exists', () => {
|
||||
it('requires props to have a visual description before generation', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'prop-1',
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
images: [],
|
||||
})).toBe(true)
|
||||
}, 'prop')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows locations to generate from seeded image descriptions', () => {
|
||||
@@ -27,7 +27,7 @@ describe('location-backed asset generation rules', () => {
|
||||
isSelected: false,
|
||||
},
|
||||
],
|
||||
})).toBe(true)
|
||||
}, 'location')).toBe(true)
|
||||
})
|
||||
|
||||
it('routes prop generation through the prop branch', () => {
|
||||
|
||||
127
tests/unit/assets/project-location-backed-selection.test.ts
Normal file
127
tests/unit/assets/project-location-backed-selection.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
155
tests/unit/components/asset-edit-modal-ai-layout.test.ts
Normal file
155
tests/unit/components/asset-edit-modal-ai-layout.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: (props: { className?: string }) => createElement('span', { className: props.className }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => createElement('span', null, 'loading'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/presentation', () => ({
|
||||
resolveTaskPresentationState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useUpdateCharacterName: () => ({ isPending: false, mutateAsync: vi.fn() }),
|
||||
useUpdateProjectCharacterName: () => ({ isPending: false, mutateAsync: vi.fn() }),
|
||||
useUpdateCharacterAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateProjectAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateProjectCharacterIntroduction: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyCharacterDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyProjectAppearanceDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateLocationName: () => ({ isPending: false, mutateAsync: vi.fn() }),
|
||||
useUpdateProjectLocationName: () => ({ isPending: false, mutateAsync: vi.fn() }),
|
||||
useUpdateLocationSummary: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateProjectLocationDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyLocationDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyProjectLocationDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyPropDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAiModifyProjectPropDescription: () => ({ mutateAsync: vi.fn() }),
|
||||
useAssetActions: () => ({
|
||||
update: vi.fn(),
|
||||
updateVariant: vi.fn(),
|
||||
generate: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
common: {
|
||||
cancel: '取消',
|
||||
},
|
||||
character: {
|
||||
name: '角色名',
|
||||
appearance: '形象',
|
||||
},
|
||||
location: {
|
||||
name: '场景名',
|
||||
description: '场景描述',
|
||||
},
|
||||
prop: {
|
||||
name: '道具名',
|
||||
summary: '简要说明',
|
||||
summaryPlaceholder: '一句话说明这是什么道具,不写剧情用途',
|
||||
description: '图片描述',
|
||||
descriptionPlaceholder: '只写道具本体的材质、颜色、结构和装饰细节',
|
||||
},
|
||||
modal: {
|
||||
editCharacter: '编辑角色',
|
||||
editLocation: '编辑场景',
|
||||
editProp: '编辑道具',
|
||||
namePlaceholder: '输入名称',
|
||||
appearancePrompt: '形象描述提示词',
|
||||
descPlaceholder: '输入描述',
|
||||
modifyDescription: 'AI修改描述',
|
||||
modifyPlaceholder: '改成夜晚',
|
||||
modifyPlaceholderCharacter: '改成黑色西装',
|
||||
modifyPlaceholderProp: '改成磨砂银质',
|
||||
saveName: '保存名字',
|
||||
saveOnly: '仅保存',
|
||||
saveAndGenerate: '保存并生成',
|
||||
introduction: '角色介绍',
|
||||
introductionPlaceholder: '输入角色介绍',
|
||||
introductionTip: '介绍角色在故事中的身份',
|
||||
},
|
||||
smartImport: {
|
||||
preview: {
|
||||
saving: '保存中',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
saveFailed: '保存失败',
|
||||
failed: '失败',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
|
||||
locale: string
|
||||
messages: AbstractIntlMessages
|
||||
timeZone: string
|
||||
children?: React.ReactNode
|
||||
}>
|
||||
|
||||
function renderWithMessages(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('asset edit modal AI layout', () => {
|
||||
it('renders character AI modify action inside the description composer instead of a standalone smart-modify card', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { CharacterEditModal } = await import('@/components/shared/assets/CharacterEditModal')
|
||||
const html = renderWithMessages(
|
||||
createElement(CharacterEditModal, {
|
||||
mode: 'project',
|
||||
characterId: 'character-1',
|
||||
characterName: '沈烬',
|
||||
description: '冷峻禁欲的男性角色形象描述',
|
||||
appearanceId: 'appearance-1',
|
||||
onClose: () => undefined,
|
||||
onSave: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('AI修改描述')
|
||||
expect(html).not.toContain('改成黑色西装')
|
||||
expect(html).not.toContain('智能修改')
|
||||
})
|
||||
|
||||
it('renders prop AI modify action with the prop-specific placeholder', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { PropEditModal } = await import('@/components/shared/assets/PropEditModal')
|
||||
const html = renderWithMessages(
|
||||
createElement(PropEditModal, {
|
||||
mode: 'project',
|
||||
propId: 'prop-1',
|
||||
propName: '遗物匕首',
|
||||
summary: '旧时代留下的金属短刃',
|
||||
description: '青铜短刃,刃面斑驳,手柄有细密雕纹',
|
||||
variantId: 'prop-variant-1',
|
||||
projectId: 'project-1',
|
||||
onClose: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('AI修改描述')
|
||||
expect(html).not.toContain('改成磨砂银质')
|
||||
expect(html).not.toContain('智能修改')
|
||||
})
|
||||
})
|
||||
243
tests/unit/components/asset-hub-card-aspect-ratio.test.ts
Normal file
243
tests/unit/components/asset-hub-card-aspect-ratio.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
|
||||
const idleMutation = {
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/query/mutations', () => ({
|
||||
useGenerateCharacterImage: () => idleMutation,
|
||||
useSelectCharacterImage: () => idleMutation,
|
||||
useUndoCharacterImage: () => idleMutation,
|
||||
useUploadCharacterImage: () => idleMutation,
|
||||
useDeleteCharacter: () => idleMutation,
|
||||
useDeleteCharacterAppearance: () => idleMutation,
|
||||
useUploadCharacterVoice: () => idleMutation,
|
||||
useGenerateLocationImage: () => idleMutation,
|
||||
useSelectLocationImage: () => idleMutation,
|
||||
useUndoLocationImage: () => idleMutation,
|
||||
useUploadLocationImage: () => idleMutation,
|
||||
useDeleteLocation: () => idleMutation,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: (props: { className?: string; name?: string }) =>
|
||||
createElement('span', { className: props.className, 'data-icon': props.name }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusOverlay', () => ({
|
||||
default: () => createElement('div', null, 'overlay'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => createElement('span', null, 'inline'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/media/MediaImageWithLoading', () => ({
|
||||
MediaImageWithLoading: (props: { containerClassName?: string; className?: string }) =>
|
||||
createElement('div', {
|
||||
className: [props.containerClassName, props.className].filter(Boolean).join(' '),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/image-generation/ImageGenerationInlineCountButton', () => ({
|
||||
default: () => createElement('button', null, 'count'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/presentation', () => ({
|
||||
resolveTaskPresentationState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-generation/use-image-generation-count', () => ({
|
||||
useImageGenerationCount: () => ({
|
||||
count: 1,
|
||||
setCount: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-generation/count', () => ({
|
||||
getImageGenerationCountOptions: () => [{ value: 1, label: '1' }],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceSettings', () => ({
|
||||
default: () => createElement('div', null, 'voice-settings'),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetHub: {
|
||||
generateFailed: '生成失败',
|
||||
selectFailed: '选择失败',
|
||||
uploadFailed: '上传失败',
|
||||
confirmDeleteLocation: '确认删除场景',
|
||||
confirmDeleteProp: '确认删除道具',
|
||||
confirmDeleteCharacter: '确认删除角色',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
propLabel: '道具',
|
||||
locationLabel: '场景',
|
||||
},
|
||||
assets: {
|
||||
image: {
|
||||
generateCountPrefix: '生成',
|
||||
generateCountSuffix: '张',
|
||||
generating: '生成中',
|
||||
generatingPlaceholder: '正在生成',
|
||||
regenerateStuck: '重新生成',
|
||||
regenCountPrefix: '重生成',
|
||||
undo: '撤回',
|
||||
upload: '上传',
|
||||
uploadReplace: '替换',
|
||||
edit: '编辑',
|
||||
selectCount: '选择数量',
|
||||
confirmOption: '确认选择',
|
||||
optionNumber: '方案 {number}',
|
||||
},
|
||||
common: {
|
||||
generateFailed: '生成失败',
|
||||
},
|
||||
location: {
|
||||
regenerateImage: '重生成场景',
|
||||
edit: '编辑场景',
|
||||
delete: '删除场景',
|
||||
},
|
||||
prop: {
|
||||
regenerateImage: '重生成道具',
|
||||
edit: '编辑道具',
|
||||
delete: '删除道具',
|
||||
},
|
||||
character: {
|
||||
deleteWhole: '删除整个角色',
|
||||
primary: '主形象',
|
||||
secondary: '子形象',
|
||||
delete: '删除角色',
|
||||
deleteOptions: '删除选项',
|
||||
},
|
||||
video: {
|
||||
panelCard: {
|
||||
editPrompt: '编辑',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
|
||||
locale: string
|
||||
messages: AbstractIntlMessages
|
||||
timeZone: string
|
||||
children?: React.ReactNode
|
||||
}>
|
||||
|
||||
function renderWithIntl(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('asset hub card aspect ratio', () => {
|
||||
it('keeps prop cards at the same 3:2 ratio as character assets while generation is running', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard')
|
||||
const html = renderWithIntl(
|
||||
createElement(LocationCard, {
|
||||
location: {
|
||||
id: 'prop-1',
|
||||
name: '鼠标',
|
||||
summary: '电脑鼠标',
|
||||
folderId: null,
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
isSelected: false,
|
||||
imageTaskRunning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
assetType: 'prop',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('aspect-[3/2]')
|
||||
expect(html).toContain('data-icon="image"')
|
||||
expect(html).not.toContain('min-h-[100px]')
|
||||
})
|
||||
|
||||
it('keeps location cards square while generation is running', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard')
|
||||
const html = renderWithIntl(
|
||||
createElement(LocationCard, {
|
||||
location: {
|
||||
id: 'location-1',
|
||||
name: '餐厅',
|
||||
summary: '极简餐厅',
|
||||
folderId: null,
|
||||
images: [
|
||||
{
|
||||
id: 'location-image-1',
|
||||
imageIndex: 0,
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
isSelected: false,
|
||||
imageTaskRunning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
assetType: 'location',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('aspect-square')
|
||||
expect(html).toContain('data-icon="image"')
|
||||
expect(html).not.toContain('min-h-[100px]')
|
||||
})
|
||||
|
||||
it('keeps character cards at the fixed 3:2 ratio while generation is running', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { CharacterCard } = await import('@/app/[locale]/workspace/asset-hub/components/CharacterCard')
|
||||
const html = renderWithIntl(
|
||||
createElement(CharacterCard, {
|
||||
character: {
|
||||
id: 'character-1',
|
||||
name: '沈烬',
|
||||
folderId: null,
|
||||
customVoiceUrl: null,
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: '默认形象',
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
imageUrls: [],
|
||||
selectedIndex: null,
|
||||
previousImageUrl: null,
|
||||
previousImageUrls: [],
|
||||
imageTaskRunning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('aspect-[3/2]')
|
||||
expect(html).not.toContain('min-h-[100px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: () => createElement('span', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusOverlay', () => ({
|
||||
default: () => createElement('div', null, 'overlay'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/media/MediaImageWithLoading', () => ({
|
||||
MediaImageWithLoading: (props: { containerClassName?: string; className?: string }) =>
|
||||
createElement('div', { className: [props.containerClassName, props.className].filter(Boolean).join(' ') }),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
common: {
|
||||
generateFailed: '生成失败',
|
||||
},
|
||||
image: {
|
||||
optionNumber: '方案 {number}',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
|
||||
locale: string
|
||||
messages: AbstractIntlMessages
|
||||
timeZone: string
|
||||
children?: React.ReactNode
|
||||
}>
|
||||
|
||||
describe('CharacterCardGallery aspect ratio', () => {
|
||||
it('renders the single-image slot at a fixed 3:2 ratio', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { default: CharacterCardGallery } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/character-card/CharacterCardGallery')
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
createElement(CharacterCardGallery, {
|
||||
mode: 'single',
|
||||
characterName: '沈烬',
|
||||
changeReason: '默认形象',
|
||||
aspectClassName: 'aspect-[3/2]',
|
||||
currentImageUrl: null,
|
||||
selectedIndex: null,
|
||||
hasMultipleImages: false,
|
||||
isAppearanceTaskRunning: true,
|
||||
displayTaskPresentation: null,
|
||||
onImageClick: () => undefined,
|
||||
overlayActions: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(html).toContain('aspect-[3/2]')
|
||||
})
|
||||
})
|
||||
143
tests/unit/components/character-section-actions.test.ts
Normal file
143
tests/unit/components/character-section-actions.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import CharacterSection from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection'
|
||||
|
||||
const useProjectAssetsMock = vi.hoisted(() => vi.fn())
|
||||
const characterCardMock = vi.hoisted(() => vi.fn((_props: unknown) => null))
|
||||
|
||||
vi.mock('@/lib/query/hooks/useProjectAssets', () => ({
|
||||
useProjectAssets: (projectId: string | null) => useProjectAssetsMock(projectId),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => characterCardMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterProfileCard', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/types/character-profile', () => ({
|
||||
parseProfileData: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/presentation', () => ({
|
||||
resolveTaskPresentationState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: (props: { name?: string; className?: string }) =>
|
||||
createElement('span', { 'data-icon': props.name, className: props.className }),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
stage: {
|
||||
characterAssets: '角色资产',
|
||||
counts: '{characterCount} 个角色,{appearanceCount} 个形象',
|
||||
pendingProfilesBanner: '待确认角色',
|
||||
pendingProfilesHint: '确认角色设定',
|
||||
confirmAll: '全部确认',
|
||||
},
|
||||
character: {
|
||||
add: '新建角色',
|
||||
assetCount: '{count} 个形象',
|
||||
copyFromGlobal: '从资产中心导入',
|
||||
delete: '删除角色',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
function renderWithIntl(node: ReactElement) {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('CharacterSection actions', () => {
|
||||
it('renders import and delete actions stacked vertically with the import icon', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
useProjectAssetsMock.mockReturnValue({
|
||||
data: {
|
||||
characters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '西装男',
|
||||
introduction: null,
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: '初始形象',
|
||||
imageUrl: null,
|
||||
imageUrls: [],
|
||||
selectedIndex: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(CharacterSection, {
|
||||
projectId: 'project-1',
|
||||
activeTaskKeys: new Set<string>(),
|
||||
onClearTaskKey: () => undefined,
|
||||
onRegisterTransientTaskKey: () => undefined,
|
||||
isAnalyzingAssets: false,
|
||||
onAddCharacter: () => undefined,
|
||||
onDeleteCharacter: () => undefined,
|
||||
onDeleteAppearance: () => undefined,
|
||||
onEditAppearance: () => undefined,
|
||||
handleGenerateImage: async () => undefined,
|
||||
onSelectImage: () => undefined,
|
||||
onConfirmSelection: () => undefined,
|
||||
onRegenerateSingle: async () => undefined,
|
||||
onRegenerateGroup: async () => undefined,
|
||||
onUndo: () => undefined,
|
||||
onImageClick: () => undefined,
|
||||
onImageEdit: () => undefined,
|
||||
onVoiceChange: () => undefined,
|
||||
onVoiceDesign: () => undefined,
|
||||
onVoiceSelectFromHub: () => undefined,
|
||||
onCopyFromGlobal: () => undefined,
|
||||
getAppearances: (character) => character.appearances,
|
||||
unconfirmedCharacters: [],
|
||||
isConfirmingCharacter: () => false,
|
||||
deletingCharacterId: null,
|
||||
batchConfirming: false,
|
||||
batchConfirmingState: null,
|
||||
onBatchConfirm: () => undefined,
|
||||
onEditProfile: () => undefined,
|
||||
onConfirmProfile: () => undefined,
|
||||
onUseExistingProfile: () => undefined,
|
||||
onDeleteProfile: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('从资产中心导入')
|
||||
expect(html).toContain('删除角色')
|
||||
expect(html).toContain('data-icon="arrowDownCircle"')
|
||||
expect(html).toContain('flex flex-col items-end gap-1.5')
|
||||
})
|
||||
})
|
||||
150
tests/unit/components/global-asset-picker-preview.test.ts
Normal file
150
tests/unit/components/global-asset-picker-preview.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
|
||||
const useQueryMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: (options: unknown) => useQueryMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/ImagePreviewModal', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/presentation', () => ({
|
||||
resolveTaskPresentationState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/media/MediaImageWithLoading', () => ({
|
||||
MediaImageWithLoading: (props: { src: string; alt: string; className?: string; containerClassName?: string }) =>
|
||||
createElement('img', {
|
||||
src: props.src,
|
||||
alt: props.alt,
|
||||
className: [props.className, props.containerClassName].filter(Boolean).join(' '),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: (props: { name?: string; className?: string }) =>
|
||||
createElement('span', { 'data-icon': props.name, className: props.className }),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetPicker: {
|
||||
selectCharacter: '从资产中心选择角色',
|
||||
selectLocation: '从资产中心选择场景',
|
||||
selectProp: '从资产中心选择道具',
|
||||
selectVoice: '从资产中心选择音色',
|
||||
searchPlaceholder: '搜索资产名称或文件夹...',
|
||||
noAssets: '资产中心暂无资产',
|
||||
createInAssetHub: '请先在资产中心创建角色/场景/音色',
|
||||
noSearchResults: '未找到匹配的资产',
|
||||
appearances: '个形象',
|
||||
images: '张图片',
|
||||
cancel: '取消',
|
||||
confirmCopy: '确认导入',
|
||||
},
|
||||
} as const
|
||||
|
||||
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
|
||||
locale: string
|
||||
messages: AbstractIntlMessages
|
||||
timeZone: string
|
||||
children?: React.ReactNode
|
||||
}>
|
||||
|
||||
describe('GlobalAssetPicker preview mapping', () => {
|
||||
it('renders the real character preview image at 3:2 without the appearance count line', async () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
useQueryMock.mockReset()
|
||||
useQueryMock.mockImplementation((options: { enabled?: boolean }) => ({
|
||||
data: options.enabled ? [{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
scope: 'global',
|
||||
name: '西装男',
|
||||
folderId: null,
|
||||
capabilities: {
|
||||
canGenerate: true,
|
||||
canSelectRender: true,
|
||||
canRevertRender: true,
|
||||
canModifyRender: true,
|
||||
canUploadRender: true,
|
||||
canBindVoice: true,
|
||||
canCopyFromGlobal: false,
|
||||
},
|
||||
taskRefs: [],
|
||||
taskState: { isRunning: false, lastError: null },
|
||||
introduction: null,
|
||||
profileData: null,
|
||||
profileConfirmed: null,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: { isRunning: false, lastError: null },
|
||||
voice: {
|
||||
voiceType: null,
|
||||
voiceId: null,
|
||||
customVoiceUrl: null,
|
||||
media: null,
|
||||
},
|
||||
variants: [{
|
||||
id: 'variant-1',
|
||||
index: 0,
|
||||
label: '默认形象',
|
||||
description: '黑西装',
|
||||
selectionState: { selectedRenderIndex: 0 },
|
||||
taskRefs: [],
|
||||
taskState: { isRunning: false, lastError: null },
|
||||
renders: [{
|
||||
id: 'render-1',
|
||||
index: 0,
|
||||
imageUrl: 'https://example.com/character.png',
|
||||
media: null,
|
||||
isSelected: true,
|
||||
previousImageUrl: null,
|
||||
previousMedia: null,
|
||||
taskRefs: [],
|
||||
taskState: { isRunning: false, lastError: null },
|
||||
}],
|
||||
}],
|
||||
}] : [],
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
|
||||
const { default: GlobalAssetPicker } = await import('@/components/shared/assets/GlobalAssetPicker')
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
createElement(GlobalAssetPicker, {
|
||||
isOpen: true,
|
||||
onClose: () => undefined,
|
||||
onSelect: () => undefined,
|
||||
type: 'character',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(html).toContain('src="https://example.com/character.png"')
|
||||
expect(html).toContain('aspect-[3/2]')
|
||||
expect(html).toContain('object-contain')
|
||||
expect(html).not.toContain('data-icon="userAlt"')
|
||||
expect(html).not.toContain('border-b')
|
||||
expect(html).not.toContain('个形象')
|
||||
})
|
||||
})
|
||||
187
tests/unit/components/location-card-ai-edit.test.ts
Normal file
187
tests/unit/components/location-card-ai-edit.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import { AI_EDIT_BUTTON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
|
||||
const locationImageListMock = vi.hoisted(() => vi.fn((props: { overlayActions?: React.ReactNode }) => createElement('div', null, props.overlayActions ?? null)))
|
||||
const uploadMutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations', () => ({
|
||||
useUploadProjectLocationImage: () => uploadMutationMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardHeader', () => ({
|
||||
default: () => createElement('div', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions', () => ({
|
||||
default: () => createElement('div', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationImageList', () => ({
|
||||
default: locationImageListMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: () => createElement('span', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons/AISparklesIcon', () => ({
|
||||
default: (props: { className?: string }) => createElement('svg', { className: props.className, 'data-icon': 'ai-sparkles' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => createElement('span', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/image-generation/ImageGenerationInlineCountButton', () => ({
|
||||
default: () => createElement('button', null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-generation/use-image-generation-count', () => ({
|
||||
useImageGenerationCount: () => ({
|
||||
count: 1,
|
||||
setCount: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-generation/count', () => ({
|
||||
getImageGenerationCountOptions: () => [{ value: 1, label: '1' }],
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/presentation', () => ({
|
||||
resolveTaskPresentationState: () => null,
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
image: {
|
||||
upload: '上传图片',
|
||||
uploadReplace: '上传替换图片',
|
||||
edit: '编辑图片',
|
||||
undo: '撤回',
|
||||
regenerateStuck: '重新生成',
|
||||
},
|
||||
location: {
|
||||
regenerateImage: '重新生成场景',
|
||||
edit: '编辑场景',
|
||||
delete: '删除场景',
|
||||
},
|
||||
prop: {
|
||||
regenerateImage: '重新生成道具',
|
||||
edit: '编辑道具',
|
||||
delete: '删除道具',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{
|
||||
locale: string
|
||||
messages: AbstractIntlMessages
|
||||
timeZone: string
|
||||
children?: React.ReactNode
|
||||
}>
|
||||
|
||||
describe('LocationCard AI edit button', () => {
|
||||
it('uses the shared AI edit button style in single-image mode', async () => {
|
||||
locationImageListMock.mockClear()
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { default: LocationCard } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard')
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
createElement(LocationCard, {
|
||||
location: {
|
||||
id: 'prop-1',
|
||||
name: '银质餐具',
|
||||
summary: '银质西式餐具套装',
|
||||
selectedImageId: 'prop-image-1',
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
description: '银质餐具套装,包含刀叉与汤匙,金属光泽冷白',
|
||||
imageUrl: 'https://example.com/prop.png',
|
||||
previousImageUrl: null,
|
||||
previousDescription: null,
|
||||
isSelected: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
assetType: 'prop',
|
||||
onEdit: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
onRegenerate: () => undefined,
|
||||
onGenerate: () => undefined,
|
||||
onImageClick: () => undefined,
|
||||
onImageEdit: () => undefined,
|
||||
projectId: 'project-1',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(html).toContain('data-icon=\"ai-sparkles\"')
|
||||
for (const token of AI_EDIT_BUTTON_CLASS.split(' ')) {
|
||||
expect(html).toContain(token)
|
||||
}
|
||||
const firstCall = locationImageListMock.mock.calls[0]?.[0] as { aspectClassName?: string } | undefined
|
||||
expect(firstCall?.aspectClassName).toBe('aspect-[3/2]')
|
||||
})
|
||||
|
||||
it('passes a square image slot to project location cards', async () => {
|
||||
locationImageListMock.mockClear()
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const { default: LocationCard } = await import('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard')
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
TestIntlProvider,
|
||||
{
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
},
|
||||
createElement(LocationCard, {
|
||||
location: {
|
||||
id: 'location-1',
|
||||
name: '餐厅',
|
||||
summary: '极简餐厅',
|
||||
selectedImageId: 'location-image-1',
|
||||
images: [
|
||||
{
|
||||
id: 'location-image-1',
|
||||
imageIndex: 0,
|
||||
description: '极简餐厅室内空间',
|
||||
imageUrl: 'https://example.com/location.png',
|
||||
previousImageUrl: null,
|
||||
previousDescription: null,
|
||||
isSelected: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
assetType: 'location',
|
||||
onEdit: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
onRegenerate: () => undefined,
|
||||
onGenerate: () => undefined,
|
||||
onImageClick: () => undefined,
|
||||
onImageEdit: () => undefined,
|
||||
projectId: 'project-1',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const firstCall = locationImageListMock.mock.calls[0]?.[0] as { aspectClassName?: string } | undefined
|
||||
expect(firstCall?.aspectClassName).toBe('aspect-square')
|
||||
})
|
||||
})
|
||||
115
tests/unit/components/location-section-prop-confirm.test.ts
Normal file
115
tests/unit/components/location-section-prop-confirm.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import LocationSection from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection'
|
||||
|
||||
const locationCardMock = vi.hoisted(() => vi.fn((_props: unknown) => null))
|
||||
const useProjectAssetsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/query/hooks/useProjectAssets', () => ({
|
||||
useProjectAssets: (projectId: string | null) => useProjectAssetsMock(projectId),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard', () => ({
|
||||
default: (props: unknown) => locationCardMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: () => null,
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
stage: {
|
||||
locationAssets: '场景资产',
|
||||
locationCounts: '{count} 个场景',
|
||||
propAssets: '道具资产',
|
||||
propCounts: '{count} 个道具',
|
||||
},
|
||||
location: {
|
||||
add: '新建场景',
|
||||
},
|
||||
prop: {
|
||||
add: '新建道具',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
function renderWithIntl(node: ReactElement) {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('LocationSection prop confirm wiring', () => {
|
||||
it('passes the confirm-selection callback through to prop cards', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
locationCardMock.mockClear()
|
||||
useProjectAssetsMock.mockReturnValue({
|
||||
data: {
|
||||
characters: [],
|
||||
locations: [],
|
||||
props: [{
|
||||
id: 'prop-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃',
|
||||
selectedImageId: 'prop-image-2',
|
||||
images: [
|
||||
{
|
||||
id: 'prop-image-1',
|
||||
imageIndex: 0,
|
||||
description: '候选 1',
|
||||
imageUrl: 'https://example.com/prop-1.png',
|
||||
isSelected: false,
|
||||
},
|
||||
{
|
||||
id: 'prop-image-2',
|
||||
imageIndex: 1,
|
||||
description: '候选 2',
|
||||
imageUrl: 'https://example.com/prop-2.png',
|
||||
isSelected: true,
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
renderWithIntl(
|
||||
createElement(LocationSection, {
|
||||
projectId: 'project-1',
|
||||
assetType: 'prop',
|
||||
activeTaskKeys: new Set<string>(),
|
||||
onClearTaskKey: () => undefined,
|
||||
onRegisterTransientTaskKey: () => undefined,
|
||||
onAddLocation: () => undefined,
|
||||
onDeleteLocation: () => undefined,
|
||||
onEditLocation: () => undefined,
|
||||
handleGenerateImage: async () => undefined,
|
||||
onSelectImage: () => undefined,
|
||||
onConfirmSelection: () => undefined,
|
||||
onRegenerateSingle: async () => undefined,
|
||||
onRegenerateGroup: async () => undefined,
|
||||
onUndo: () => undefined,
|
||||
onImageClick: () => undefined,
|
||||
onImageEdit: () => undefined,
|
||||
onCopyFromGlobal: () => undefined,
|
||||
filterIds: null,
|
||||
}),
|
||||
)
|
||||
|
||||
const firstCall = locationCardMock.mock.calls[0]?.[0] as { onConfirmSelection?: () => void } | undefined
|
||||
expect(firstCall).toBeDefined()
|
||||
expect(typeof firstCall?.onConfirmSelection).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,7 @@ describe('image reference normalization guard', () => {
|
||||
expect(
|
||||
inspectImageReferenceNormalization('src/lib/workers/handlers/bad-handler.ts', content),
|
||||
).toEqual([
|
||||
'src/lib/workers/handlers/bad-handler.ts uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos',
|
||||
'src/lib/workers/handlers/bad-handler.ts uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateProjectLabeledImageToStorage/generateCleanImageToStorage',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addCharacterPromptSuffix,
|
||||
addPropPromptSuffix,
|
||||
CHARACTER_PROMPT_SUFFIX,
|
||||
PROP_PROMPT_SUFFIX,
|
||||
removeCharacterPromptSuffix,
|
||||
removePropPromptSuffix,
|
||||
} from '@/lib/constants'
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
@@ -32,4 +35,21 @@ describe('character prompt suffix regression', () => {
|
||||
expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removeCharacterPromptSuffix('')).toBe('')
|
||||
})
|
||||
|
||||
it('appends the prop suffix exactly once', () => {
|
||||
const basePrompt = '银质餐具套装,包含刀叉与汤匙,金属光泽冷白'
|
||||
const generated = addPropPromptSuffix(basePrompt)
|
||||
|
||||
expect(generated).toContain(PROP_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(generated, PROP_PROMPT_SUFFIX)).toBe(1)
|
||||
})
|
||||
|
||||
it('removes the prop suffix from prompts', () => {
|
||||
const basePrompt = '黑铁长棍,两端包裹金色金属箍'
|
||||
const withSuffix = addPropPromptSuffix(basePrompt)
|
||||
const removed = removePropPromptSuffix(withSuffix)
|
||||
|
||||
expect(removed).not.toContain(PROP_PROMPT_SUFFIX)
|
||||
expect(removed).toContain(basePrompt)
|
||||
})
|
||||
})
|
||||
|
||||
22
tests/unit/helpers/prop-modify-task-registration.test.ts
Normal file
22
tests/unit/helpers/prop-modify-task-registration.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isBillableTaskType } from '@/lib/billing/task-policy'
|
||||
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
|
||||
import { getTaskTypeLabel } from '@/lib/task/progress-message'
|
||||
import { resolveTaskIntent } from '@/lib/task/intent'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
|
||||
describe('prop modify task registration', () => {
|
||||
it('registers project prop modify tasks across task metadata helpers', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.AI_MODIFY_PROP)).toBe('modify')
|
||||
expect(getTaskTypeLabel(TASK_TYPE.AI_MODIFY_PROP)).toBe('progress.taskType.aiModifyProp')
|
||||
expect(isBillableTaskType(TASK_TYPE.AI_MODIFY_PROP)).toBe(true)
|
||||
expect(getLLMTaskPolicy(TASK_TYPE.AI_MODIFY_PROP).consoleEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('registers asset-hub prop modify tasks across task metadata helpers', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP)).toBe('modify')
|
||||
expect(getTaskTypeLabel(TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP)).toBe('progress.taskType.assetHubAiModifyProp')
|
||||
expect(isBillableTaskType(TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP)).toBe(true)
|
||||
expect(getLLMTaskPolicy(TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP).consoleEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,12 @@ describe('bailian video capabilities catalog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('registers wan2.7 i2v as dual-mode', () => {
|
||||
const capabilities = findBuiltinCapabilities('video', 'bailian', 'wan2.7-i2v')
|
||||
expect(capabilities?.video?.generationModeOptions).toEqual(['normal', 'firstlastframe'])
|
||||
expect(capabilities?.video?.firstlastframe).toBe(true)
|
||||
})
|
||||
|
||||
it('registers bailian kf2v models as firstlastframe-only', () => {
|
||||
const models = [
|
||||
'wan2.2-kf2v-flash',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useQueryClientMock,
|
||||
useMutationMock,
|
||||
requestJsonWithErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useQueryClientMock: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
||||
useMutationMock: vi.fn((options: unknown) => options),
|
||||
requestJsonWithErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
useMutation: (options: unknown) => useMutationMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
|
||||
'@/lib/query/mutations/mutation-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
invalidateQueryTemplates: vi.fn(),
|
||||
requestJsonWithError: requestJsonWithErrorMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { useConfirmProjectLocationSelection } from '@/lib/query/mutations/location-management-mutations'
|
||||
|
||||
interface ConfirmLocationSelectionMutation {
|
||||
mutationFn: (variables: { locationId: string }) => Promise<unknown>
|
||||
}
|
||||
|
||||
describe('project location-backed confirm mutations', () => {
|
||||
beforeEach(() => {
|
||||
useQueryClientMock.mockClear()
|
||||
useMutationMock.mockClear()
|
||||
requestJsonWithErrorMock.mockReset()
|
||||
requestJsonWithErrorMock.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('routes prop confirmation to the unified asset select-render endpoint', async () => {
|
||||
const mutation = useConfirmProjectLocationSelection('project-1', 'prop') as unknown as ConfirmLocationSelectionMutation
|
||||
|
||||
await mutation.mutationFn({ locationId: 'prop-1' })
|
||||
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestJsonWithErrorMock).toHaveBeenCalledWith(
|
||||
'/api/assets/prop-1/select-render',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
confirm: true,
|
||||
}),
|
||||
},
|
||||
'确认选择失败',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,22 +2,30 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
|
||||
describe('select prop prompt template', () => {
|
||||
it('zh template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||
it('zh template restricts extraction to recurring unique props and rejects ordinary scene items', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'zh')
|
||||
|
||||
expect(template).toContain('关键剧情道具资产分析师')
|
||||
expect(template).toContain('宁缺毋滥')
|
||||
expect(template).toContain('必须有明确剧情作用')
|
||||
expect(template).toContain('明确文本证据')
|
||||
expect(template).toContain('普通可替换物件')
|
||||
expect(template).toContain('可替换性测试')
|
||||
expect(template).toContain('贯穿性测试')
|
||||
expect(template).toContain('餐厅里的叉子')
|
||||
expect(template).toContain('如果不确定它是否值得进入资产库,直接不输出')
|
||||
expect(template).toContain('仅因外观具体、名词明确,不足以成为关键道具')
|
||||
})
|
||||
|
||||
it('en template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||
it('en template restricts extraction to recurring unique props and rejects ordinary scene items', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'en')
|
||||
|
||||
expect(template).toContain('key story prop extractor')
|
||||
expect(template).toContain('Be conservative')
|
||||
expect(template).toContain('explicit story function')
|
||||
expect(template).toContain('explicit textual evidence')
|
||||
expect(template).toContain('Ordinary replaceable items')
|
||||
expect(template).toContain('Replaceability test')
|
||||
expect(template).toContain('Continuity test')
|
||||
expect(template).toContain('a fork in a restaurant')
|
||||
expect(template).toContain('If you are unsure whether it deserves an asset entry, do not output it')
|
||||
expect(template).toContain('A specific-looking noun is not enough')
|
||||
})
|
||||
|
||||
@@ -82,6 +82,51 @@ describe('bailian video provider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits wan2.7 i2v task without last frame', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
output: {
|
||||
task_id: 'task-wan27-i2v',
|
||||
task_status: 'PENDING',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
|
||||
|
||||
const result = await generateBailianVideo({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/frame.png',
|
||||
prompt: '让人物转身看向镜头',
|
||||
options: {
|
||||
provider: 'bailian',
|
||||
modelId: 'wan2.7-i2v',
|
||||
modelKey: 'bailian::wan2.7-i2v',
|
||||
},
|
||||
})
|
||||
|
||||
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
|
||||
expect(firstCall).toBeDefined()
|
||||
if (!firstCall) {
|
||||
throw new Error('missing fetch call')
|
||||
}
|
||||
expect(firstCall[0]).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis')
|
||||
expect(firstCall[1].body).toBe(JSON.stringify({
|
||||
model: 'wan2.7-i2v',
|
||||
input: {
|
||||
img_url: 'https://example.com/frame.png',
|
||||
prompt: '让人物转身看向镜头',
|
||||
},
|
||||
}))
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
async: true,
|
||||
requestId: 'task-wan27-i2v',
|
||||
externalId: 'BAILIAN:VIDEO:task-wan27-i2v',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits kf2v task with first and last frame', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
@@ -136,6 +181,53 @@ describe('bailian video provider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits wan2.7 i2v task with first and last frame', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
output: {
|
||||
task_id: 'task-wan27-flf',
|
||||
task_status: 'PENDING',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
|
||||
|
||||
const result = await generateBailianVideo({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/first.png',
|
||||
prompt: '从清晨过渡到夜晚',
|
||||
options: {
|
||||
provider: 'bailian',
|
||||
modelId: 'wan2.7-i2v',
|
||||
modelKey: 'bailian::wan2.7-i2v',
|
||||
lastFrameImageUrl: 'https://example.com/last.png',
|
||||
},
|
||||
})
|
||||
|
||||
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
|
||||
expect(firstCall).toBeDefined()
|
||||
if (!firstCall) {
|
||||
throw new Error('missing fetch call')
|
||||
}
|
||||
expect(firstCall[0]).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis')
|
||||
expect(firstCall[1].body).toBe(JSON.stringify({
|
||||
model: 'wan2.7-i2v',
|
||||
input: {
|
||||
first_frame_url: 'https://example.com/first.png',
|
||||
last_frame_url: 'https://example.com/last.png',
|
||||
prompt: '从清晨过渡到夜晚',
|
||||
},
|
||||
}))
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
async: true,
|
||||
requestId: 'task-wan27-flf',
|
||||
externalId: 'BAILIAN:VIDEO:task-wan27-flf',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails fast when kf2v model misses last frame', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
|
||||
|
||||
@@ -32,10 +32,15 @@ vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
getArtStylePrompt: vi.fn(() => 'cinematic style'),
|
||||
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
|
||||
}))
|
||||
vi.mock('@/lib/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/constants')>()
|
||||
return {
|
||||
...actual,
|
||||
getArtStylePrompt: vi.fn(() => 'cinematic style'),
|
||||
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
|
||||
removePropPromptSuffix: vi.fn((text: string) => text),
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
@@ -125,7 +130,8 @@ describe('worker analyze-novel behavior', () => {
|
||||
props: [
|
||||
{
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
summary: '孙悟空随身铁棍法器',
|
||||
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -194,7 +200,7 @@ describe('worker analyze-novel behavior', () => {
|
||||
{
|
||||
locationId: 'prop-new-1',
|
||||
imageIndex: 0,
|
||||
description: '一根两头包裹金片的黑铁长棍',
|
||||
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
|
||||
availableSlots: '[]',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
|
||||
import { CHARACTER_ASSET_IMAGE_RATIO, CHARACTER_PROMPT_SUFFIX, PROP_IMAGE_RATIO, PROP_PROMPT_SUFFIX } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const workersUtilsMock = vi.hoisted(() => ({
|
||||
@@ -27,7 +27,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/generated-character.png'),
|
||||
generateCleanImageToStorage: vi.fn(async () => 'cos/generated-character.png'),
|
||||
parseJsonStringArray: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
generateCleanImageToStorage: sharedMock.generateCleanImageToStorage,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
@@ -93,13 +93,19 @@ describe('asset hub character image prompt suffix regression', () => {
|
||||
|
||||
await handleAssetHubImageTask(job)
|
||||
|
||||
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt?: string }] | undefined
|
||||
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
|
||||
prompt?: string
|
||||
options?: { aspectRatio?: string }
|
||||
label?: string
|
||||
}] | undefined
|
||||
const callArg = generationCall?.[0]
|
||||
const prompt = callArg?.prompt || ''
|
||||
|
||||
expect(prompt).toContain('主角,黑发,冷静')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: CHARACTER_ASSET_IMAGE_RATIO }))
|
||||
expect(callArg?.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors requested count for global location generation', async () => {
|
||||
@@ -124,11 +130,42 @@ describe('asset hub character image prompt suffix regression', () => {
|
||||
locationId: 'global-location-1',
|
||||
imageCount: 1,
|
||||
})
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)
|
||||
expect(sharedMock.generateCleanImageToStorage).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'global-location-image-1' },
|
||||
data: { imageUrl: 'cos/generated-character.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the prop prompt suffix in global prop generation prompts', async () => {
|
||||
prismaMock.globalLocation.findFirst.mockResolvedValueOnce({
|
||||
id: 'global-prop-1',
|
||||
name: 'Silver Cutlery',
|
||||
images: [
|
||||
{
|
||||
id: 'global-prop-image-1',
|
||||
description: '银质餐具套装,包含刀叉与汤匙,线条简洁,金属冷白光泽',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await handleAssetHubImageTask(buildJob({
|
||||
type: 'prop',
|
||||
id: 'global-prop-1',
|
||||
}))
|
||||
|
||||
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
|
||||
prompt?: string
|
||||
options?: { aspectRatio?: string }
|
||||
label?: string
|
||||
}] | undefined
|
||||
const callArg = generationCall?.[0]
|
||||
const prompt = callArg?.prompt || ''
|
||||
|
||||
expect(prompt).toContain(PROP_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(prompt, PROP_PROMPT_SUFFIX)).toBe(1)
|
||||
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }))
|
||||
expect(callArg?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,8 +25,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn<(input: {
|
||||
generateProjectLabeledImageToStorage: vi.fn<(input: {
|
||||
prompt: string
|
||||
label: string
|
||||
options?: { referenceImages?: string[]; aspectRatio?: string }
|
||||
}) => Promise<string>>(async () => 'cos/character-generated-0.png'),
|
||||
}))
|
||||
@@ -41,7 +42,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,8 +102,9 @@ describe('worker character-image-task-handler behavior', () => {
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
})
|
||||
|
||||
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
|
||||
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
|
||||
prompt: string
|
||||
label: string
|
||||
options?: { referenceImages?: string[]; aspectRatio?: string }
|
||||
}
|
||||
const realisticStylePrompt = getArtStylePrompt('realistic', 'zh')
|
||||
@@ -111,6 +113,7 @@ describe('worker character-image-task-handler behavior', () => {
|
||||
expect(generationInput.prompt).toContain(realisticStylePrompt)
|
||||
expect(generationInput.prompt.split(CHARACTER_PROMPT_SUFFIX).length - 1).toBe(1)
|
||||
expect(generationInput.prompt.split(realisticStylePrompt).length - 1).toBe(1)
|
||||
expect(generationInput.label).toBe('Hero - 战斗形态')
|
||||
expect(generationInput.options).toEqual(expect.objectContaining({
|
||||
referenceImages: ['normalized-primary-ref'],
|
||||
aspectRatio: '3:2',
|
||||
@@ -129,7 +132,7 @@ describe('worker character-image-task-handler behavior', () => {
|
||||
const job = buildJob({ imageIndex: 0, artStyle: 'japanese-anime' })
|
||||
await handleCharacterImageTask(job)
|
||||
|
||||
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
|
||||
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
|
||||
prompt: string
|
||||
}
|
||||
expect(generationInput.prompt).toContain(getArtStylePrompt('japanese-anime', 'zh'))
|
||||
@@ -143,7 +146,7 @@ describe('worker character-image-task-handler behavior', () => {
|
||||
})
|
||||
|
||||
it('uses requested count for grouped generation and expands imageUrls to requested size', async () => {
|
||||
sharedMock.generateLabeledImageToCos
|
||||
sharedMock.generateProjectLabeledImageToStorage
|
||||
.mockResolvedValueOnce('cos/character-generated-0.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-1.png')
|
||||
.mockResolvedValueOnce('cos/character-generated-2.png')
|
||||
@@ -152,7 +155,7 @@ describe('worker character-image-task-handler behavior', () => {
|
||||
|
||||
const result = await handleCharacterImageTask(buildJob({ count: 5 }))
|
||||
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(5)
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(5)
|
||||
expect(result).toEqual({
|
||||
appearanceId: 'appearance-2',
|
||||
imageCount: 5,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
@@ -118,7 +119,7 @@ describe('worker image-task-handlers-core', () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '1:1',
|
||||
aspectRatio: LOCATION_IMAGE_RATIO,
|
||||
resolution: '1536x1024',
|
||||
referenceImages: ['required-reference-image', 'normalized-reference-image'],
|
||||
}),
|
||||
@@ -132,6 +133,34 @@ describe('worker image-task-handlers-core', () => {
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('uses the character-matching aspect ratio when modifying project prop images', async () => {
|
||||
prismaMock.locationImage.findUnique.mockResolvedValueOnce({
|
||||
id: 'prop-image-1',
|
||||
locationId: 'prop-1',
|
||||
imageUrl: 'cos/prop-old.png',
|
||||
description: 'silver prop',
|
||||
previousDescription: null,
|
||||
location: { name: 'Silver Prop' },
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(buildJob({
|
||||
type: 'prop',
|
||||
locationImageId: 'prop-image-1',
|
||||
modifyPrompt: 'make it brushed silver',
|
||||
generationOptions: { resolution: '1536x1024' },
|
||||
}))
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
resolution: '1536x1024',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('updates storyboard panel image and keeps candidateImages reset', async () => {
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getArtStylePrompt } from '@/lib/constants'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, getArtStylePrompt } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
@@ -20,7 +20,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/location-generated-1.png'),
|
||||
generateProjectLabeledImageToStorage: vi.fn(async () => 'cos/location-generated-1.png'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
@@ -32,7 +32,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,32 +100,32 @@ describe('worker location-image-task-handler behavior', () => {
|
||||
locationIds: ['location-1'],
|
||||
})
|
||||
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('雨夜街道'),
|
||||
label: 'Old Town',
|
||||
targetId: 'location-image-1',
|
||||
options: expect.objectContaining({ aspectRatio: '1:1' }),
|
||||
options: expect.objectContaining({ aspectRatio: LOCATION_IMAGE_RATIO }),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('可站位置:'),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('街道左侧靠墙的留白位置'),
|
||||
}),
|
||||
)
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('必须使用宽广完整的场景全景构图'),
|
||||
}),
|
||||
)
|
||||
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt: string }] | undefined
|
||||
const generationCall = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0] as unknown as [{ prompt: string }] | undefined
|
||||
expect(generationCall).toBeTruthy()
|
||||
if (!generationCall) throw new Error('expected generateLabeledImageToCos call')
|
||||
if (!generationCall) throw new Error('expected generateProjectLabeledImageToStorage call')
|
||||
const generationInput = generationCall[0]
|
||||
expect(generationInput.prompt.split(animeStylePrompt).length - 1).toBe(1)
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('worker location-image-task-handler behavior', () => {
|
||||
it('payload artStyle overrides project artStyle in prompt', async () => {
|
||||
await handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'realistic' }))
|
||||
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining(getArtStylePrompt('realistic', 'zh')),
|
||||
}),
|
||||
@@ -169,11 +169,21 @@ describe('worker location-image-task-handler behavior', () => {
|
||||
updated: 1,
|
||||
locationIds: ['location-1'],
|
||||
})
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-image-1' },
|
||||
data: { imageUrl: 'cos/location-generated-1.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same aspect ratio as character generation for prop images', async () => {
|
||||
await handleLocationImageTask(buildJob({ type: 'prop', imageIndex: 0 }))
|
||||
|
||||
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
@@ -27,6 +28,7 @@ const promptMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: {
|
||||
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
|
||||
NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',
|
||||
NP_PROP_DESCRIPTION_UPDATE: 'np_prop_description_update',
|
||||
},
|
||||
buildPrompt: vi.fn(({ promptId }: { promptId: string }) => `${promptId}-prompt`),
|
||||
}))
|
||||
@@ -200,6 +202,16 @@ describe('modify image syncs descriptions after edit', () => {
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
expect(aiRuntimeMock.executeAiVisionStep).toHaveBeenCalledTimes(1)
|
||||
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['https://signed/current-image.png', 'https://ref.example/b.png'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const globalCharacterUpdateCall = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = globalCharacterUpdateCall?.[0]
|
||||
@@ -246,6 +258,17 @@ describe('modify image syncs descriptions after edit', () => {
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['https://signed/current-image.png', 'https://ref.example/location.png'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateArg = globalLocationUpdateCall?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
@@ -253,4 +276,60 @@ describe('modify image syncs descriptions after edit', () => {
|
||||
expect(updateData.description).toBe('VISION_UPDATED_LOCATION')
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-location-image.png')
|
||||
})
|
||||
|
||||
it('syncs project prop descriptions for pure text edits', async () => {
|
||||
aiRuntimeMock.executeAiTextStep.mockResolvedValueOnce({ text: '{"prompt":"TEXT_UPDATED_PROP"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
|
||||
type: 'prop',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '把表面改成拉丝银色,并增加刻纹',
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(job)
|
||||
|
||||
const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateData = getUpdateData(locationUpdateCall?.[0])
|
||||
expect(updateData.previousDescription).toBe('old location description')
|
||||
expect(updateData.description).toBe('TEXT_UPDATED_PROP')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('syncs asset-hub prop descriptions for reference-image edits', async () => {
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-prop-image.png')
|
||||
aiRuntimeMock.executeAiVisionStep.mockResolvedValueOnce({ text: '{"prompt":"VISION_UPDATED_PROP"}' })
|
||||
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
|
||||
type: 'prop',
|
||||
id: 'global-location-1',
|
||||
imageIndex: 0,
|
||||
modifyPrompt: '改成磨砂银色餐具,去掉多余反光',
|
||||
extraImageUrls: ['https://ref.example/prop.png'],
|
||||
})
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
|
||||
const updateData = getUpdateData(globalLocationUpdateCall?.[0])
|
||||
expect(updateData.previousDescription).toBe('global location description')
|
||||
expect(updateData.description).toBe('VISION_UPDATED_PROP')
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-prop-image.png')
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: PROP_IMAGE_RATIO,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,6 +177,8 @@ describe('worker reference-to-character', () => {
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
|
||||
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('冷静黑发角色')
|
||||
@@ -201,6 +203,8 @@ describe('worker reference-to-character', () => {
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
|
||||
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('BASE_REFERENCE_PROMPT')
|
||||
@@ -237,4 +241,20 @@ describe('worker reference-to-character', () => {
|
||||
expect(cosKeys).toHaveLength(5)
|
||||
expect(cosKeys?.every((item) => item.startsWith('cos/reference-key-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds project label bars only for project reference generation', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: ['https://example.com/ref-a.png'],
|
||||
characterName: 'Hero',
|
||||
count: 1,
|
||||
},
|
||||
TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(fontsMock.initializeFonts).toHaveBeenCalledTimes(1)
|
||||
expect(fontsMock.createLabelSVG).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user