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:
saturn
2026-04-03 22:36:41 +08:00
parent 854b932e67
commit 78b93331b4
136 changed files with 3393 additions and 875 deletions

View File

@@ -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',

View File

@@ -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: '[]',
},
],

View File

@@ -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', () => {

View 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()
})
})

View 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('智能修改')
})
})

View 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]')
})
})

View File

@@ -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]')
})
})

View 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')
})
})

View 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('个形象')
})
})

View 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')
})
})

View 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')
})
})

View File

@@ -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',
])
})
})

View File

@@ -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)
})
})

View 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)
})
})

View File

@@ -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',

View File

@@ -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,
}),
},
'确认选择失败',
)
})
})

View File

@@ -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')
})

View File

@@ -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)

View File

@@ -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: '[]',
},
],

View File

@@ -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()
})
})

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 }),
}),
)
})
})

View File

@@ -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,
}),
}),
)
})
})

View File

@@ -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)
})
})