返回 oh-my-ppt
style-switch.test.ts
根目录 / tests / unit / generation / style-switch.test.ts
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
257 lines TYPESCRIPT