release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal file
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
169
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal file
169
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal file
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal file
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal file
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal file
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal 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 invalidation;COMPLETED 触发', 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
123
tests/unit/optimistic/task-target-state-map.test.ts
Normal file
123
tests/unit/optimistic/task-target-state-map.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user