返回 oh-my-ppt
master-mutation-service.test.ts
根目录 / tests / unit / master / master-mutation-service.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2 import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3 import os from 'node:os'
4 import path from 'node:path'
5 import { createDefaultMasterGradient } from '../../../src/shared/master'
6
7 const state = vi.hoisted(() => ({
8 ensureBaseline: vi.fn(),
9 recordOperation: vi.fn(),
10 rollbackCommittedOperation: vi.fn(),
11 resolveProjectFontResources: vi.fn(),
12 copyProjectFontResources: vi.fn()
13 }))
14
15 vi.mock('../../../src/main/history/git-history-service', () => ({
16 GitHistoryService: class {
17 ensureBaseline = state.ensureBaseline
18 recordOperation = state.recordOperation
19 rollbackCommittedOperation = state.rollbackCommittedOperation
20 }
21 }))
22
23 vi.mock('../../../src/main/presentation/fonts/font-registry', () => ({
24 resolveProjectFontResources: state.resolveProjectFontResources,
25 copyProjectFontResources: state.copyProjectFontResources
26 }))
27
28 const roots: string[] = []
29
30 const pageRecord = (htmlPath: string) => ({
31 id: 'page-row-1',
32 session_id: 'session-1',
33 legacy_page_id: null,
34 file_slug: 'page-1',
35 page_number: 1,
36 title: 'Page 1',
37 html_path: htmlPath,
38 status: 'completed',
39 error: null,
40 created_at: 0,
41 updated_at: 0,
42 deleted_at: null
43 })
44
45 afterEach(async () => {
46 await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
47 })
48
49 describe('slide master mutation transaction', () => {
50 beforeEach(() => {
51 state.ensureBaseline.mockReset()
52 state.recordOperation.mockReset()
53 state.rollbackCommittedOperation.mockReset()
54 state.resolveProjectFontResources.mockReset()
55 state.copyProjectFontResources.mockReset()
56 state.recordOperation.mockResolvedValue(null)
57 state.resolveProjectFontResources.mockResolvedValue({ css: '', assets: [] })
58 state.copyProjectFontResources.mockResolvedValue(undefined)
59 })
60
61 const createContext = (projectDir: string, pages: ReturnType<typeof pageRecord>[]) => ({
62 sessionRunStates: new Map(),
63 resolveSessionProjectDir: vi.fn(async () => projectDir),
64 db: {
65 listSessionPages: vi.fn(async () => pages)
66 }
67 })
68
69 it('saves the current config and links every readable session page in one operation', async () => {
70 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
71 roots.push(projectDir)
72 const htmlPath = path.join(projectDir, 'page-1.html')
73 await writeFile(htmlPath, '<html><head><style>.page{}</style></head><body>Page</body></html>')
74 const ctx = createContext(projectDir, [pageRecord(htmlPath)])
75 const { saveSessionMaster } = await import('../../../src/main/session/master-mutation-service')
76
77 const result = await saveSessionMaster(ctx as never, 'session-1', {
78 backgroundColor: '#f1efea',
79 backgroundMode: 'override',
80 backgroundStyle: 'solid',
81 backgroundGradient: createDefaultMasterGradient(),
82 backgroundImage: null,
83 titleFontPreset: 'serif',
84 bodyFontPreset: 'sans',
85 titleFontFamily: null,
86 bodyFontFamily: null,
87 titleFontSize: null,
88 bodyFontSize: null,
89 elements: {
90 logoImage: null,
91 footerText: '',
92 watermarkText: '',
93 showLogo: false,
94 showFooter: false,
95 showPageNumber: true,
96 showWatermark: false,
97 footerFontSize: 16,
98 pageNumberFontSize: 16,
99 footerColor: '#334155',
100 pageNumberColor: '#334155',
101 watermarkRotation: -24,
102 watermarkSizeAuto: true,
103 logoPosition: { x: 5, y: 5 },
104 footerPosition: { x: 5, y: 91 },
105 pageNumberPosition: { x: 90, y: 91 },
106 watermarkPosition: { x: 30, y: 42 },
107 logoSize: { width: 16, height: 10 },
108 footerSize: { width: 56, height: 5 },
109 pageNumberSize: { width: 6, height: 5 },
110 watermarkSize: { width: 40, height: 16 }
111 }
112 })
113
114 expect(result).toMatchObject({
115 exists: true,
116 linkedPageCount: 1,
117 unlinkedPageCount: 0,
118 missingPageCount: 0,
119 totalPageCount: 1
120 })
121 expect(result.config).toEqual({
122 backgroundColor: '#f1efea',
123 backgroundMode: 'override',
124 backgroundStyle: 'solid',
125 backgroundGradient: createDefaultMasterGradient(),
126 backgroundImage: null,
127 titleFontPreset: 'serif',
128 bodyFontPreset: 'sans',
129 titleFontFamily: null,
130 bodyFontFamily: null,
131 titleFontSize: null,
132 bodyFontSize: null,
133 elements: {
134 logoImage: null,
135 footerText: '',
136 watermarkText: '',
137 showLogo: false,
138 showFooter: false,
139 showPageNumber: true,
140 showWatermark: false,
141 footerFontSize: 16,
142 pageNumberFontSize: 16,
143 footerColor: '#334155',
144 pageNumberColor: '#334155',
145 watermarkRotation: -24,
146 watermarkSizeAuto: true,
147 logoPosition: { x: 5, y: 5 },
148 footerPosition: { x: 5, y: 91 },
149 pageNumberPosition: { x: 90, y: 91 },
150 watermarkPosition: { x: 30, y: 42 },
151 logoSize: { width: 16, height: 10 },
152 footerSize: { width: 56, height: 5 },
153 pageNumberSize: { width: 6, height: 5 },
154 watermarkSize: { width: 40, height: 16 }
155 }
156 })
157 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toContain(
158 '--ppt-master-slide-background: #f1efea'
159 )
160 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toContain(
161 '--ppt-title-font:'
162 )
163 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toContain(
164 '--ppt-body-font:'
165 )
166 expect(await readFile(htmlPath, 'utf8')).toContain('data-ppt-master="1"')
167 expect(state.recordOperation).toHaveBeenCalledWith(
168 expect.objectContaining({
169 prompt: '修改并应用演示母版',
170 metadata: { feature: 'slide-master', action: 'save-and-apply' },
171 allowedPaths: ['master/master.css', 'master/master.html', 'page-1.html']
172 })
173 )
174 })
175
176 it('rejects missing pages before writing master.css or changing any HTML', async () => {
177 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
178 roots.push(projectDir)
179 const missingPath = path.join(projectDir, 'page-1.html')
180 const ctx = createContext(projectDir, [pageRecord(missingPath)])
181 const { saveSessionMaster } = await import('../../../src/main/session/master-mutation-service')
182
183 await expect(
184 saveSessionMaster(ctx as never, 'session-1', {
185 backgroundColor: '#112233',
186 backgroundMode: 'override',
187 backgroundStyle: 'solid',
188 backgroundGradient: createDefaultMasterGradient(),
189 backgroundImage: null,
190 titleFontPreset: 'inherit',
191 bodyFontPreset: 'inherit',
192 titleFontFamily: null,
193 bodyFontFamily: null,
194 titleFontSize: null,
195 bodyFontSize: null
196 })
197 ).rejects.toThrow('存在缺失或不安全的页面文件')
198 await expect(readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).rejects.toMatchObject({
199 code: 'ENOENT'
200 })
201 expect(state.recordOperation).not.toHaveBeenCalled()
202 })
203
204 it('reports pages as unlinked when their master stylesheet is missing', async () => {
205 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
206 roots.push(projectDir)
207 const htmlPath = path.join(projectDir, 'page-1.html')
208 await writeFile(
209 htmlPath,
210 '<html><head><link rel="stylesheet" href="./master/master.css" data-ppt-master="1"></head><body>Page</body></html>'
211 )
212 const ctx = createContext(projectDir, [pageRecord(htmlPath)])
213 const { getSessionMasterStatus } =
214 await import('../../../src/main/session/master-mutation-service')
215
216 await expect(getSessionMasterStatus(ctx as never, 'session-1')).resolves.toMatchObject({
217 exists: false,
218 linkedPageCount: 0,
219 unlinkedPageCount: 1,
220 missingPageCount: 0,
221 totalPageCount: 1
222 })
223 })
224
225 it('restores the previous master.css and every touched page if history recording fails', async () => {
226 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
227 roots.push(projectDir)
228 const htmlPath = path.join(projectDir, 'page-1.html')
229 const originalHtml = '<html><head></head><body>Page</body></html>'
230 const originalMaster = '/* user managed baseline */\n:root { --ppt-page-bg: #aabbcc; }\n'
231 await writeFile(htmlPath, originalHtml)
232 await mkdir(path.join(projectDir, 'master'), { recursive: true })
233 await writeFile(path.join(projectDir, 'master', 'master.css'), originalMaster)
234 state.recordOperation.mockRejectedValueOnce(new Error('history unavailable'))
235 const ctx = createContext(projectDir, [pageRecord(htmlPath)])
236 const { saveSessionMaster } = await import('../../../src/main/session/master-mutation-service')
237
238 await expect(
239 saveSessionMaster(ctx as never, 'session-1', {
240 backgroundColor: '#112233',
241 backgroundMode: 'override',
242 backgroundStyle: 'solid',
243 backgroundGradient: createDefaultMasterGradient(),
244 backgroundImage: null,
245 titleFontPreset: 'mono',
246 bodyFontPreset: 'sans',
247 titleFontFamily: null,
248 bodyFontFamily: null,
249 titleFontSize: null,
250 bodyFontSize: null
251 })
252 ).rejects.toThrow('history unavailable')
253 expect(await readFile(htmlPath, 'utf8')).toBe(originalHtml)
254 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toBe(originalMaster)
255 })
256
257 it('records the selected session background image in the master history operation', async () => {
258 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
259 roots.push(projectDir)
260 const htmlPath = path.join(projectDir, 'page-1.html')
261 const backgroundPath = path.join(projectDir, 'images', 'master-background.png')
262 await writeFile(htmlPath, '<html><head></head><body>Page</body></html>')
263 await mkdir(path.dirname(backgroundPath), { recursive: true })
264 await writeFile(backgroundPath, 'not-a-real-png')
265 const ctx = createContext(projectDir, [pageRecord(htmlPath)])
266 const { saveSessionMaster } = await import('../../../src/main/session/master-mutation-service')
267
268 await saveSessionMaster(ctx as never, 'session-1', {
269 backgroundColor: '#ffffff',
270 backgroundMode: 'override',
271 backgroundStyle: 'image',
272 backgroundGradient: createDefaultMasterGradient(),
273 backgroundImage: './images/master-background.png',
274 titleFontPreset: 'inherit',
275 bodyFontPreset: 'inherit',
276 titleFontFamily: null,
277 bodyFontFamily: null,
278 titleFontSize: null,
279 bodyFontSize: null
280 })
281
282 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toContain(
283 'url("../images/master-background.png") center center / cover no-repeat'
284 )
285 expect(state.recordOperation).toHaveBeenCalledWith(
286 expect.objectContaining({
287 allowedPaths: [
288 'master/master.css',
289 'master/master.html',
290 'page-1.html',
291 'images/master-background.png'
292 ]
293 })
294 )
295 })
296
297 it('persists selected font resources in the same history operation as master.css', async () => {
298 const projectDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-master-mutation-'))
299 roots.push(projectDir)
300 const htmlPath = path.join(projectDir, 'page-1.html')
301 const targetPath = path.join(
302 projectDir,
303 'assets',
304 'fonts',
305 'google-fonts',
306 'NotoSansSC',
307 'noto-sans-sc.woff2'
308 )
309 await writeFile(htmlPath, '<html><head></head><body>Page</body></html>')
310 state.resolveProjectFontResources.mockResolvedValueOnce({
311 css: '@font-face{font-family:"Noto Sans SC";src:url("./assets/fonts/google-fonts/NotoSansSC/noto-sans-sc.woff2")}',
312 assets: [{ sourcePath: '/font-registry/NotoSansSC/noto-sans-sc.woff2', targetPath }]
313 })
314 const ctx = createContext(projectDir, [pageRecord(htmlPath)])
315 const { saveSessionMaster } = await import('../../../src/main/session/master-mutation-service')
316
317 await saveSessionMaster(ctx as never, 'session-1', {
318 backgroundColor: '#ffffff',
319 backgroundMode: 'inherit',
320 backgroundStyle: 'solid',
321 backgroundGradient: createDefaultMasterGradient(),
322 backgroundImage: null,
323 titleFontPreset: 'inherit',
324 bodyFontPreset: 'inherit',
325 titleFontFamily: 'Noto Sans SC',
326 bodyFontFamily: null,
327 titleFontSize: 56,
328 bodyFontSize: null
329 })
330
331 expect(state.resolveProjectFontResources).toHaveBeenCalledWith(['Noto Sans SC'], projectDir)
332 expect(state.copyProjectFontResources).toHaveBeenCalledWith(
333 expect.objectContaining({ assets: [expect.objectContaining({ targetPath })] })
334 )
335 expect(await readFile(path.join(projectDir, 'master', 'master.css'), 'utf8')).toContain(
336 '@font-face{font-family:"Noto Sans SC";'
337 )
338 expect(state.recordOperation).toHaveBeenCalledWith(
339 expect.objectContaining({
340 allowedPaths: [
341 'master/master.css',
342 'master/master.html',
343 'page-1.html',
344 'assets/fonts/google-fonts/NotoSansSC/noto-sans-sc.woff2'
345 ]
346 })
347 )
348 })
349 })
350
350 lines TYPESCRIPT