| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import { describe, expect, it } from 'vitest' |
| 4 | import { |
| 5 | buildStyleSwitchUserMessage, |
| 6 | collectFailedStyleSwitchPageIds |
| 7 | } from '../../../src/main/generation/style-switch' |
| 8 | |
| 9 | describe('style switch generation', () => { |
| 10 | it('builds a strict visual-only deck edit instruction', () => { |
| 11 | const message = buildStyleSwitchUserMessage('极简白') |
| 12 | expect(message).toContain('现有风格「极简白」') |
| 13 | expect(message).toContain('禁止修改每页文字内容') |
| 14 | expect(message).toContain('必须逐字逐项原样保留') |
| 15 | expect(message).toContain('页面布局与视觉结构可以按现有风格重新设计') |
| 16 | expect(message).not.toContain('禁止改变信息结构和内容层级') |
| 17 | }) |
| 18 | |
| 19 | it('preserves style names containing prompt delimiters', () => { |
| 20 | const styleName = '「未来」“数据”\n第二行' |
| 21 | const message = buildStyleSwitchUserMessage(styleName) |
| 22 | |
| 23 | expect(message).toContain(`现有风格「${styleName}」`) |
| 24 | expect(message).toContain('禁止修改每页文字内容') |
| 25 | expect(message).toContain('页面布局与视觉结构可以按现有风格重新设计') |
| 26 | }) |
| 27 | |
| 28 | it('collects failed retry page ids with legacy fallbacks', () => { |
| 29 | expect( |
| 30 | collectFailedStyleSwitchPageIds([ |
| 31 | { id: 'row-0', page_id: 'page-0', file_slug: 'slug-0', status: 'failed' }, |
| 32 | { id: 'row-1', file_slug: 'page-1', legacy_page_id: 'legacy-1', status: 'failed' }, |
| 33 | { id: 'row-2', file_slug: '', legacy_page_id: 'legacy-2', status: 'failed' }, |
| 34 | { id: 'row-3', file_slug: '', legacy_page_id: '', status: 'failed' }, |
| 35 | { id: 'row-4', file_slug: 'page-4', legacy_page_id: 'legacy-4', status: 'completed' }, |
| 36 | { id: '', file_slug: '', legacy_page_id: '', status: 'failed' } |
| 37 | ]) |
| 38 | ).toEqual(['page-0', 'page-1', 'legacy-2', 'row-3']) |
| 39 | }) |
| 40 | |
| 41 | it('uses an independent persistent style-switch job with two workers', () => { |
| 42 | const serviceSource = fs.readFileSync( |
| 43 | path.resolve('src/main/edit-jobs/style-switch-job-service.ts'), |
| 44 | 'utf8' |
| 45 | ) |
| 46 | const typesSource = fs.readFileSync( |
| 47 | path.resolve('src/main/edit-jobs/style-switch-job-types.ts'), |
| 48 | 'utf8' |
| 49 | ) |
| 50 | const flowSource = fs.readFileSync( |
| 51 | path.resolve('src/main/edit-jobs/style-switch-job-flow.ts'), |
| 52 | 'utf8' |
| 53 | ) |
| 54 | const databaseSource = fs.readFileSync(path.resolve('src/main/db/database.ts'), 'utf8') |
| 55 | |
| 56 | expect(typesSource).toContain('const STYLE_SWITCH_CONCURRENCY = 2') |
| 57 | expect(typesSource).toContain("jobType: 'style-switch'") |
| 58 | expect(serviceSource).toContain("kind: 'style-switch'") |
| 59 | expect(serviceSource).toContain("mode: 'style-switch'") |
| 60 | expect(serviceSource).toContain('createGenerationRunWithSessionJobAndPages') |
| 61 | expect(serviceSource).toContain('private reservedJobIds') |
| 62 | expect(serviceSource).toContain("domain: 'style'") |
| 63 | expect(serviceSource).toContain('sessionLockKey(sessionId)') |
| 64 | expect(serviceSource).toContain('{ runId: lease.jobId, abortSignal: lease.signal }') |
| 65 | expect(serviceSource).toContain('this.coordinator.cancel(job.lease.jobId)') |
| 66 | expect(serviceSource).not.toContain('SessionJobCoordinator') |
| 67 | expect(serviceSource).not.toContain('lease.controller') |
| 68 | expect(serviceSource).not.toContain('agentManager.cancelSession') |
| 69 | expect(serviceSource).toContain( |
| 70 | 'await this.ctx.db.replaceSessionStyleSnapshot(sessionId, styleId)' |
| 71 | ) |
| 72 | expect(serviceSource).toContain('context = await resolveEditContext') |
| 73 | expect(serviceSource).toContain('await this.runWorkers(job)') |
| 74 | expect(serviceSource).toContain('restoreStyleSwitchFileSnapshot(indexPath, indexSnapshot)') |
| 75 | expect(serviceSource).toContain('runStyleSwitchPageFlow') |
| 76 | expect(flowSource).toContain('runDeepAgentEdit') |
| 77 | expect(flowSource).toContain('projectDir: job.context.projectDir') |
| 78 | expect(flowSource).toContain('signal: job.context.abortSignal') |
| 79 | expect(flowSource).not.toContain('context.entry') |
| 80 | expect(flowSource).toContain("editScope: 'page'") |
| 81 | expect(flowSource).toContain('pageFileMap: { [page.pageId]: page.htmlPath }') |
| 82 | expect(serviceSource).not.toContain('executeDeckAllPageEditGeneration') |
| 83 | expect(databaseSource).toContain("| 'style-switch'") |
| 84 | expect(databaseSource).toContain('createGenerationRunWithSessionJobAndPages') |
| 85 | }) |
| 86 | |
| 87 | it('does not carry the previous visual contract into the new style', () => { |
| 88 | const message = buildStyleSwitchUserMessage('极简白') |
| 89 | |
| 90 | expect(message).toContain('禁止沿用此前风格的配色、装饰和布局语言') |
| 91 | expect(message).toContain('视觉设计必须以当前现有风格规范为准') |
| 92 | |
| 93 | const flowSource = fs.readFileSync( |
| 94 | path.resolve('src/main/generation/edit-deck-allpage-flow.ts'), |
| 95 | 'utf8' |
| 96 | ) |
| 97 | expect(flowSource).toContain('!context.resetVisualStyle &&') |
| 98 | expect(flowSource).toContain('!context.resetVisualStyle && page.layout_intent') |
| 99 | expect(flowSource).toContain( |
| 100 | 'let savedDesignContract: DesignContract | undefined = context.designContract' |
| 101 | ) |
| 102 | }) |
| 103 | |
| 104 | it('starts through the dedicated job UI without a style-switch dialog', () => { |
| 105 | const styleViewSource = fs.readFileSync( |
| 106 | path.resolve('src/renderer/src/components/session-detail/style/StyleView.tsx'), |
| 107 | 'utf8' |
| 108 | ) |
| 109 | const jobBarSource = fs.readFileSync( |
| 110 | path.resolve('src/renderer/src/components/session-detail/style/StyleSwitchJobBar.tsx'), |
| 111 | 'utf8' |
| 112 | ) |
| 113 | const cancelHookSource = fs.readFileSync( |
| 114 | path.resolve('src/renderer/src/components/session-detail/hooks/useCancelStyleSwitch.ts'), |
| 115 | 'utf8' |
| 116 | ) |
| 117 | |
| 118 | expect(styleViewSource).toContain('startStyleSwitch') |
| 119 | expect(styleViewSource).toContain('ipc.startStyleSwitch') |
| 120 | expect(styleViewSource).not.toContain('AlertDialog') |
| 121 | expect(styleViewSource).not.toContain('setSwitchTarget') |
| 122 | expect(jobBarSource).toContain('useCancelStyleSwitch') |
| 123 | expect(cancelHookSource).toContain('ipc.cancelStyleSwitch') |
| 124 | expect(cancelHookSource).toContain('if (!result.success)') |
| 125 | expect(cancelHookSource).toContain('ipc.getStyleSwitchState(sessionId)') |
| 126 | expect(jobBarSource).toContain('ipc.retryFailedStyleSwitchPages') |
| 127 | expect(jobBarSource).toContain("if (!job || job.status === 'completed') return null") |
| 128 | }) |
| 129 | |
| 130 | it('retries only failed pages through the dedicated style-switch service', () => { |
| 131 | const serviceSource = fs.readFileSync( |
| 132 | path.resolve('src/main/edit-jobs/style-switch-job-service.ts'), |
| 133 | 'utf8' |
| 134 | ) |
| 135 | |
| 136 | expect(serviceSource).toContain('async retryPage(') |
| 137 | expect(serviceSource).toContain("page.page_id === pageId && page.status === 'failed'") |
| 138 | expect(serviceSource).toContain('async retryFailed(') |
| 139 | expect(serviceSource).toContain(".filter((page) => page.status === 'failed')") |
| 140 | expect(serviceSource).toContain("ipcMain.handle('style-switch:retryPage'") |
| 141 | expect(serviceSource).toContain("ipcMain.handle('style-switch:retryFailed'") |
| 142 | }) |
| 143 | |
| 144 | it('uses the session style snapshot when the global style has been disabled', () => { |
| 145 | const handlerSource = fs.readFileSync( |
| 146 | path.resolve('src/main/styles/handlers.ts'), |
| 147 | 'utf8' |
| 148 | ) |
| 149 | |
| 150 | expect(handlerSource).toContain('await db.getSessionStyleSnapshot(sessionId)') |
| 151 | expect(handlerSource).toContain('items.unshift({') |
| 152 | expect(handlerSource).toContain('id: snapshot.styleId') |
| 153 | }) |
| 154 | |
| 155 | it('retries normal deck edits through the deck job with their original request', () => { |
| 156 | const handlerSource = fs.readFileSync( |
| 157 | path.resolve('src/main/generation/handlers.ts'), |
| 158 | 'utf8' |
| 159 | ) |
| 160 | const retryHandler = handlerSource.slice( |
| 161 | handlerSource.indexOf("ipcMain.handle('generate:retryDeckEdit'"), |
| 162 | handlerSource.indexOf("ipcMain.handle('generate:startTemplate'") |
| 163 | ) |
| 164 | |
| 165 | const deckJobSource = fs.readFileSync( |
| 166 | path.resolve('src/main/edit-jobs/deck-edit-job-service.ts'), |
| 167 | 'utf8' |
| 168 | ) |
| 169 | |
| 170 | expect(retryHandler).toContain('return deckEditJobs.retry(event, payload)') |
| 171 | expect(deckJobSource).toContain('getFailedPagesForRun(sessionId, failedRunId)') |
| 172 | expect(deckJobSource).toContain('userMessage,') |
| 173 | expect(deckJobSource).toContain('selectPageIds: failedPageIds') |
| 174 | expect(deckJobSource).toContain('persistUserMessage: false') |
| 175 | expect(deckJobSource).toContain('const result = await this.start(event') |
| 176 | expect(deckJobSource).not.toContain('executeRetryFailedPages') |
| 177 | }) |
| 178 | |
| 179 | it('keeps internal style-switch prompts out of the visible chat history', () => { |
| 180 | const serviceSource = fs.readFileSync( |
| 181 | path.resolve('src/main/edit-jobs/style-switch-job-service.ts'), |
| 182 | 'utf8' |
| 183 | ) |
| 184 | const editFlowSource = fs.readFileSync( |
| 185 | path.resolve('src/main/generation/edit-flow.ts'), |
| 186 | 'utf8' |
| 187 | ) |
| 188 | |
| 189 | expect(serviceSource).toContain('persistUserMessage: false') |
| 190 | expect(editFlowSource).toContain('if (input.persistUserMessage)') |
| 191 | }) |
| 192 | |
| 193 | it('writes a page history commit before publishing it as editable', () => { |
| 194 | const serviceSource = fs.readFileSync( |
| 195 | path.resolve('src/main/edit-jobs/style-switch-job-service.ts'), |
| 196 | 'utf8' |
| 197 | ) |
| 198 | const historySource = fs.readFileSync( |
| 199 | path.resolve('src/main/history/git-history-service.ts'), |
| 200 | 'utf8' |
| 201 | ) |
| 202 | const commitPageSource = serviceSource.slice( |
| 203 | serviceSource.indexOf('private async commitPage'), |
| 204 | serviceSource.indexOf('private emitPageProgress') |
| 205 | ) |
| 206 | |
| 207 | expect(commitPageSource).toContain("scope: 'page'") |
| 208 | expect(commitPageSource).toContain('prompt: `切换风格 · 第 ${page.pageNumber} 页`') |
| 209 | expect(commitPageSource).toContain('styleName: job.context.styleName || null') |
| 210 | expect(commitPageSource).toContain('allowedPaths: [relativePath]') |
| 211 | expect(commitPageSource).toContain('if (!operation?.after_commit)') |
| 212 | expect(commitPageSource).toContain("status: 'completed'") |
| 213 | expect(commitPageSource.indexOf('recordOperation({')).toBeLessThan( |
| 214 | commitPageSource.indexOf("type: 'page_updated'") |
| 215 | ) |
| 216 | expect(commitPageSource.indexOf('recordOperation({')).toBeLessThan( |
| 217 | commitPageSource.indexOf('await this.ctx.db.upsertSessionPage({') |
| 218 | ) |
| 219 | expect(commitPageSource.indexOf('recordOperation({')).toBeLessThan( |
| 220 | commitPageSource.indexOf('await this.ctx.db.upsertGenerationPage({') |
| 221 | ) |
| 222 | expect(commitPageSource.indexOf('if (!operation?.after_commit)')).toBeLessThan( |
| 223 | commitPageSource.indexOf("type: 'page_updated'") |
| 224 | ) |
| 225 | expect(historySource).toContain('allowedPaths?: string[]') |
| 226 | expect(historySource).toContain('stageControlledChanges(projectDir, args.allowedPaths)') |
| 227 | expect(historySource).toContain('git.resetIndex({ fs, dir: projectDir, filepath })') |
| 228 | expect(historySource).toContain('rollbackCommittedOperation') |
| 229 | expect(historySource).toContain("if (metadata.jobType === 'style-switch')") |
| 230 | expect(historySource).toContain('`切换风格 · 第 ${styleSwitchPageNumber} 页`') |
| 231 | expect(commitPageSource).toContain('history.rollbackCommittedOperation') |
| 232 | }) |
| 233 | |
| 234 | it('does not commit queued pages after cancellation or roll back a durable commit on notify failure', () => { |
| 235 | const serviceSource = fs.readFileSync( |
| 236 | path.resolve('src/main/edit-jobs/style-switch-job-service.ts'), |
| 237 | 'utf8' |
| 238 | ) |
| 239 | const commitPageSource = serviceSource.slice( |
| 240 | serviceSource.indexOf('private async commitPage'), |
| 241 | serviceSource.indexOf('private emitPageProgress') |
| 242 | ) |
| 243 | |
| 244 | expect(commitPageSource).toContain('this.assertCommitNotCancelled(job)') |
| 245 | expect(commitPageSource.indexOf('this.assertCommitNotCancelled(job)')).toBeLessThan( |
| 246 | commitPageSource.indexOf('recordOperation({') |
| 247 | ) |
| 248 | expect(commitPageSource).toContain( |
| 249 | "log.warn('[style-switch:job] page commit notification failed'" |
| 250 | ) |
| 251 | expect(commitPageSource.indexOf('if (!operation?.after_commit)')).toBeLessThan( |
| 252 | commitPageSource.indexOf("log.warn('[style-switch:job] page commit notification failed'") |
| 253 | ) |
| 254 | expect(commitPageSource).toContain('retryCount: page.retryCount') |
| 255 | }) |
| 256 | }) |
| 257 |