返回 oh-my-ppt
page-merge-template-source.test.ts
根目录 / tests / unit / session / page-merge-template-source.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 loadTemplateManifest: vi.fn(),
13 listTemplates: vi.fn(),
14 logInfo: vi.fn(),
15 logWarn: vi.fn(),
16 logError: vi.fn()
17 }))
18
19 vi.mock('electron-log/main.js', () => ({
20 default: { info: mocks.logInfo, warn: mocks.logWarn, error: mocks.logError }
21 }))
22
23 vi.mock('../../../src/main/session/page-management-service', () => ({
24 loadEditableSessionPages: mocks.loadEditableSessionPages,
25 persistManagedPages: mocks.persistManagedPages
26 }))
27
28 vi.mock('../../../src/main/history/git-history-service', () => ({
29 ensureHistoryBaselineSafe: mocks.ensureHistoryBaselineSafe,
30 recordHistoryOperationStrict: mocks.recordHistoryOperationStrict
31 }))
32
33 vi.mock('../../../src/main/session/template-builder', () => ({
34 SESSION_ASSET_FILE_NAMES: ['ppt-runtime.js']
35 }))
36
37 vi.mock('../../../src/main/presentation/html/html-utils', () => ({
38 validatePersistedPageHtml: () => ({ valid: true, errors: [] })
39 }))
40
41 vi.mock('../../../src/main/presentation/fonts/font-registry', () => ({
42 buildFontHeadTags: mocks.buildFontHeadTags
43 }))
44
45 vi.mock('../../../src/main/templates/template-service', () => ({
46 loadTemplateManifest: mocks.loadTemplateManifest,
47 listTemplates: mocks.listTemplates
48 }))
49
50 vi.mock('../../../src/main/templates/template-paths', () => ({
51 resolveTemplateRelativePath: (templateDir: string, relativePath?: string) => {
52 if (!relativePath) return null
53 const resolved = path.resolve(templateDir, relativePath)
54 const rel = path.relative(templateDir, resolved)
55 if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
56 return resolved
57 }
58 }))
59
60 import {
61 listMergeSourceTemplatePages,
62 listMergeSourceTemplates,
63 mergeSessionPages
64 } from '../../../src/main/session/page-merge-service'
65
66 const wideSlideSize = { slideSizeId: 'wide-16-9', slideWidth: 1600, slideHeight: 900 }
67
68 describe('mergeSessionPages template source', () => {
69 let root: string
70 let templateDir: string
71 let targetProjectDir: string
72 let upsertedPages: Array<{ id: string; title: string; pageNumber: number; fileSlug: string }>
73
74 const createContext = (targetMetadata = '{}') => ({
75 db: {
76 getSession: vi.fn(async (sessionId: string) => ({
77 id: sessionId,
78 title: 'Target deck',
79 status: 'completed',
80 metadata: targetMetadata,
81 ...wideSlideSize
82 })),
83 getProject: vi.fn(async () => ({ id: 'project', status: 'published' })),
84 listSourcePageSkeletons: vi.fn(async () => []),
85 upsertSessionPage: vi.fn(async (page: never) => {
86 upsertedPages.push(page)
87 }),
88 upsertSourcePageSkeleton: vi.fn(),
89 updateProjectStatus: vi.fn(),
90 updateSessionStatus: vi.fn(),
91 hardDeleteSessionPages: vi.fn(),
92 deleteSourcePageSkeletons: vi.fn(),
93 replaceSessionPageOrder: vi.fn(),
94 updateSessionMetadata: vi.fn()
95 },
96 sessionRunStates: new Map(),
97 getPageSourceUrl: (htmlPath?: string) => (htmlPath ? `file://${htmlPath}` : undefined)
98 })
99
100 beforeEach(async () => {
101 root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'page-merge-template-'))
102 templateDir = path.join(root, 'tpl_source')
103 targetProjectDir = path.join(root, 'target')
104 upsertedPages = []
105
106 await fs.promises.mkdir(path.join(templateDir, 'assets', 'fonts'), { recursive: true })
107 await fs.promises.mkdir(path.join(targetProjectDir, 'assets', 'fonts'), { recursive: true })
108 await fs.promises.writeFile(
109 path.join(templateDir, 'assets', 'fonts', 'source.woff2'),
110 'source-font'
111 )
112 await fs.promises.writeFile(
113 path.join(targetProjectDir, 'assets', 'fonts', 'target.woff2'),
114 'target-font'
115 )
116 await fs.promises.writeFile(
117 path.join(templateDir, 'page-one.html'),
118 '<html><head><style data-ppt-fonts="google">@font-face{font-family:"Source Font";src:url("./assets/fonts/source.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Source Font";--ppt-body-font:"Source Font"}</style><style>.t{font-family:"Source Font"}</style></head><body data-page-id="page-tpl-1"><p class="t">Template</p></body></html>'
119 )
120 await fs.promises.writeFile(
121 path.join(targetProjectDir, 'existing.html'),
122 '<html><head><style data-ppt-fonts="google">@font-face{font-family:"Target Font";src:url("./assets/fonts/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>'
123 )
124 await fs.promises.writeFile(path.join(targetProjectDir, 'index.html'), '<html>old index</html>')
125
126 mocks.loadTemplateManifest.mockResolvedValue({
127 manifest: {
128 schemaVersion: 1 as const,
129 id: 'tpl_source',
130 name: 'Source Template',
131 description: '',
132 pageCount: 1,
133 tags: [],
134 styleId: null,
135 slideSizeId: 'wide-16-9',
136 slideWidth: 1600,
137 slideHeight: 900,
138 designContract: undefined,
139 pages: [
140 {
141 pageNumber: 1,
142 pageId: 'page-tpl-1',
143 title: 'Template Page',
144 htmlPath: 'page-one.html'
145 }
146 ]
147 },
148 templateDir
149 })
150 mocks.loadEditableSessionPages.mockResolvedValue({
151 session: { ...wideSlideSize },
152 projectDir: targetProjectDir,
153 indexPath: path.join(targetProjectDir, 'index.html'),
154 deckTitle: 'Target',
155 pages: [
156 {
157 id: 'target-page-1',
158 pageNumber: 1,
159 pageId: 'existing',
160 title: 'Existing',
161 htmlPath: path.join(targetProjectDir, 'existing.html'),
162 status: 'completed'
163 }
164 ]
165 })
166 mocks.persistManagedPages.mockImplementation(async (_ctx, args) => args.pages)
167 })
168
169 afterEach(async () => {
170 vi.restoreAllMocks()
171 vi.clearAllMocks()
172 await fs.promises.rm(root, { recursive: true, force: true })
173 })
174
175 it('pins the source template first and disables size-mismatched templates', async () => {
176 mocks.listTemplates.mockResolvedValue({
177 items: [
178 {
179 id: 'tpl_other',
180 name: 'Other',
181 pageCount: 2,
182 ...wideSlideSize,
183 updatedAt: 2,
184 thumbnailPath: null,
185 previewPages: [{ pageNumber: 1, pageId: 'p', title: 'p', htmlPath: 'p.html' }]
186 },
187 {
188 id: 'tpl_source',
189 name: 'Source Template',
190 pageCount: 1,
191 ...wideSlideSize,
192 updatedAt: 1,
193 thumbnailPath: null,
194 previewPages: [{ pageNumber: 1, pageId: 'p', title: 'p', htmlPath: 'p.html' }]
195 },
196 {
197 id: 'tpl_small',
198 name: 'Small',
199 pageCount: 1,
200 slideSizeId: 'square-1-1',
201 slideWidth: 1000,
202 slideHeight: 1000,
203 updatedAt: 3,
204 thumbnailPath: null,
205 previewPages: [{ pageNumber: 1, pageId: 'p', title: 'p', htmlPath: 'p.html' }]
206 }
207 ]
208 })
209
210 const context = createContext(JSON.stringify({ source: 'template', templateId: 'tpl_source' }))
211
212 const result = await listMergeSourceTemplates(context as never, 'target')
213
214 expect(result.map((item) => item.id)).toEqual(['tpl_source', 'tpl_small', 'tpl_other'])
215 expect(result[0]).toEqual(
216 expect.objectContaining({ id: 'tpl_source', isSource: true, selectable: true })
217 )
218 expect(result.find((item) => item.id === 'tpl_small')).toEqual(
219 expect.objectContaining({
220 selectable: false,
221 disabledReason: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH'
222 })
223 )
224 expect(result.find((item) => item.id === 'tpl_other')?.selectable).toBe(true)
225 })
226
227 it('keeps template fonts as-is (preserveFonts) instead of normalizing to the target deck', async () => {
228 const context = createContext()
229
230 const result = await mergeSessionPages(context as never, {
231 targetSessionId: 'target',
232 sourceType: 'template',
233 sourceTemplateId: 'tpl_source',
234 sourcePageIds: ['tpl_source:1']
235 })
236
237 expect(result.insertedPageIds).toHaveLength(1)
238 expect(upsertedPages.map((page) => page.title)).toEqual(['Template Page'])
239
240 const inserted = upsertedPages[0]
241 const copiedHtml = await fs.promises.readFile(
242 path.join(targetProjectDir, `${inserted.fileSlug}.html`),
243 'utf-8'
244 )
245 expect(copiedHtml).toContain('Source Font')
246 expect(copiedHtml).not.toContain('Target Font')
247 const copiedAssets = await fs.promises.readdir(
248 path.join(targetProjectDir, 'assets', 'merged-pages'),
249 { recursive: true }
250 )
251 expect(copiedAssets.some((entry) => String(entry).endsWith('source.woff2'))).toBe(true)
252 })
253
254 it('rejects a template whose canvas size differs from the target session', async () => {
255 mocks.loadTemplateManifest.mockResolvedValue({
256 manifest: {
257 schemaVersion: 1 as const,
258 id: 'tpl_source',
259 name: 'Source Template',
260 description: '',
261 pageCount: 1,
262 tags: [],
263 styleId: null,
264 slideSizeId: 'square-1-1',
265 slideWidth: 1000,
266 slideHeight: 1000,
267 designContract: undefined,
268 pages: [
269 { pageNumber: 1, pageId: 'page-tpl-1', title: 'Template Page', htmlPath: 'page-one.html' }
270 ]
271 },
272 templateDir
273 })
274 const context = createContext()
275
276 await expect(
277 mergeSessionPages(context as never, {
278 targetSessionId: 'target',
279 sourceType: 'template',
280 sourceTemplateId: 'tpl_source',
281 sourcePageIds: ['tpl_source:1']
282 })
283 ).rejects.toMatchObject({ code: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH' })
284
285 expect(upsertedPages).toEqual([])
286 })
287
288 it('rejects template page listing when the canvas size differs from the target session', async () => {
289 mocks.loadTemplateManifest.mockResolvedValue({
290 manifest: {
291 schemaVersion: 1 as const,
292 id: 'tpl_source',
293 name: 'Source Template',
294 description: '',
295 pageCount: 1,
296 tags: [],
297 styleId: null,
298 slideSizeId: 'square-1-1',
299 slideWidth: 1000,
300 slideHeight: 1000,
301 designContract: undefined,
302 pages: [
303 { pageNumber: 1, pageId: 'page-tpl-1', title: 'Template Page', htmlPath: 'page-one.html' }
304 ]
305 },
306 templateDir
307 })
308 const context = createContext()
309
310 await expect(
311 listMergeSourceTemplatePages(context as never, 'target', 'tpl_source')
312 ).rejects.toMatchObject({ code: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH' })
313 })
314 })
315
315 lines TYPESCRIPT