| 1 | import { beforeEach, describe, expect, it } from 'vitest' |
| 2 | import { |
| 3 | hydrateStyleSwitchJob, |
| 4 | isStyleSwitchJobActive, |
| 5 | isStyleSwitchPageLocked, |
| 6 | useGenerateStore |
| 7 | } from '../../../src/renderer/src/store/generateStore' |
| 8 | |
| 9 | describe('generateStore page edit job', () => { |
| 10 | const sessionId = 'session-1' |
| 11 | |
| 12 | beforeEach(() => { |
| 13 | useGenerateStore.getState().reset() |
| 14 | }) |
| 15 | |
| 16 | it('tracks a page edit without enabling the global generation lock', () => { |
| 17 | useGenerateStore.getState().startPageEdit(sessionId, { pageId: 'page-2', pageNumber: 2 }) |
| 18 | useGenerateStore.getState().updatePageEdit(sessionId, { |
| 19 | runId: 'run-edit-1', |
| 20 | status: 'running', |
| 21 | label: '正在编辑第 2 页', |
| 22 | progress: 55 |
| 23 | }) |
| 24 | |
| 25 | expect(useGenerateStore.getState()).toMatchObject({ |
| 26 | isGenerating: false, |
| 27 | pageEditJobs: { |
| 28 | [sessionId]: { |
| 29 | pageId: 'page-2', |
| 30 | pageNumber: 2, |
| 31 | runId: 'run-edit-1', |
| 32 | status: 'running', |
| 33 | progress: 55 |
| 34 | } |
| 35 | } |
| 36 | }) |
| 37 | |
| 38 | useGenerateStore.getState().finishPageEdit(sessionId) |
| 39 | expect(useGenerateStore.getState().pageEditJobs[sessionId]).toBeUndefined() |
| 40 | }) |
| 41 | |
| 42 | it('keeps a page-edit plan pending without creating a page job', () => { |
| 43 | useGenerateStore.getState().startPageEditPlanning(sessionId, 'page-2') |
| 44 | useGenerateStore.getState().setPendingPageEditPlan(sessionId, { |
| 45 | targetPageId: 'page-2', |
| 46 | targetPageNumber: 2, |
| 47 | payload: { |
| 48 | sessionId: 'session-1', |
| 49 | userMessage: '把标题改成更简洁的表达', |
| 50 | type: 'page', |
| 51 | chatType: 'page', |
| 52 | selectedPageId: 'page-2' |
| 53 | }, |
| 54 | plan: { |
| 55 | intent: 'content', |
| 56 | target: '第 2 页标题', |
| 57 | summary: '精简标题并保持现有布局。', |
| 58 | changes: ['缩短标题文案'], |
| 59 | confirmationQuestion: '确认按此计划修改吗?' |
| 60 | } |
| 61 | }) |
| 62 | useGenerateStore.getState().finishPageEditPlanning(sessionId) |
| 63 | |
| 64 | expect(useGenerateStore.getState()).toMatchObject({ |
| 65 | isGenerating: false, |
| 66 | pageEditJobs: {}, |
| 67 | pageEditPlanning: { |
| 68 | [sessionId]: { |
| 69 | isAssessing: false, |
| 70 | pendingPlan: { |
| 71 | targetPageId: 'page-2', |
| 72 | plan: { intent: 'content' } |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | }) |
| 77 | |
| 78 | useGenerateStore.getState().clearPendingPageEditPlan(sessionId) |
| 79 | expect(useGenerateStore.getState().pageEditPlanning[sessionId]?.pendingPlan).toBeNull() |
| 80 | }) |
| 81 | |
| 82 | it('tracks a deck edit by session without clearing the current pages', () => { |
| 83 | useGenerateStore.getState().setPages([ |
| 84 | { |
| 85 | id: 'page-record-1', |
| 86 | pageId: 'page-1', |
| 87 | pageNumber: 1, |
| 88 | title: 'Page 1', |
| 89 | html: '<div>Page</div>' |
| 90 | } |
| 91 | ]) |
| 92 | useGenerateStore.getState().startDeckEdit(sessionId, { totalPages: 3 }) |
| 93 | useGenerateStore.getState().updateDeckEdit(sessionId, { |
| 94 | runId: 'deck-edit-run-1', |
| 95 | status: 'running', |
| 96 | label: '正在编辑主会话', |
| 97 | progress: 40, |
| 98 | totalPages: 3 |
| 99 | }) |
| 100 | |
| 101 | expect(useGenerateStore.getState()).toMatchObject({ |
| 102 | isGenerating: false, |
| 103 | currentPages: [{ pageId: 'page-1' }], |
| 104 | deckEditJobs: { |
| 105 | [sessionId]: { |
| 106 | runId: 'deck-edit-run-1', |
| 107 | totalPages: 3, |
| 108 | progress: 40 |
| 109 | } |
| 110 | } |
| 111 | }) |
| 112 | |
| 113 | useGenerateStore.getState().finishDeckEdit(sessionId) |
| 114 | expect(useGenerateStore.getState()).toMatchObject({ |
| 115 | isGenerating: false, |
| 116 | deckEditJobs: {}, |
| 117 | currentPages: [{ pageId: 'page-1' }] |
| 118 | }) |
| 119 | }) |
| 120 | |
| 121 | it('keeps jobs and plans isolated between sessions', () => { |
| 122 | useGenerateStore.getState().startPageEdit('session-a', { pageId: 'page-a', pageNumber: 1 }) |
| 123 | useGenerateStore.getState().startDeckEdit('session-b', { totalPages: 2 }) |
| 124 | useGenerateStore.getState().setPendingPageEditPlan('session-a', { |
| 125 | targetPageId: 'page-a', |
| 126 | payload: { sessionId: 'session-a', userMessage: '改标题', type: 'page', chatType: 'page' }, |
| 127 | plan: { |
| 128 | intent: 'content', |
| 129 | target: '标题', |
| 130 | summary: '更新标题', |
| 131 | changes: ['替换标题文本'], |
| 132 | confirmationQuestion: '确认修改吗?' |
| 133 | } |
| 134 | }) |
| 135 | |
| 136 | expect(useGenerateStore.getState().pageEditJobs['session-b']).toBeUndefined() |
| 137 | expect(useGenerateStore.getState().deckEditJobs['session-a']).toBeUndefined() |
| 138 | expect(useGenerateStore.getState().pageEditPlanning['session-b']).toBeUndefined() |
| 139 | expect(useGenerateStore.getState().pageEditPlanning['session-a']?.pendingPlan).not.toBeNull() |
| 140 | }) |
| 141 | |
| 142 | it('does not let an older assessment finish a newer assessment', () => { |
| 143 | useGenerateStore.getState().startPageEditPlanning(sessionId, 'page-2', 'assessment-old') |
| 144 | useGenerateStore.getState().startPageEditPlanning(sessionId, 'page-2', 'assessment-new') |
| 145 | |
| 146 | useGenerateStore.getState().finishPageEditPlanning(sessionId, 'assessment-old') |
| 147 | expect(useGenerateStore.getState().pageEditPlanning[sessionId]).toMatchObject({ |
| 148 | assessmentId: 'assessment-new', |
| 149 | isAssessing: true |
| 150 | }) |
| 151 | |
| 152 | useGenerateStore.getState().finishPageEditPlanning(sessionId, 'assessment-new') |
| 153 | expect(useGenerateStore.getState().pageEditPlanning[sessionId]?.isAssessing).toBe(false) |
| 154 | }) |
| 155 | |
| 156 | it('keeps session errors isolated and clears only the session that starts a new job', () => { |
| 157 | useGenerateStore.getState().setSessionError('session-a', 'A failed') |
| 158 | useGenerateStore.getState().setSessionError('session-b', 'B failed') |
| 159 | |
| 160 | useGenerateStore.getState().startPageEdit('session-a', { pageId: 'page-a', pageNumber: 1 }) |
| 161 | |
| 162 | expect(useGenerateStore.getState().sessionErrors).toEqual({ |
| 163 | 'session-b': 'B failed' |
| 164 | }) |
| 165 | |
| 166 | useGenerateStore.getState().clearSessionError('session-b') |
| 167 | expect(useGenerateStore.getState().sessionErrors).toEqual({}) |
| 168 | }) |
| 169 | |
| 170 | it('keeps a cancelling edit job locked until a terminal event removes it', () => { |
| 171 | useGenerateStore.getState().startDeckEdit(sessionId, { totalPages: 2 }) |
| 172 | useGenerateStore.getState().updateDeckEdit(sessionId, { |
| 173 | status: 'cancelling', |
| 174 | label: '正在取消并恢复修改前内容' |
| 175 | }) |
| 176 | |
| 177 | expect(useGenerateStore.getState().deckEditJobs[sessionId]).toMatchObject({ |
| 178 | status: 'cancelling' |
| 179 | }) |
| 180 | |
| 181 | useGenerateStore.getState().finishDeckEdit(sessionId) |
| 182 | expect(useGenerateStore.getState().deckEditJobs[sessionId]).toBeUndefined() |
| 183 | }) |
| 184 | |
| 185 | it('unlocks only pages with a committed style-switch result', () => { |
| 186 | useGenerateStore.getState().startStyleSwitch(sessionId, { |
| 187 | styleId: 'style-new', |
| 188 | styleName: 'New style', |
| 189 | totalPages: 2, |
| 190 | pages: [ |
| 191 | { |
| 192 | pageId: 'page-1', |
| 193 | pageNumber: 1, |
| 194 | title: 'Page 1', |
| 195 | status: 'pending', |
| 196 | error: null, |
| 197 | retryCount: 0 |
| 198 | }, |
| 199 | { |
| 200 | pageId: 'page-2', |
| 201 | pageNumber: 2, |
| 202 | title: 'Page 2', |
| 203 | status: 'pending', |
| 204 | error: null, |
| 205 | retryCount: 0 |
| 206 | } |
| 207 | ] |
| 208 | }) |
| 209 | useGenerateStore.getState().updateStyleSwitchJob(sessionId, { |
| 210 | runId: 'style-run-1', |
| 211 | status: 'running' |
| 212 | }) |
| 213 | const job = useGenerateStore.getState().styleSwitchJobs[sessionId] |
| 214 | |
| 215 | expect(isStyleSwitchJobActive(job)).toBe(true) |
| 216 | expect(isStyleSwitchPageLocked(job, 'page-1')).toBe(true) |
| 217 | expect(isStyleSwitchPageLocked(job, 'page-2')).toBe(true) |
| 218 | |
| 219 | useGenerateStore.getState().updateStyleSwitchPage(sessionId, 'page-1', { |
| 220 | status: 'completed', |
| 221 | error: null |
| 222 | }) |
| 223 | const afterFirstCommit = useGenerateStore.getState().styleSwitchJobs[sessionId] |
| 224 | expect(isStyleSwitchPageLocked(afterFirstCommit, 'page-1')).toBe(false) |
| 225 | expect(isStyleSwitchPageLocked(afterFirstCommit, 'page-2')).toBe(true) |
| 226 | |
| 227 | useGenerateStore.getState().finishStyleSwitch(sessionId, { status: 'partial', error: null }) |
| 228 | expect(isStyleSwitchJobActive(useGenerateStore.getState().styleSwitchJobs[sessionId])).toBe( |
| 229 | false |
| 230 | ) |
| 231 | expect( |
| 232 | isStyleSwitchPageLocked(useGenerateStore.getState().styleSwitchJobs[sessionId], 'page-2') |
| 233 | ).toBe(true) |
| 234 | }) |
| 235 | |
| 236 | it('replaces an optimistic style-switch job with the persisted active run', () => { |
| 237 | useGenerateStore.getState().startStyleSwitch(sessionId, { |
| 238 | styleId: 'style-new', |
| 239 | totalPages: 1, |
| 240 | pages: [ |
| 241 | { |
| 242 | pageId: 'page-1', |
| 243 | pageNumber: 1, |
| 244 | title: 'Optimistic page', |
| 245 | status: 'pending', |
| 246 | error: null, |
| 247 | retryCount: 0 |
| 248 | } |
| 249 | ] |
| 250 | }) |
| 251 | |
| 252 | hydrateStyleSwitchJob(sessionId, { |
| 253 | runId: 'persisted-run', |
| 254 | status: 'running', |
| 255 | progress: 40, |
| 256 | totalPages: 2, |
| 257 | targetStyleId: 'style-existing', |
| 258 | targetStyleName: 'Existing style', |
| 259 | error: null, |
| 260 | pages: [ |
| 261 | { |
| 262 | pageId: 'page-1', |
| 263 | pageNumber: 1, |
| 264 | title: 'Persisted page', |
| 265 | status: 'completed', |
| 266 | error: null, |
| 267 | retryCount: 1 |
| 268 | }, |
| 269 | { |
| 270 | pageId: 'page-2', |
| 271 | pageNumber: 2, |
| 272 | title: 'Running page', |
| 273 | status: 'running', |
| 274 | error: null, |
| 275 | retryCount: 0 |
| 276 | } |
| 277 | ] |
| 278 | }) |
| 279 | |
| 280 | expect(useGenerateStore.getState().styleSwitchJobs[sessionId]).toMatchObject({ |
| 281 | runId: 'persisted-run', |
| 282 | status: 'running', |
| 283 | styleId: 'style-existing', |
| 284 | totalPages: 2 |
| 285 | }) |
| 286 | expect( |
| 287 | isStyleSwitchPageLocked(useGenerateStore.getState().styleSwitchJobs[sessionId], 'page-1') |
| 288 | ).toBe(false) |
| 289 | expect( |
| 290 | isStyleSwitchPageLocked(useGenerateStore.getState().styleSwitchJobs[sessionId], 'page-2') |
| 291 | ).toBe(true) |
| 292 | }) |
| 293 | }) |
| 294 |