release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
createAIDataModalDraftState,
mergeAIDataModalDraftStateByDirty,
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useAIDataModalState'
describe('useAIDataModalState optimistic sync helpers', () => {
it('keeps dirty fields when server data refreshes', () => {
const localDraft = createAIDataModalDraftState({
initialShotType: 'Close-up',
initialCameraMove: 'Push in',
initialDescription: 'user typing draft',
initialVideoPrompt: 'prompt-a',
initialPhotographyRules: null,
initialActingNotes: null,
})
const serverDraft = createAIDataModalDraftState({
initialShotType: 'Wide',
initialCameraMove: 'Pan left',
initialDescription: 'server-updated-desc',
initialVideoPrompt: 'prompt-b',
initialPhotographyRules: null,
initialActingNotes: null,
})
const merged = mergeAIDataModalDraftStateByDirty(
localDraft,
serverDraft,
new Set(['description']),
)
expect(merged.description).toBe('user typing draft')
expect(merged.shotType).toBe('Wide')
expect(merged.cameraMove).toBe('Pan left')
expect(merged.videoPrompt).toBe('prompt-b')
})
it('syncs non-dirty nested fields from server', () => {
const localDraft = createAIDataModalDraftState({
initialShotType: 'A',
initialCameraMove: 'B',
initialDescription: 'C',
initialVideoPrompt: 'D',
initialPhotographyRules: {
scene_summary: 'local scene',
lighting: {
direction: 'front',
quality: 'soft',
},
characters: [{
name: 'hero',
screen_position: 'left',
posture: 'standing',
facing: 'camera',
}],
depth_of_field: 'deep',
color_tone: 'warm',
},
initialActingNotes: [{
name: 'hero',
acting: 'smile',
}],
})
const serverDraft = createAIDataModalDraftState({
initialShotType: 'A2',
initialCameraMove: 'B2',
initialDescription: 'C2',
initialVideoPrompt: 'D2',
initialPhotographyRules: {
scene_summary: 'server scene',
lighting: {
direction: 'back',
quality: 'hard',
},
characters: [{
name: 'hero',
screen_position: 'center',
posture: 'running',
facing: 'right',
}],
depth_of_field: 'shallow',
color_tone: 'cool',
},
initialActingNotes: [{
name: 'hero',
acting: 'angry',
}],
})
const merged = mergeAIDataModalDraftStateByDirty(
localDraft,
serverDraft,
new Set(['videoPrompt']),
)
expect(merged.videoPrompt).toBe('D')
expect(merged.photographyRules?.scene_summary).toBe('server scene')
expect(merged.photographyRules?.lighting.direction).toBe('back')
expect(merged.actingNotes[0]?.acting).toBe('angry')
})
})

View File

@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalCharacter, GlobalLocation } from '@/lib/query/hooks/useGlobalAssets'
import { queryKeys } from '@/lib/query/keys'
import { MockQueryClient } from '../../helpers/mock-query-client'
let queryClient = new MockQueryClient()
const useQueryClientMock = vi.fn(() => queryClient)
const useMutationMock = vi.fn((options: unknown) => options)
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useRef: <T,>(value: T) => ({ current: value }),
}
})
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,
requestJsonWithError: vi.fn(),
requestVoidWithError: vi.fn(),
}
})
vi.mock('@/lib/query/mutations/asset-hub-mutations-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/query/mutations/asset-hub-mutations-shared')>(
'@/lib/query/mutations/asset-hub-mutations-shared',
)
return {
...actual,
invalidateGlobalCharacters: vi.fn(),
invalidateGlobalLocations: vi.fn(),
}
})
import {
useSelectCharacterImage,
} from '@/lib/query/mutations/asset-hub-character-mutations'
import { useDeleteLocation as useDeleteAssetHubLocation } from '@/lib/query/mutations/asset-hub-location-mutations'
interface SelectCharacterMutation {
onMutate: (variables: {
characterId: string
appearanceIndex: number
imageIndex: number | null
}) => Promise<unknown>
onError: (error: unknown, variables: unknown, context: unknown) => void
}
interface DeleteLocationMutation {
onMutate: (locationId: string) => Promise<unknown>
onError: (error: unknown, locationId: string, context: unknown) => void
}
function buildGlobalCharacter(selectedIndex: number | null): GlobalCharacter {
return {
id: 'character-1',
name: 'Hero',
folderId: 'folder-1',
customVoiceUrl: null,
appearances: [{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'default',
description: null,
descriptionSource: null,
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
imageUrls: ['img-0', 'img-1', 'img-2'],
selectedIndex,
previousImageUrl: null,
previousImageUrls: [],
imageTaskRunning: false,
}],
}
}
function buildGlobalLocation(id: string): GlobalLocation {
return {
id,
name: `Location ${id}`,
summary: null,
folderId: 'folder-1',
images: [{
id: `${id}-img-0`,
imageIndex: 0,
description: null,
imageUrl: null,
previousImageUrl: null,
isSelected: true,
imageTaskRunning: false,
}],
}
}
describe('asset hub optimistic mutations', () => {
beforeEach(() => {
queryClient = new MockQueryClient()
useQueryClientMock.mockClear()
useMutationMock.mockClear()
})
it('updates all character query caches optimistically and ignores stale rollback', async () => {
const allCharactersKey = queryKeys.globalAssets.characters()
const folderCharactersKey = queryKeys.globalAssets.characters('folder-1')
queryClient.seedQuery(allCharactersKey, [buildGlobalCharacter(0)])
queryClient.seedQuery(folderCharactersKey, [buildGlobalCharacter(0)])
const mutation = useSelectCharacterImage() as unknown as SelectCharacterMutation
const firstVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 1,
}
const secondVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 2,
}
const firstContext = await mutation.onMutate(firstVariables)
const afterFirstAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
const afterFirstFolder = queryClient.getQueryData<GlobalCharacter[]>(folderCharactersKey)
expect(afterFirstAll?.[0]?.appearances[0]?.selectedIndex).toBe(1)
expect(afterFirstFolder?.[0]?.appearances[0]?.selectedIndex).toBe(1)
const secondContext = await mutation.onMutate(secondVariables)
const afterSecondAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterSecondAll?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('first failed'), firstVariables, firstContext)
const afterStaleError = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterStaleError?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('second failed'), secondVariables, secondContext)
const afterLatestRollback = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterLatestRollback?.[0]?.appearances[0]?.selectedIndex).toBe(1)
})
it('optimistically removes location and restores on error', async () => {
const allLocationsKey = queryKeys.globalAssets.locations()
const folderLocationsKey = queryKeys.globalAssets.locations('folder-1')
queryClient.seedQuery(allLocationsKey, [buildGlobalLocation('loc-1'), buildGlobalLocation('loc-2')])
queryClient.seedQuery(folderLocationsKey, [buildGlobalLocation('loc-1')])
const mutation = useDeleteAssetHubLocation() as unknown as DeleteLocationMutation
const context = await mutation.onMutate('loc-1')
const afterDeleteAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const afterDeleteFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(afterDeleteAll?.map((item) => item.id)).toEqual(['loc-2'])
expect(afterDeleteFolder).toEqual([])
mutation.onError(new Error('delete failed'), 'loc-1', context)
const rolledBackAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const rolledBackFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(rolledBackAll?.map((item) => item.id)).toEqual(['loc-1', 'loc-2'])
expect(rolledBackFolder?.map((item) => item.id)).toEqual(['loc-1'])
})
})

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
serializeStructuredJsonField,
syncPanelCharacterDependentJson,
} from '@/lib/novel-promotion/panel-ai-data-sync'
describe('panel ai data sync helpers', () => {
it('removes deleted character from acting notes and photography rules', () => {
const synced = syncPanelCharacterDependentJson({
characters: [
{ name: '楚江锴/当朝皇帝', appearance: '初始形象' },
{ name: '燕画乔/魏画乔', appearance: '初始形象' },
],
removeIndex: 0,
actingNotesJson: JSON.stringify([
{ name: '楚江锴/当朝皇帝', acting: '紧握手腕' },
{ name: '燕画乔/魏画乔', acting: '本能后退' },
]),
photographyRulesJson: JSON.stringify({
lighting: {
direction: '侧逆光',
quality: '硬光',
},
characters: [
{ name: '楚江锴/当朝皇帝', screen_position: 'left' },
{ name: '燕画乔/魏画乔', screen_position: 'right' },
],
}),
})
expect(synced.characters).toEqual([{ name: '燕画乔/魏画乔', appearance: '初始形象' }])
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
{ name: '燕画乔/魏画乔', acting: '本能后退' },
])
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
lighting: {
direction: '侧逆光',
quality: '硬光',
},
characters: [
{ name: '燕画乔/魏画乔', screen_position: 'right' },
],
})
})
it('keeps notes by character name when another appearance of same name remains', () => {
const synced = syncPanelCharacterDependentJson({
characters: [
{ name: '顾娘子/顾盼之', appearance: '素衣' },
{ name: '顾娘子/顾盼之', appearance: '华服' },
],
removeIndex: 1,
actingNotesJson: JSON.stringify([
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
]),
photographyRulesJson: JSON.stringify({
characters: [
{ name: '顾娘子/顾盼之', screen_position: 'center' },
],
}),
})
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
])
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
characters: [
{ name: '顾娘子/顾盼之', screen_position: 'center' },
],
})
})
it('supports double-serialized JSON string inputs', () => {
const actingNotes = JSON.stringify([{ name: '甲', acting: '动作' }])
const doubleSerialized = JSON.stringify(actingNotes)
expect(serializeStructuredJsonField(doubleSerialized, 'actingNotes')).toBe(actingNotes)
})
it('throws on malformed acting notes to avoid silent fallback', () => {
expect(() => syncPanelCharacterDependentJson({
characters: [{ name: '甲', appearance: '初始形象' }],
removeIndex: 0,
actingNotesJson: '[{"name":"甲","acting":"动作"}, {"acting":"缺少名字"}]',
photographyRulesJson: null,
})).toThrowError('actingNotes item.name must be a non-empty string')
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'
import {
PanelSaveCoordinator,
type PanelSaveState,
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-save-coordinator'
function buildSnapshot(description: string): PanelEditData {
return {
id: 'panel-1',
panelIndex: 0,
panelNumber: 1,
shotType: 'close-up',
cameraMove: 'push',
description,
location: null,
characters: [],
srtStart: null,
srtEnd: null,
duration: null,
videoPrompt: null,
}
}
describe('PanelSaveCoordinator', () => {
it('keeps single-flight and only flushes the latest snapshot after burst edits', async () => {
const savedDescriptions: string[] = []
let releaseFirstAttempt: () => void = () => {}
const firstAttemptGate = new Promise<void>((resolve) => {
releaseFirstAttempt = () => resolve()
})
let attempts = 0
const coordinator = new PanelSaveCoordinator({
onSavingChange: () => {},
onStateChange: () => {},
runSave: async ({ snapshot }) => {
attempts += 1
if (attempts === 1) {
await firstAttemptGate
}
savedDescriptions.push(snapshot.description ?? '')
},
resolveErrorMessage: () => 'save failed',
})
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v1'))
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v2'))
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v3'))
releaseFirstAttempt()
await firstRun
expect(savedDescriptions).toEqual(['v1', 'v3'])
})
it('marks error on failure and clears unsaved state after retry success', async () => {
const stateByPanel = new Map<string, PanelSaveState>()
let attemptCount = 0
const coordinator = new PanelSaveCoordinator({
onSavingChange: () => {},
onStateChange: (panelId, state) => {
stateByPanel.set(panelId, state)
},
runSave: async () => {
attemptCount += 1
if (attemptCount === 1) {
throw new Error('network timeout')
}
},
resolveErrorMessage: (error) => (error instanceof Error ? error.message : 'unknown'),
})
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('draft text'))
await firstRun
expect(stateByPanel.get('panel-1')).toEqual({
status: 'error',
errorMessage: 'network timeout',
})
const retryRun = coordinator.retry('panel-1', buildSnapshot('draft text'))
await retryRun
expect(stateByPanel.get('panel-1')).toEqual({
status: 'idle',
errorMessage: null,
})
})
})

View File

@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Character, Location, Project } from '@/types/project'
import type { ProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
import { queryKeys } from '@/lib/query/keys'
import { MockQueryClient } from '../../helpers/mock-query-client'
let queryClient = new MockQueryClient()
const useQueryClientMock = vi.fn(() => queryClient)
const useMutationMock = vi.fn((options: unknown) => options)
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useRef: <T,>(value: T) => ({ current: value }),
}
})
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,
requestJsonWithError: vi.fn(),
requestVoidWithError: vi.fn(),
invalidateQueryTemplates: vi.fn(),
}
})
import {
useDeleteProjectCharacter,
useSelectProjectCharacterImage,
} from '@/lib/query/mutations/character-base-mutations'
interface SelectProjectCharacterMutation {
onMutate: (variables: {
characterId: string
appearanceId: string
imageIndex: number | null
}) => Promise<unknown>
onError: (error: unknown, variables: unknown, context: unknown) => void
}
interface DeleteProjectCharacterMutation {
onMutate: (characterId: string) => Promise<unknown>
onError: (error: unknown, characterId: string, context: unknown) => void
}
function buildCharacter(selectedIndex: number | null): Character {
return {
id: 'character-1',
name: 'Hero',
appearances: [{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'default',
description: null,
descriptions: null,
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
imageUrls: ['img-0', 'img-1', 'img-2'],
previousImageUrl: null,
previousImageUrls: [],
previousDescription: null,
previousDescriptions: null,
selectedIndex,
}],
}
}
function buildAssets(selectedIndex: number | null): ProjectAssetsData {
return {
characters: [buildCharacter(selectedIndex)],
locations: [] as Location[],
}
}
function buildProject(selectedIndex: number | null): Project {
return {
novelPromotionData: {
characters: [buildCharacter(selectedIndex)],
locations: [],
},
} as unknown as Project
}
describe('project asset optimistic mutations', () => {
beforeEach(() => {
queryClient = new MockQueryClient()
useQueryClientMock.mockClear()
useMutationMock.mockClear()
})
it('optimistically selects project character image and ignores stale rollback', async () => {
const projectId = 'project-1'
const assetsKey = queryKeys.projectAssets.all(projectId)
const projectKey = queryKeys.projectData(projectId)
queryClient.seedQuery(assetsKey, buildAssets(0))
queryClient.seedQuery(projectKey, buildProject(0))
const mutation = useSelectProjectCharacterImage(projectId) as unknown as SelectProjectCharacterMutation
const firstVariables = {
characterId: 'character-1',
appearanceId: 'appearance-1',
imageIndex: 1,
}
const secondVariables = {
characterId: 'character-1',
appearanceId: 'appearance-1',
imageIndex: 2,
}
const firstContext = await mutation.onMutate(firstVariables)
const afterFirst = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterFirst?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
const secondContext = await mutation.onMutate(secondVariables)
const afterSecond = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterSecond?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('first failed'), firstVariables, firstContext)
const afterStaleError = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterStaleError?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('second failed'), secondVariables, secondContext)
const afterLatestRollback = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterLatestRollback?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
})
it('optimistically deletes project character and restores on error', async () => {
const projectId = 'project-1'
const assetsKey = queryKeys.projectAssets.all(projectId)
const projectKey = queryKeys.projectData(projectId)
queryClient.seedQuery(assetsKey, buildAssets(0))
queryClient.seedQuery(projectKey, buildProject(0))
const mutation = useDeleteProjectCharacter(projectId) as unknown as DeleteProjectCharacterMutation
const context = await mutation.onMutate('character-1')
const afterDeleteAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterDeleteAssets?.characters).toHaveLength(0)
const afterDeleteProject = queryClient.getQueryData<Project>(projectKey)
expect(afterDeleteProject?.novelPromotionData?.characters ?? []).toHaveLength(0)
mutation.onError(new Error('delete failed'), 'character-1', context)
const rolledBackAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(rolledBackAssets?.characters).toHaveLength(1)
expect(rolledBackAssets?.characters[0]?.id).toBe('character-1')
})
})

View File

@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKeys } from '@/lib/query/keys'
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE } from '@/lib/task/types'
type InvalidateArg = { queryKey?: readonly unknown[]; exact?: boolean }
type EffectCleanup = (() => void) | void
const runtime = vi.hoisted(() => ({
queryClient: {
invalidateQueries: vi.fn(async (_arg?: InvalidateArg) => undefined),
},
effectCleanup: null as EffectCleanup,
scheduledTimers: [] as Array<() => void>,
}))
const overlayMock = vi.hoisted(() => ({
applyTaskLifecycleToOverlay: vi.fn(),
}))
class FakeEventSource {
static OPEN = 1
static instances: FakeEventSource[] = []
readonly url: string
readyState = FakeEventSource.OPEN
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: Event) => void) | null = null
private listeners = new Map<string, Set<EventListener>>()
constructor(url: string) {
this.url = url
FakeEventSource.instances.push(this)
}
addEventListener(type: string, handler: EventListener) {
const set = this.listeners.get(type) || new Set<EventListener>()
set.add(handler)
this.listeners.set(type, set)
}
removeEventListener(type: string, handler: EventListener) {
const set = this.listeners.get(type)
if (!set) return
set.delete(handler)
}
emit(type: string, payload: unknown) {
const event = { data: JSON.stringify(payload) } as MessageEvent
if (this.onmessage) this.onmessage(event)
const set = this.listeners.get(type)
if (!set) return
for (const handler of set) {
handler(event as unknown as Event)
}
}
close() {
this.readyState = 2
}
}
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
useRef: <T,>(value: T) => ({ current: value }),
useEffect: (effect: () => EffectCleanup) => {
runtime.effectCleanup = effect()
},
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => runtime.queryClient,
}))
vi.mock('@/lib/query/task-target-overlay', () => overlayMock)
function hasInvalidation(predicate: (arg: InvalidateArg) => boolean) {
return runtime.queryClient.invalidateQueries.mock.calls.some((call) => {
const arg = (call[0] || {}) as InvalidateArg
return predicate(arg)
})
}
describe('sse invalidation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
runtime.effectCleanup = null
runtime.scheduledTimers = []
FakeEventSource.instances = []
;(globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource
;(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = {
setTimeout: ((cb: () => void) => {
runtime.scheduledTimers.push(cb)
return runtime.scheduledTimers.length as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
}
})
it('PROCESSING(progress 数值) 不触发 target-state invalidationCOMPLETED 触发', async () => {
const { useSSE } = await import('@/lib/query/hooks/useSSE')
useSSE({
projectId: 'project-1',
episodeId: 'episode-1',
enabled: true,
})
const source = FakeEventSource.instances[0]
expect(source).toBeTruthy()
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: 'task-1',
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: 'episode-1',
payload: {
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
progress: 32,
},
})
expect(hasInvalidation((arg) => {
const key = arg.queryKey || []
return Array.isArray(key) && key[0] === 'task-target-states'
})).toBe(false)
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: 'task-1',
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: 'episode-1',
payload: {
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
},
})
for (const cb of runtime.scheduledTimers) cb()
expect(hasInvalidation((arg) => {
const key = arg.queryKey || []
return Array.isArray(key)
&& key[0] === queryKeys.tasks.targetStatesAll('project-1')[0]
&& key[1] === 'project-1'
&& arg.exact === false
})).toBe(true)
expect(overlayMock.applyTaskLifecycleToOverlay).toHaveBeenCalledWith(
runtime.queryClient,
expect.objectContaining({
projectId: 'project-1',
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
}),
)
})
})

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'
const runtime = vi.hoisted(() => ({
useQueryCalls: [] as Array<Record<string, unknown>>,
}))
const overlayNow = new Date().toISOString()
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
}
})
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: Record<string, unknown>) => {
runtime.useQueryCalls.push(options)
const queryKey = (options.queryKey || []) as unknown[]
const first = queryKey[0]
if (first === 'task-target-states-overlay') {
return {
data: {
'CharacterAppearance:appearance-1': {
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
phase: 'processing',
runningTaskId: 'task-ov-1',
runningTaskType: 'IMAGE_CHARACTER',
intent: 'process',
hasOutputAtStart: false,
progress: 50,
stage: 'generate',
stageLabel: '生成中',
updatedAt: overlayNow,
lastError: null,
expiresAt: Date.now() + 30_000,
},
'NovelPromotionPanel:panel-1': {
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
phase: 'queued',
runningTaskId: 'task-ov-2',
runningTaskType: 'LIP_SYNC',
intent: 'process',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
updatedAt: overlayNow,
lastError: null,
expiresAt: Date.now() + 30_000,
},
},
}
}
return {
data: [
{
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
phase: 'idle',
runningTaskId: null,
runningTaskType: null,
intent: 'process',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: null,
},
{
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
phase: 'processing',
runningTaskId: 'task-api-panel',
runningTaskType: 'IMAGE_PANEL',
intent: 'process',
hasOutputAtStart: null,
progress: 10,
stage: 'api',
stageLabel: 'API处理中',
lastError: null,
updatedAt: overlayNow,
},
] as TaskTargetState[],
}
},
}))
describe('task target state map behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
runtime.useQueryCalls = []
})
it('keeps polling disabled and merges overlay only when rules match', async () => {
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
const result = useTaskTargetStateMap('project-1', [
{ targetType: 'CharacterAppearance', targetId: 'appearance-1', types: ['IMAGE_CHARACTER'] },
{ targetType: 'NovelPromotionPanel', targetId: 'panel-1', types: ['IMAGE_PANEL'] },
])
const firstCall = runtime.useQueryCalls[0]
expect(firstCall?.refetchInterval).toBe(false)
const appearance = result.getState('CharacterAppearance', 'appearance-1')
expect(appearance?.phase).toBe('processing')
expect(appearance?.runningTaskType).toBe('IMAGE_CHARACTER')
expect(appearance?.runningTaskId).toBe('task-ov-1')
const panel = result.getState('NovelPromotionPanel', 'panel-1')
expect(panel?.phase).toBe('processing')
expect(panel?.runningTaskType).toBe('IMAGE_PANEL')
expect(panel?.runningTaskId).toBe('task-api-panel')
})
})