返回 oh-my-ppt
in-memory-edits.test.ts
根目录 / tests / unit / html-editor / in-memory-edits.test.ts
1 import * as cheerio from 'cheerio'
2 import { describe, expect, it, vi } from 'vitest'
3
4 vi.mock('../../../src/main/html-editor/html-editor-thumbnail', () => ({
5 refreshHtmlEditorCoverThumbnail: vi.fn(),
6 warmHtmlEditorCoverThumbnails: vi.fn(async () => new Map())
7 }))
8
9 import { applyEditsToHtml } from '../../../src/main/html-editor/html-editor-handlers'
10 import { resolveHtmlEditorDocumentPath } from '../../../src/main/html-editor/html-editor-handlers'
11 import { ensureElementAnchorInHtml } from '../../../src/main/element-editor/shared'
12
13 const PAGE_ID = 'd1'
14
15 const sampleHtml = (inner = '<div id="el1" class="box">Hello</div>'): string =>
16 `<html><body data-page-id="${PAGE_ID}"><main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-width="1280"><div class="ppt-page-fit-scope">${inner}</div></main></body></html>`
17
18 const load = (html: string) => cheerio.load(html, { scriptingEnabled: false })
19
20 describe('applyEditsToHtml', () => {
21 it('applies a text edit via patchElementProperties (anchored element)', () => {
22 const anchor = ensureElementAnchorInHtml(sampleHtml(), {
23 pageId: PAGE_ID,
24 selector: '#el1'
25 })
26 const { html } = applyEditsToHtml(anchor.html, PAGE_ID, {
27 textEdits: [{ selector: '#el1', patch: { text: 'World', style: { color: '#ff0000' } } }]
28 })
29 expect(load(html)('#el1').text()).toBe('World')
30 })
31
32 it('replaces an element class through a property edit', () => {
33 const { html } = applyEditsToHtml(
34 sampleHtml('<div id="el1" class="text-gray-800 font-bold">Hello</div>'),
35 PAGE_ID,
36 {
37 propertyEdits: [
38 {
39 selector: '#el1',
40 patch: { attrs: { className: 'text-red-500 font-bold' } }
41 }
42 ]
43 }
44 )
45 expect(load(html)('#el1').attr('class')).toBe('text-red-500 font-bold')
46 })
47
48 it('adds an element into the fit-scope parent', () => {
49 const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
50 addElements: [
51 {
52 parentSelector: '.ppt-page-fit-scope',
53 htmlFragment: '<div id="el2">New</div>',
54 insertIndex: -1
55 }
56 ]
57 })
58 expect(load(html)('#el2').text()).toBe('New')
59 expect(load(html)('#el2').attr('style')).toMatch(/position:\s*relative/)
60 expect(load(html)('#el2').attr('style')).toMatch(/z-index:\s*20/)
61 expect(load(html)('#el1').text()).toBe('Hello') // 原元素仍在
62 })
63
64 it('preserves an explicit z-index on an added element', () => {
65 const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
66 addElements: [
67 {
68 parentSelector: '.ppt-page-fit-scope',
69 htmlFragment: '<div id="el2" style="z-index: 99">New</div>',
70 insertIndex: -1
71 }
72 ]
73 })
74 expect(load(html)('#el2').attr('style')).toMatch(/z-index:\s*99/)
75 expect(load(html)('#el2').attr('style')).not.toMatch(/z-index:\s*20/)
76 })
77
78 it('deletes an element by selector', () => {
79 const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
80 deletes: [{ selector: '#el1' }]
81 })
82 expect(load(html)('#el1').length).toBe(0)
83 })
84
85 it('applies a drag edit without removing the element (sets inline style)', () => {
86 const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
87 dragEdits: [
88 {
89 selector: '#el1',
90 x: 100,
91 y: 50,
92 width: 200,
93 height: 80,
94 childUpdates: [],
95 isAbsoluteMode: true
96 }
97 ]
98 })
99 const el = load(html)('#el1')
100 expect(el.length).toBe(1)
101 expect(el.attr('style')).toMatch(/position:\s*absolute/)
102 expect(el.attr('style')).toMatch(/left:\s*100px/)
103 expect(el.attr('style')).toMatch(/top:\s*50px/)
104 expect(el.attr('style')).toMatch(/width:\s*200px/)
105 expect(el.attr('style')).toMatch(/height:\s*80px/)
106 })
107
108 it('persists a flow resize as translate and actual dimensions', () => {
109 const { html } = applyEditsToHtml(
110 sampleHtml('<div id="el1" style="width: 240px; height: 100px">Hello</div>'),
111 PAGE_ID,
112 {
113 dragEdits: [
114 {
115 selector: '#el1',
116 x: 20,
117 y: -8,
118 width: 360,
119 height: 75,
120 childUpdates: [],
121 isAbsoluteMode: false
122 }
123 ]
124 }
125 )
126
127 const el = load(html)('#el1')
128 expect(el.attr('style')).toMatch(/width:\s*360px/)
129 expect(el.attr('style')).toMatch(/height:\s*75px/)
130 expect(el.attr('style')).toMatch(/translate:\s*var\(--ppt-drag-x, 0px\) var\(--ppt-drag-y, 0px\)/)
131 })
132
133 it('freezes a flex layout island before persisting the resized element geometry', () => {
134 const { html } = applyEditsToHtml(
135 sampleHtml(`
136 <section id="island" style="display: flex">
137 <article id="item-a"><p style="font-size: 24px">Text</p></article>
138 <article id="item-b"><img alt="Visual"></article>
139 </section>
140 `),
141 PAGE_ID,
142 {
143 dragEdits: [
144 {
145 selector: '#item-a',
146 x: 20,
147 y: 30,
148 width: 260,
149 height: 120,
150 childUpdates: [],
151 isAbsoluteMode: true,
152 layoutIsland: {
153 selector: '#island',
154 width: 640,
155 height: 360,
156 children: [
157 { index: 0, x: 20, y: 30, width: 220, height: 120 },
158 { index: 1, x: 300, y: 30, width: 300, height: 180 }
159 ]
160 }
161 }
162 ]
163 }
164 )
165
166 const $ = load(html)
167 expect($('#island').attr('data-ppt-layout-frozen')).toBe('1')
168 expect($('#island').attr('style')).toMatch(/display:\s*block/)
169 expect($('#item-a').attr('style')).toMatch(/position:\s*absolute/)
170 expect($('#item-a').attr('style')).toMatch(/width:\s*260px/)
171 expect($('#item-b').attr('style')).toMatch(/left:\s*300px/)
172 expect($('#item-a p').attr('style')).toContain('font-size: 24px')
173 expect($('#item-a').attr('style')).not.toContain('scale:')
174 })
175
176 it('resolves a property edit by blockId after anchoring', () => {
177 const anchor = ensureElementAnchorInHtml(sampleHtml(), {
178 pageId: PAGE_ID,
179 selector: '#el1'
180 })
181 expect(anchor.changed).toBe(true)
182 expect(anchor.blockId).toBeTruthy()
183 const { html, warnings } = applyEditsToHtml(anchor.html, PAGE_ID, {
184 propertyEdits: [
185 {
186 selector: '#el1',
187 blockId: anchor.blockId,
188 patch: { style: { opacity: 0.5 } }
189 }
190 ]
191 })
192 expect(warnings).toEqual([])
193 expect(load(html)('#el1').attr('style')).toContain('0.5')
194 })
195
196 it('records a warning when a property edit target does not exist', () => {
197 const { warnings } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
198 propertyEdits: [{ selector: '#nope', patch: { style: { opacity: 0.5 } } }]
199 })
200 expect(warnings.length).toBe(1)
201 expect(warnings[0]).toContain('#nope')
202 })
203
204 it('applies deletes before adds (no conflict)', () => {
205 const { html } = applyEditsToHtml(sampleHtml(), PAGE_ID, {
206 deletes: [{ selector: '#el1' }],
207 addElements: [
208 {
209 parentSelector: '.ppt-page-fit-scope',
210 htmlFragment: '<div id="el2">New</div>',
211 insertIndex: -1
212 }
213 ]
214 })
215 const $ = load(html)
216 expect($('#el1').length).toBe(0)
217 expect($('#el2').length).toBe(1)
218 })
219
220 it('is pure: does not mutate the input string and needs no filesystem', () => {
221 const original = sampleHtml()
222 applyEditsToHtml(original, PAGE_ID, {
223 deletes: [{ selector: '#el1' }]
224 })
225 expect(load(original)('#el1').length).toBe(1) // 输入未变
226 })
227 })
228
229 describe('resolveHtmlEditorDocumentPath', () => {
230 it('accepts only the registered current.html path for a document', () => {
231 expect(
232 resolveHtmlEditorDocumentPath({
233 storagePath: '/tmp/storage',
234 docId: 'hedit-1',
235 storedHtmlPath: '/tmp/storage/html-editor/hedit-1/current.html'
236 })
237 ).toBe('/tmp/storage/html-editor/hedit-1/current.html')
238 })
239
240 it('rejects a path outside the document directory', () => {
241 expect(() =>
242 resolveHtmlEditorDocumentPath({
243 storagePath: '/tmp/storage',
244 docId: 'hedit-1',
245 storedHtmlPath: '/tmp/storage/other.html'
246 })
247 ).toThrow('HTML 编辑文档路径无效')
248 })
249 })
250
250 lines TYPESCRIPT