返回 oh-my-ppt
page-merge-service.test.ts
根目录 / tests / unit / session / page-merge-service.test.ts
1 import fs from 'fs'
2 import os from 'os'
3 import path from 'path'
4 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
6 const mocks = vi.hoisted(() => ({
7 loadEditableSessionPages: vi.fn(),
8 persistManagedPages: vi.fn(),
9 ensureHistoryBaselineSafe: vi.fn(),
10 recordHistoryOperationStrict: vi.fn(),
11 buildFontHeadTags: vi.fn(),
12 logInfo: vi.fn(),
13 logWarn: vi.fn(),
14 logError: vi.fn()
15 }))
16
17 vi.mock('electron-log/main.js', () => ({
18 default: {
19 info: mocks.logInfo,
20 warn: mocks.logWarn,
21 error: mocks.logError
22 }
23 }))
24
25 vi.mock('../../../src/main/session/page-management-service', () => ({
26 loadEditableSessionPages: mocks.loadEditableSessionPages,
27 persistManagedPages: mocks.persistManagedPages
28 }))
29
30 vi.mock('../../../src/main/history/git-history-service', () => ({
31 ensureHistoryBaselineSafe: mocks.ensureHistoryBaselineSafe,
32 recordHistoryOperationStrict: mocks.recordHistoryOperationStrict
33 }))
34
35 vi.mock('../../../src/main/session/template-builder', () => ({
36 SESSION_ASSET_FILE_NAMES: ['ppt-runtime.js', 'index-runtime.js']
37 }))
38
39 vi.mock('../../../src/main/presentation/html/html-utils', () => ({
40 validatePersistedPageHtml: () => ({ valid: true, errors: [] })
41 }))
42
43 vi.mock('../../../src/main/presentation/fonts/font-registry', () => ({
44 buildFontHeadTags: mocks.buildFontHeadTags
45 }))
46
47 vi.mock('../../../src/main/templates/template-service', () => ({
48 loadTemplateManifest: vi.fn(),
49 listTemplates: vi.fn()
50 }))
51
52 vi.mock('../../../src/main/templates/template-paths', () => ({
53 resolveTemplateRelativePath: vi.fn()
54 }))
55
56 import {
57 listMergeSourceSessions,
58 mergeSessionPages
59 } from '../../../src/main/session/page-merge-service'
60
61 const wideSlideSize = {
62 slideSizeId: 'wide-16-9',
63 slideWidth: 1600,
64 slideHeight: 900
65 }
66
67 describe('mergeSessionPages', () => {
68 let root: string
69 let sourceProjectDir: string
70 let targetProjectDir: string
71 let sourceSkeletons: Array<Record<string, unknown>>
72 let upsertedPages: Array<{ id: string; title: string; pageNumber: number; fileSlug: string }>
73
74 const createContext = () => ({
75 db: {
76 getSession: vi.fn(async (sessionId: string) => ({
77 id: sessionId,
78 title: sessionId === 'source' ? 'Source deck' : 'Target deck',
79 status: 'completed',
80 metadata: '{}',
81 ...wideSlideSize
82 })),
83 listSessionsWithPageCounts: vi.fn(),
84 listSourcePageSkeletons: vi.fn(async () => sourceSkeletons),
85 upsertSessionPage: vi.fn(async (page) => {
86 upsertedPages.push(page)
87 }),
88 upsertSourcePageSkeleton: vi.fn(),
89 getProject: vi.fn(async () => ({ id: 'project', status: 'published' })),
90 updateProjectStatus: vi.fn(),
91 updateSessionStatus: vi.fn(),
92 hardDeleteSessionPages: vi.fn(),
93 deleteSourcePageSkeletons: vi.fn(),
94 replaceSessionPageOrder: vi.fn(),
95 updateSessionMetadata: vi.fn()
96 },
97 sessionRunStates: new Map(),
98 getPageSourceUrl: (htmlPath?: string) => (htmlPath ? `file://${htmlPath}` : undefined)
99 })
100
101 beforeEach(async () => {
102 root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'page-merge-service-'))
103 sourceProjectDir = path.join(root, 'source')
104 targetProjectDir = path.join(root, 'target')
105 sourceSkeletons = []
106 upsertedPages = []
107 await fs.promises.mkdir(path.join(sourceProjectDir, 'images'), { recursive: true })
108 await fs.promises.mkdir(path.join(sourceProjectDir, 'assets', 'custom-font'), {
109 recursive: true
110 })
111 await fs.promises.mkdir(
112 path.join(sourceProjectDir, 'assets', 'fonts', 'google-fonts', 'Source-Font'),
113 { recursive: true }
114 )
115 await fs.promises.mkdir(
116 path.join(targetProjectDir, 'assets', 'fonts', 'google-fonts', 'Target-Font'),
117 { recursive: true }
118 )
119 await fs.promises.mkdir(targetProjectDir, { recursive: true })
120 await fs.promises.writeFile(path.join(sourceProjectDir, 'images', 'shared.png'), 'image')
121 await fs.promises.writeFile(
122 path.join(sourceProjectDir, 'assets', 'custom-font', 'font.css'),
123 '@font-face{font-family:Demo;src:url("./demo.woff2")}'
124 )
125 await fs.promises.writeFile(
126 path.join(sourceProjectDir, 'assets', 'custom-font', 'demo.woff2'),
127 'font'
128 )
129 await fs.promises.writeFile(
130 path.join(sourceProjectDir, 'assets', 'fonts', 'google-fonts', 'Source-Font', 'source.woff2'),
131 'source-font'
132 )
133 await fs.promises.writeFile(
134 path.join(targetProjectDir, 'assets', 'fonts', 'google-fonts', 'Target-Font', 'target.woff2'),
135 'target-font'
136 )
137 await fs.promises.writeFile(
138 path.join(sourceProjectDir, 'page-two.html'),
139 '<html><head><link rel="stylesheet" href="./assets/custom-font/font.css"><style data-ppt-fonts="google">@font-face{font-family:"Source Font";src:url("./assets/fonts/google-fonts/Source-Font/source.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Source Font";--ppt-body-font:"Source Font"}</style><style>.source-font{font-family:"Source Font"}</style></head><body data-page-id="page-two"><img src="./images/shared.png"><p class="source-font">Two</p></body></html>'
140 )
141 await fs.promises.writeFile(
142 path.join(sourceProjectDir, 'page-one.html'),
143 '<html><body data-page-id="page-one"><p>One</p></body></html>'
144 )
145 await fs.promises.writeFile(
146 path.join(targetProjectDir, 'existing.html'),
147 '<html><head><style data-ppt-fonts="google">@font-face{font-family:"Target Font";src:url("./assets/fonts/google-fonts/Target-Font/target.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Target Font";--ppt-body-font:"Target Font"}</style></head><body data-page-id="existing"><p>Existing</p></body></html>'
148 )
149 await fs.promises.writeFile(path.join(targetProjectDir, 'index.html'), '<html>old index</html>')
150
151 mocks.loadEditableSessionPages.mockImplementation(async (_ctx, sessionId: string) => {
152 if (sessionId === 'source') {
153 return {
154 session: wideSlideSize,
155 projectDir: sourceProjectDir,
156 indexPath: path.join(sourceProjectDir, 'index.html'),
157 deckTitle: 'Source',
158 pages: [
159 {
160 id: 'source-page-1',
161 pageNumber: 1,
162 pageId: 'page-one',
163 title: 'One',
164 htmlPath: path.join(sourceProjectDir, 'page-one.html'),
165 status: 'completed'
166 },
167 {
168 id: 'source-page-2',
169 pageNumber: 2,
170 pageId: 'page-two',
171 title: 'Two',
172 htmlPath: path.join(sourceProjectDir, 'page-two.html'),
173 status: 'completed'
174 }
175 ]
176 }
177 }
178 return {
179 session: wideSlideSize,
180 projectDir: targetProjectDir,
181 indexPath: path.join(targetProjectDir, 'index.html'),
182 deckTitle: 'Target',
183 pages: [
184 {
185 id: 'target-page-1',
186 pageNumber: 1,
187 pageId: 'existing',
188 title: 'Existing',
189 htmlPath: path.join(targetProjectDir, 'existing.html'),
190 status: 'completed'
191 }
192 ]
193 }
194 })
195 mocks.persistManagedPages.mockImplementation(async (_ctx, args) => args.pages)
196 })
197
198 afterEach(async () => {
199 vi.restoreAllMocks()
200 vi.clearAllMocks()
201 await fs.promises.rm(root, { recursive: true, force: true })
202 })
203
204 it('sorts selected source pages, copies resources, and records one history operation', async () => {
205 const context = createContext()
206
207 const result = await mergeSessionPages(context as never, {
208 targetSessionId: 'target',
209 sourceSessionId: 'source',
210 sourcePageIds: ['source-page-2', 'source-page-1']
211 })
212
213 expect(upsertedPages.map((page) => page.title)).toEqual(['One', 'Two'])
214 expect(upsertedPages.map((page) => page.pageNumber)).toEqual([2, 3])
215 expect(result.insertedPageIds).toHaveLength(2)
216 expect(result.generatedPages.map((page) => page.title)).toEqual(['Existing', 'One', 'Two'])
217 expect(mocks.persistManagedPages).toHaveBeenCalledTimes(1)
218 expect(mocks.recordHistoryOperationStrict).toHaveBeenCalledTimes(1)
219 expect(mocks.logInfo).toHaveBeenCalledWith(
220 '[page-merge]',
221 expect.objectContaining({ stage: 'request:start', targetSessionId: 'target' })
222 )
223 expect(mocks.logInfo).toHaveBeenCalledWith(
224 '[page-merge]',
225 expect.objectContaining({
226 stage: 'request:completed',
227 insertedPageCount: 2,
228 targetSessionId: 'target'
229 })
230 )
231
232 const secondMergedPage = upsertedPages.find((page) => page.title === 'Two')
233 expect(secondMergedPage).toBeDefined()
234 const copiedHtml = await fs.promises.readFile(
235 path.join(targetProjectDir, `${secondMergedPage?.fileSlug}.html`),
236 'utf-8'
237 )
238 expect(copiedHtml).toContain('./assets/merged-pages/')
239 expect(copiedHtml).toContain('font-family:"Target Font"')
240 expect(copiedHtml).toContain('./assets/fonts/google-fonts/Target-Font/target.woff2')
241 expect(copiedHtml).not.toContain('Source Font')
242 expect(copiedHtml).not.toContain('Source-Font/source.woff2')
243 const copiedAssets = await fs.promises.readdir(
244 path.join(targetProjectDir, 'assets', 'merged-pages'),
245 { recursive: true }
246 )
247 expect(copiedAssets.some((entry) => String(entry).endsWith('shared.png'))).toBe(true)
248 expect(copiedAssets.some((entry) => String(entry).endsWith('demo.woff2'))).toBe(false)
249 expect(copiedAssets.some((entry) => String(entry).endsWith('source.woff2'))).toBe(false)
250 const copiedCssPath = copiedAssets.find((entry) => String(entry).endsWith('font.css'))
251 expect(copiedCssPath).toBeDefined()
252 expect(
253 await fs.promises.readFile(
254 path.join(targetProjectDir, 'assets', 'merged-pages', String(copiedCssPath)),
255 'utf-8'
256 )
257 ).not.toContain('@font-face')
258 })
259
260 it('uses one aggregated query for source sessions', async () => {
261 const context = createContext()
262 context.db.listSessionsWithPageCounts.mockResolvedValue([
263 {
264 session: {
265 id: 'target',
266 title: 'Target',
267 status: 'completed',
268 updated_at: 3,
269 ...wideSlideSize
270 },
271 pageCount: 1
272 },
273 {
274 session: {
275 id: 'source',
276 title: 'Source',
277 status: 'completed',
278 updated_at: 2,
279 ...wideSlideSize
280 },
281 pageCount: 4
282 }
283 ])
284
285 const result = await listMergeSourceSessions(context as never, 'target')
286
287 expect(context.db.listSessionsWithPageCounts).toHaveBeenCalledTimes(1)
288 expect(result).toEqual([
289 expect.objectContaining({ id: 'source', pageCount: 4, selectable: true })
290 ])
291 })
292
293 it('disables and rejects source sessions with a different canvas size', async () => {
294 const context = createContext()
295 context.db.getSession.mockImplementation(async (sessionId: string) => ({
296 id: sessionId,
297 title: sessionId,
298 status: 'completed',
299 metadata: '{}',
300 slideSizeId: sessionId === 'source' ? 'vertical-9-16' : 'wide-16-9',
301 slideWidth: sessionId === 'source' ? 900 : 1600,
302 slideHeight: sessionId === 'source' ? 1600 : 900
303 }))
304 context.db.listSessionsWithPageCounts.mockResolvedValue([
305 {
306 session: await context.db.getSession('source'),
307 pageCount: 2
308 }
309 ])
310
311 await expect(listMergeSourceSessions(context as never, 'target')).resolves.toEqual([
312 expect.objectContaining({
313 id: 'source',
314 selectable: false,
315 disabledReason: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH'
316 })
317 ])
318 await expect(
319 mergeSessionPages(context as never, {
320 targetSessionId: 'target',
321 sourceSessionId: 'source',
322 sourcePageIds: ['source-page-1']
323 })
324 ).rejects.toMatchObject({ code: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH' })
325 })
326
327 it('rejects a page when a required local resource is missing', async () => {
328 await fs.promises.writeFile(
329 path.join(sourceProjectDir, 'page-one.html'),
330 '<html><body data-page-id="page-one"><img src="./images/missing.png"></body></html>'
331 )
332 const context = createContext()
333
334 await expect(
335 mergeSessionPages(context as never, {
336 targetSessionId: 'target',
337 sourceSessionId: 'source',
338 sourcePageIds: ['source-page-1']
339 })
340 ).rejects.toMatchObject({ code: 'PAGE_MERGE_PAGE_COPY_FAILED' })
341
342 expect(upsertedPages).toEqual([])
343 })
344
345 it('copies source documents and rewrites the skeleton path', async () => {
346 await fs.promises.mkdir(path.join(sourceProjectDir, 'docs'), { recursive: true })
347 await fs.promises.writeFile(path.join(sourceProjectDir, 'docs', 'source.md'), '# source')
348 sourceSkeletons = [
349 {
350 page_number: 2,
351 title: 'Two',
352 role: 'content',
353 source_document_path: '/docs/source.md',
354 source_document_name: 'source.md',
355 source_heading: 'Two heading',
356 heading_level: 1,
357 line_start: 1,
358 line_end: 2,
359 reason: null,
360 confidence: 'high'
361 }
362 ]
363 const context = createContext()
364
365 await mergeSessionPages(context as never, {
366 targetSessionId: 'target',
367 sourceSessionId: 'source',
368 sourcePageIds: ['source-page-2']
369 })
370
371 expect(context.db.upsertSourcePageSkeleton).toHaveBeenCalledWith(
372 expect.objectContaining({
373 sourceDocumentPath: expect.stringMatching(/^\/docs\/merged-pages\/mg_/),
374 sourceHeading: 'Two heading'
375 })
376 )
377 const copiedDocs = await fs.promises.readdir(
378 path.join(targetProjectDir, 'docs', 'merged-pages'),
379 { recursive: true }
380 )
381 expect(copiedDocs.some((entry) => String(entry).endsWith('source.md'))).toBe(true)
382 })
383
384 it('uses a legacy marker when the source document is missing', async () => {
385 sourceSkeletons = [
386 {
387 page_number: 2,
388 title: 'Two',
389 role: 'content',
390 source_document_path: '/docs/missing.md',
391 source_document_name: 'missing.md',
392 source_heading: 'Two heading',
393 heading_level: 1,
394 line_start: 1,
395 line_end: 2,
396 reason: null,
397 confidence: 'medium'
398 }
399 ]
400 const context = createContext()
401
402 await mergeSessionPages(context as never, {
403 targetSessionId: 'target',
404 sourceSessionId: 'source',
405 sourcePageIds: ['source-page-2']
406 })
407
408 expect(context.db.upsertSourcePageSkeleton).toHaveBeenCalledWith(
409 expect.objectContaining({
410 sourceDocumentPath: expect.stringMatching(/^merged-session:mg_/)
411 })
412 )
413 })
414
415 it('rolls back database rows, files, resources, and index when commit fails', async () => {
416 const context = createContext()
417 context.db.getSession.mockImplementation(async (sessionId: string) => ({
418 id: sessionId,
419 title: sessionId === 'source' ? 'Source deck' : 'Target deck',
420 status: sessionId === 'target' ? 'failed' : 'completed',
421 metadata: '{}',
422 ...wideSlideSize
423 }))
424 mocks.persistManagedPages.mockImplementationOnce(async (_ctx, args) => {
425 await fs.promises.writeFile(args.indexPath, '<html>new index</html>')
426 return args.pages
427 })
428 mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed'))
429
430 await expect(
431 mergeSessionPages(context as never, {
432 targetSessionId: 'target',
433 sourceSessionId: 'source',
434 sourcePageIds: ['source-page-2']
435 })
436 ).rejects.toThrow('history failed')
437
438 expect(context.db.hardDeleteSessionPages).toHaveBeenCalledWith(
439 'target',
440 expect.arrayContaining([expect.any(String)])
441 )
442 expect(context.db.deleteSourcePageSkeletons).toHaveBeenCalled()
443 expect(context.db.replaceSessionPageOrder).toHaveBeenCalledWith('target', [
444 { id: 'target-page-1', pageNumber: 1 }
445 ])
446 expect(context.db.updateSessionStatus).toHaveBeenLastCalledWith('target', 'failed')
447 expect(context.db.updateProjectStatus).toHaveBeenLastCalledWith('project', 'published')
448 expect(await fs.promises.readFile(path.join(targetProjectDir, 'index.html'), 'utf-8')).toBe(
449 '<html>old index</html>'
450 )
451 expect(
452 await fs.promises.readdir(path.join(targetProjectDir, 'assets', 'merged-pages'), {
453 recursive: true
454 })
455 ).toEqual([])
456 for (const page of upsertedPages) {
457 await expect(
458 fs.promises.access(path.join(targetProjectDir, `${page.fileSlug}.html`))
459 ).rejects.toThrow()
460 }
461 })
462
463 it('removes newly staged target font files when merge commit fails', async () => {
464 await fs.promises.writeFile(
465 path.join(targetProjectDir, 'existing.html'),
466 '<html><body data-page-id="existing"><p>Existing</p></body></html>'
467 )
468 mocks.buildFontHeadTags.mockImplementationOnce(async ({ projectDir }) => {
469 const fontPath = path.join(
470 projectDir,
471 'assets',
472 'fonts',
473 'google-fonts',
474 'Fallback-Font',
475 'fallback.woff2'
476 )
477 await fs.promises.mkdir(path.dirname(fontPath), { recursive: true })
478 await fs.promises.writeFile(fontPath, 'fallback-font')
479 return '<style data-ppt-fonts="google">@font-face{font-family:"Fallback Font";src:url("./assets/fonts/google-fonts/Fallback-Font/fallback.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Fallback Font";--ppt-body-font:"Fallback Font"}</style>'
480 })
481 mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed'))
482 const context = createContext()
483
484 await expect(
485 mergeSessionPages(context as never, {
486 targetSessionId: 'target',
487 sourceSessionId: 'source',
488 sourcePageIds: ['source-page-1']
489 })
490 ).rejects.toThrow('history failed')
491
492 await expect(
493 fs.promises.access(
494 path.join(
495 targetProjectDir,
496 'assets',
497 'fonts',
498 'google-fonts',
499 'Fallback-Font',
500 'fallback.woff2'
501 )
502 )
503 ).rejects.toThrow()
504 })
505
506 it('logs the rollback stage when a compensation step fails', async () => {
507 const context = createContext()
508 context.db.hardDeleteSessionPages.mockRejectedValueOnce(new Error('delete failed'))
509 mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed'))
510 await expect(
511 mergeSessionPages(context as never, {
512 targetSessionId: 'target',
513 sourceSessionId: 'source',
514 sourcePageIds: ['source-page-1']
515 })
516 ).rejects.toThrow('history failed')
517
518 expect(mocks.logError).toHaveBeenCalledWith(
519 '[page-merge]',
520 expect.objectContaining({ stage: 'request:failed', failedStage: 'record-history' })
521 )
522 expect(mocks.logWarn).toHaveBeenCalledWith(
523 '[page-merge]',
524 expect.objectContaining({
525 stage: 'rollback:delete-session-pages:failed',
526 error: 'delete failed'
527 })
528 )
529 })
530 })
531
531 lines TYPESCRIPT