| 1 | /** |
| 2 | * @vitest-environment happy-dom |
| 3 | */ |
| 4 | import React, { act } from 'react' |
| 5 | import { createRoot } from 'react-dom/client' |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 7 | import type { GenerateChunkEvent } from '@shared/generation' |
| 8 | |
| 9 | const { onGenerateChunkMock, navigateMock, getSessionMock, toastMock } = vi.hoisted(() => ({ |
| 10 | onGenerateChunkMock: vi.fn(), |
| 11 | navigateMock: vi.fn(), |
| 12 | getSessionMock: vi.fn(), |
| 13 | toastMock: { |
| 14 | success: vi.fn(), |
| 15 | error: vi.fn(), |
| 16 | warning: vi.fn(), |
| 17 | info: vi.fn() |
| 18 | } |
| 19 | })) |
| 20 | |
| 21 | vi.mock('react-router-dom', () => ({ |
| 22 | useNavigate: () => navigateMock |
| 23 | })) |
| 24 | |
| 25 | vi.mock('../../../src/renderer/src/lib/ipc', () => ({ |
| 26 | ipc: { |
| 27 | onGenerateChunk: onGenerateChunkMock, |
| 28 | getSession: getSessionMock |
| 29 | } |
| 30 | })) |
| 31 | |
| 32 | vi.mock('../../../src/renderer/src/store', () => ({ |
| 33 | useToastStore: { |
| 34 | getState: () => toastMock |
| 35 | } |
| 36 | })) |
| 37 | |
| 38 | vi.mock('../../../src/renderer/src/i18n', () => ({ |
| 39 | useT: () => (key: string, params?: Record<string, string | number>) => { |
| 40 | if (key === 'generationNotifications.untitled') return '未命名' |
| 41 | if (key === 'generationNotifications.view') return '查看' |
| 42 | if (key === 'generationNotifications.completed') return `完成 ${params?.title ?? ''}` |
| 43 | if (key === 'generationNotifications.failed') return `失败 ${params?.title ?? ''}` |
| 44 | return key |
| 45 | } |
| 46 | })) |
| 47 | |
| 48 | import { useGenerationNotifications } from '../../../src/renderer/src/hooks/useGenerationNotifications' |
| 49 | |
| 50 | function Probe() { |
| 51 | useGenerationNotifications() |
| 52 | return null |
| 53 | } |
| 54 | |
| 55 | const flushAsync = async (): Promise<void> => { |
| 56 | // The notify() path awaits readSessionTitle() then calls toast. Two microtask ticks cover |
| 57 | // the awaited ipc.getSession resolution and the subsequent state-mutating sync call. |
| 58 | await Promise.resolve() |
| 59 | await Promise.resolve() |
| 60 | } |
| 61 | |
| 62 | describe('useGenerationNotifications', () => { |
| 63 | let container: HTMLDivElement |
| 64 | let captured: ((chunk: GenerateChunkEvent) => void) | null = null |
| 65 | |
| 66 | beforeEach(() => { |
| 67 | container = document.createElement('div') |
| 68 | document.body.appendChild(container) |
| 69 | captured = null |
| 70 | onGenerateChunkMock.mockImplementation((cb: (chunk: GenerateChunkEvent) => void) => { |
| 71 | captured = cb |
| 72 | return () => { |
| 73 | captured = null |
| 74 | } |
| 75 | }) |
| 76 | getSessionMock.mockResolvedValue({ session: { title: 'Deck' } }) |
| 77 | Object.values(toastMock).forEach((mock) => mock.mockClear()) |
| 78 | navigateMock.mockClear() |
| 79 | act(() => { |
| 80 | createRoot(container).render(React.createElement(Probe)) |
| 81 | }) |
| 82 | }) |
| 83 | |
| 84 | afterEach(() => { |
| 85 | container.remove() |
| 86 | }) |
| 87 | |
| 88 | it('fires the completion toast for a full-deck generation run', async () => { |
| 89 | expect(captured).toBeTruthy() |
| 90 | captured!({ |
| 91 | type: 'run_completed', |
| 92 | payload: { runId: 'run-1', totalPages: 3, sessionId: 's1', activityKind: undefined } |
| 93 | }) |
| 94 | await flushAsync() |
| 95 | expect(toastMock.success).toHaveBeenCalledTimes(1) |
| 96 | }) |
| 97 | |
| 98 | it('suppresses the toast for a page-beautify run (dedicated in-page toast handles it)', async () => { |
| 99 | captured!({ |
| 100 | type: 'run_completed', |
| 101 | payload: { runId: 'run-2', totalPages: 1, sessionId: 's1', activityKind: 'page-beautify' } |
| 102 | }) |
| 103 | await flushAsync() |
| 104 | expect(toastMock.success).not.toHaveBeenCalled() |
| 105 | }) |
| 106 | |
| 107 | it('suppresses the toast for page-edit, deck-edit, and style-switch runs', async () => { |
| 108 | const kinds = ['page-edit', 'deck-edit', 'style-switch'] as const |
| 109 | for (const activityKind of kinds) { |
| 110 | captured!({ |
| 111 | type: 'run_completed', |
| 112 | payload: { runId: `run-${activityKind}`, totalPages: 1, sessionId: 's1', activityKind } |
| 113 | }) |
| 114 | } |
| 115 | await flushAsync() |
| 116 | expect(toastMock.success).not.toHaveBeenCalled() |
| 117 | }) |
| 118 | |
| 119 | it('suppresses the error toast for a failed page-beautify run (session-detail owns the error path)', async () => { |
| 120 | captured!({ |
| 121 | type: 'run_error', |
| 122 | payload: { |
| 123 | runId: 'run-fail', |
| 124 | sessionId: 's1', |
| 125 | activityKind: 'page-beautify', |
| 126 | message: 'boom' |
| 127 | } |
| 128 | }) |
| 129 | await flushAsync() |
| 130 | expect(toastMock.error).not.toHaveBeenCalled() |
| 131 | }) |
| 132 | |
| 133 | it('suppresses the error toast for a failed single-page retry', async () => { |
| 134 | captured!({ |
| 135 | type: 'run_error', |
| 136 | payload: { |
| 137 | runId: 'run-single-page-fail', |
| 138 | sessionId: 's1', |
| 139 | activityKind: 'single-page-retry', |
| 140 | message: 'boom' |
| 141 | } |
| 142 | }) |
| 143 | await flushAsync() |
| 144 | expect(toastMock.error).not.toHaveBeenCalled() |
| 145 | }) |
| 146 | |
| 147 | it('suppresses the error toast for a failed generated-page addition', async () => { |
| 148 | captured!({ |
| 149 | type: 'run_error', |
| 150 | payload: { |
| 151 | runId: 'run-add-page-fail', |
| 152 | sessionId: 's1', |
| 153 | activityKind: 'addPage', |
| 154 | message: 'boom' |
| 155 | } |
| 156 | }) |
| 157 | await flushAsync() |
| 158 | expect(toastMock.error).not.toHaveBeenCalled() |
| 159 | }) |
| 160 | }) |
| 161 |