| 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 |