fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience
This commit is contained in:
@@ -4,6 +4,8 @@ const prismaMock = vi.hoisted(() => ({
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
locationImage: { createMany: vi.fn() },
|
||||
globalLocationImage: { createMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
@@ -44,4 +46,50 @@ describe('location-backed assets service', () => {
|
||||
expect(imageSql).toContain('FROM location_images')
|
||||
expect(imageSql).toContain('NULL AS previousImageMediaId')
|
||||
})
|
||||
|
||||
it('seeds an initial project image slot when creating a prop asset', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
const result = await mod.createProjectLocationBackedAsset({
|
||||
novelPromotionProjectId: 'novel-project-1',
|
||||
name: 'Bronze Dagger',
|
||||
summary: 'Old bronze dagger',
|
||||
kind: 'prop',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: result.id,
|
||||
imageIndex: 0,
|
||||
description: 'Old bronze dagger',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds multiple project image slots when explicit descriptions are provided', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
await mod.seedProjectLocationBackedImageSlots({
|
||||
locationId: 'location-1',
|
||||
descriptions: ['Night street', 'Rainy alley'],
|
||||
fallbackDescription: 'Night street',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: 'Night street',
|
||||
},
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 1,
|
||||
description: 'Rainy alley',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
|
||||
|
||||
describe('location-backed asset generation rules', () => {
|
||||
it('allows props to generate from summary even before any image slot exists', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'prop-1',
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
images: [],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('allows locations to generate from seeded image descriptions', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'location-1',
|
||||
name: '雨夜街道',
|
||||
summary: null,
|
||||
images: [
|
||||
{
|
||||
id: 'image-1',
|
||||
imageIndex: 0,
|
||||
description: '潮湿反光的老街',
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
previousDescription: null,
|
||||
isSelected: false,
|
||||
},
|
||||
],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('routes prop generation through the prop branch', () => {
|
||||
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
|
||||
expect(resolveLocationBackedGenerateType('location')).toBe('location')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ describe('asset mappers', () => {
|
||||
id: 'character-1',
|
||||
name: '林夏',
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
voiceType: 'custom',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/voice.mp3',
|
||||
@@ -37,6 +38,7 @@ describe('asset mappers', () => {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: true,
|
||||
voice: expect.objectContaining({
|
||||
voiceType: 'custom',
|
||||
|
||||
80
tests/unit/components/asset-toolbar.test.ts
Normal file
80
tests/unit/components/asset-toolbar.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 AssetToolbar from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar'
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useProjectAssets: vi.fn(() => ({ data: { characters: [], locations: [], props: [] } })),
|
||||
useProjectData: vi.fn(() => ({ data: { name: '项目A' } })),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
common: {
|
||||
refresh: '刷新',
|
||||
},
|
||||
filterBar: {
|
||||
allEpisodes: '全部集数',
|
||||
},
|
||||
toolbar: {
|
||||
assetManagement: '资产管理',
|
||||
assetCount: '共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)',
|
||||
globalAnalyze: '全局分析',
|
||||
globalAnalyzeHint: '分析所有资产',
|
||||
downloadAll: '下载全部',
|
||||
generateAll: '生成全部图片',
|
||||
regenerateAll: '重新生成全部',
|
||||
regenerateAllHint: '重新生成所有图片',
|
||||
},
|
||||
assetLibrary: {
|
||||
downloadEmpty: '没有可下载图片',
|
||||
downloadFailed: '下载失败',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const 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('AssetToolbar', () => {
|
||||
it('删除批量生成与刷新按钮 -> 仅保留全局分析和下载入口', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetToolbar, {
|
||||
projectId: 'project-1',
|
||||
totalAssets: 24,
|
||||
totalAppearances: 11,
|
||||
totalLocations: 13,
|
||||
totalProps: 0,
|
||||
isBatchSubmitting: false,
|
||||
isAnalyzingAssets: false,
|
||||
isGlobalAnalyzing: false,
|
||||
onGlobalAnalyze: () => undefined,
|
||||
episodeId: null,
|
||||
onEpisodeChange: () => undefined,
|
||||
episodes: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('全局分析')
|
||||
expect(html).toContain('title="下载全部"')
|
||||
expect(html).not.toContain('生成全部图片')
|
||||
expect(html).not.toContain('重新生成全部')
|
||||
expect(html).not.toContain('>刷新<')
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,9 @@ const messages = {
|
||||
waitingModelOutput: '等待模型输出...',
|
||||
reasoningNotProvided: '该步骤未返回思考过程',
|
||||
},
|
||||
streamStep: {
|
||||
analyzeProps: '道具分析',
|
||||
},
|
||||
runtime: {
|
||||
llm: {
|
||||
processing: '模型处理中...',
|
||||
@@ -69,4 +72,27 @@ describe('LLMStageStreamCard error rendering', () => {
|
||||
expect(html).not.toContain('Copy error detail')
|
||||
expect(html).not.toContain('Open feedback form')
|
||||
})
|
||||
|
||||
it('resolves analyze props progress keys without missing message errors', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const html = renderWithIntl(
|
||||
createElement(LLMStageStreamCard, {
|
||||
title: 'progress.streamStep.analyzeProps',
|
||||
stages: [{
|
||||
id: 'analyze_props',
|
||||
title: 'progress.streamStep.analyzeProps',
|
||||
subtitle: 'progress.streamStep.analyzeProps',
|
||||
status: 'processing',
|
||||
progress: 35,
|
||||
}],
|
||||
activeStageId: 'analyze_props',
|
||||
activeMessage: 'progress.streamStep.analyzeProps',
|
||||
outputText: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('道具分析')
|
||||
expect(html).not.toContain('progress.streamStep.analyzeProps')
|
||||
expect(html).not.toContain('MISSING_MESSAGE')
|
||||
})
|
||||
})
|
||||
|
||||
29
tests/unit/components/segmented-control.test.ts
Normal file
29
tests/unit/components/segmented-control.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('compact 布局 -> 输出左对齐的非拉伸结构', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SegmentedControl, {
|
||||
options: [
|
||||
{ value: 'all', label: '全部 (24)' },
|
||||
{ value: 'character', label: '角色 (11)' },
|
||||
{ value: 'location', label: '场景 (13)' },
|
||||
{ value: 'prop', label: '道具 (0)' },
|
||||
],
|
||||
value: 'all',
|
||||
onChange: () => undefined,
|
||||
layout: 'compact',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('inline-block max-w-full')
|
||||
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||
expect(html).not.toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
|
||||
})
|
||||
})
|
||||
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'zh')
|
||||
|
||||
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', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'en')
|
||||
|
||||
expect(template).toContain('key story prop extractor')
|
||||
expect(template).toContain('Be conservative')
|
||||
expect(template).toContain('clear story function')
|
||||
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')
|
||||
})
|
||||
})
|
||||
55
tests/unit/query/use-project-assets.test.ts
Normal file
55
tests/unit/query/use-project-assets.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyAssetGroupMap } from '@/lib/assets/grouping'
|
||||
import { mapAssetGroupsToProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
|
||||
|
||||
describe('useProjectAssets adapters', () => {
|
||||
it('preserves profileData for unconfirmed character profiles', () => {
|
||||
const groups = createEmptyAssetGroupMap()
|
||||
groups.character.push({
|
||||
id: 'character-1',
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
name: '林夏',
|
||||
folderId: null,
|
||||
capabilities: {
|
||||
canGenerate: true,
|
||||
canSelectRender: true,
|
||||
canRevertRender: true,
|
||||
canModifyRender: true,
|
||||
canUploadRender: true,
|
||||
canBindVoice: true,
|
||||
canCopyFromGlobal: true,
|
||||
},
|
||||
taskRefs: [],
|
||||
taskState: {
|
||||
isRunning: false,
|
||||
lastError: null,
|
||||
},
|
||||
variants: [],
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: {
|
||||
isRunning: false,
|
||||
lastError: null,
|
||||
},
|
||||
voice: {
|
||||
voiceType: null,
|
||||
voiceId: null,
|
||||
customVoiceUrl: null,
|
||||
media: null,
|
||||
},
|
||||
})
|
||||
|
||||
const data = mapAssetGroupsToProjectAssetsData(groups)
|
||||
|
||||
expect(data.characters).toHaveLength(1)
|
||||
expect(data.characters[0]).toEqual(expect.objectContaining({
|
||||
id: 'character-1',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import ScriptViewAssetsPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel'
|
||||
|
||||
const messages = {
|
||||
scriptView: {
|
||||
inSceneAssets: '出场资产',
|
||||
assetView: {
|
||||
allClips: '全部片段',
|
||||
},
|
||||
segment: {
|
||||
title: '片段 {index}',
|
||||
},
|
||||
asset: {
|
||||
activeCharacters: '出场角色',
|
||||
activeLocations: '出场场景',
|
||||
defaultAppearance: '默认形象',
|
||||
},
|
||||
screenplay: {
|
||||
noCharacter: '当前片段未选择角色',
|
||||
noLocation: '当前片段未选择场景',
|
||||
},
|
||||
generate: {
|
||||
startGenerate: '开始生成',
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
character: {
|
||||
primary: '初始形象',
|
||||
},
|
||||
},
|
||||
novelPromotion: {
|
||||
buttons: {
|
||||
assetLibrary: '资产库',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
edit: '编辑',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
},
|
||||
} 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),
|
||||
)
|
||||
}
|
||||
|
||||
function renderPanel(propsCount: number) {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const props = Array.from({ length: propsCount }, (_, index) => ({
|
||||
id: `prop-${index + 1}`,
|
||||
name: `道具${index + 1}`,
|
||||
summary: `道具描述${index + 1}`,
|
||||
selectedImageId: null,
|
||||
images: [],
|
||||
}))
|
||||
|
||||
return renderWithIntl(
|
||||
createElement(ScriptViewAssetsPanel, {
|
||||
clips: [{ id: 'clip-1', location: null, props: null }],
|
||||
assetViewMode: 'all',
|
||||
setAssetViewMode: () => undefined,
|
||||
setSelectedClipId: () => undefined,
|
||||
characters: [],
|
||||
locations: [],
|
||||
props,
|
||||
activeCharIds: [],
|
||||
activeLocationIds: [],
|
||||
activePropIds: [],
|
||||
selectedAppearanceKeys: new Set<string>(),
|
||||
onUpdateClipAssets: async () => undefined,
|
||||
onOpenAssetLibrary: () => undefined,
|
||||
assetsLoading: false,
|
||||
assetsLoadingState: null,
|
||||
allAssetsHaveImages: true,
|
||||
globalCharIds: [],
|
||||
globalLocationIds: [],
|
||||
globalPropIds: [],
|
||||
missingAssetsCount: 0,
|
||||
onGenerateStoryboard: () => undefined,
|
||||
isSubmittingStoryboardBuild: false,
|
||||
getSelectedAppearances: () => [],
|
||||
tScript: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'inSceneAssets') return '出场资产'
|
||||
if (key === 'assetView.allClips') return '全部片段'
|
||||
if (key === 'segment.title') return `片段 ${String(values?.index ?? '')}`
|
||||
if (key === 'asset.activeCharacters') return '出场角色'
|
||||
if (key === 'asset.activeLocations') return '出场场景'
|
||||
if (key === 'screenplay.noCharacter') return '当前片段未选择角色'
|
||||
if (key === 'screenplay.noLocation') return '当前片段未选择场景'
|
||||
if (key === 'generate.startGenerate') return '开始生成'
|
||||
if (key === 'asset.defaultAppearance') return '默认形象'
|
||||
return key
|
||||
},
|
||||
tAssets: (key: string) => (key === 'character.primary' ? '初始形象' : key),
|
||||
tNP: (key: string) => (key === 'buttons.assetLibrary' ? '资产库' : key),
|
||||
tCommon: (key: string) => {
|
||||
if (key === 'edit') return '编辑'
|
||||
if (key === 'cancel') return '取消'
|
||||
if (key === 'confirm') return '确定'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ScriptViewAssetsPanel', () => {
|
||||
it('hides the prop section when the project has no prop assets', () => {
|
||||
const html = renderPanel(0)
|
||||
|
||||
expect(html).not.toContain('道具 (0)')
|
||||
expect(html).not.toContain('当前片段未选择道具')
|
||||
})
|
||||
|
||||
it('keeps the prop section visible when the project has prop assets even if none are selected in the current clip', () => {
|
||||
const html = renderPanel(1)
|
||||
|
||||
expect(html).toContain('道具 (0)')
|
||||
expect(html).toContain('当前片段未选择道具')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,10 @@ const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionEpisode: { findFirst: vi.fn() },
|
||||
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
|
||||
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },
|
||||
locationImage: { create: vi.fn(async () => ({})) },
|
||||
locationImage: {
|
||||
create: vi.fn(async () => ({})),
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
@@ -49,6 +52,7 @@ vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'char',
|
||||
NP_SELECT_LOCATION: 'loc',
|
||||
NP_SELECT_PROP: 'prop',
|
||||
},
|
||||
buildPrompt: vi.fn(() => 'analysis-prompt'),
|
||||
}))
|
||||
@@ -75,6 +79,10 @@ describe('worker analyze-novel behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionLocation.create
|
||||
.mockResolvedValueOnce({ id: 'loc-new-1' })
|
||||
.mockResolvedValueOnce({ id: 'prop-new-1' })
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
mode: 'novel-promotion',
|
||||
@@ -114,6 +122,14 @@ describe('worker analyze-novel behavior', () => {
|
||||
},
|
||||
],
|
||||
}))
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
props: [
|
||||
{
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('no global text and no episode text -> explicit error', async () => {
|
||||
@@ -137,10 +153,10 @@ describe('worker analyze-novel behavior', () => {
|
||||
success: true,
|
||||
characters: [{ id: 'char-new-1' }],
|
||||
locations: [{ id: 'loc-new-1' }],
|
||||
props: [],
|
||||
props: [{ id: 'prop-new-1' }],
|
||||
characterCount: 1,
|
||||
locationCount: 1,
|
||||
propCount: 0,
|
||||
propCount: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
|
||||
@@ -163,12 +179,24 @@ describe('worker analyze-novel behavior', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.locationImage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
expect(prismaMock.locationImage.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'prop-new-1',
|
||||
imageIndex: 0,
|
||||
description: '一根两头包裹金片的黑铁长棍',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
novelPromotionCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -10,6 +11,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({})),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -89,6 +91,9 @@ function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>):
|
||||
describe('worker character-profile behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
|
||||
return await callback(prismaMock)
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify({
|
||||
@@ -134,10 +139,13 @@ describe('worker character-profile behavior', () => {
|
||||
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
|
||||
})
|
||||
|
||||
it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {
|
||||
it('confirm profile success -> rebuilds appearances and marks profileConfirmed', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
characterId: 'character-1',
|
||||
@@ -149,7 +157,10 @@ describe('worker character-profile behavior', () => {
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'character-1' },
|
||||
data: { profileConfirmed: true },
|
||||
data: {
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
@@ -171,4 +182,18 @@ describe('worker character-profile behavior', () => {
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reconfirm with existing appearances -> replaces old rows instead of colliding on unique index', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
|
||||
await expect(handleCharacterProfileTask(job)).resolves.toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user