fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience

This commit is contained in:
saturn
2026-03-21 14:35:32 +08:00
parent f364bbc9e4
commit a6ad11b9c4
42 changed files with 1189 additions and 553 deletions

View File

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

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

View File

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

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

View File

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

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

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

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

View 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('当前片段未选择道具')
})
})

View File

@@ -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({

View File

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