返回 oh-my-ppt
edit-session-store.test.ts
根目录 / tests / unit / editor / edit-session-store.test.ts
1 import { beforeEach, describe, expect, it, vi } from 'vitest'
2
3 vi.mock('sonner', () => {
4 const fn = (() => '') as unknown as {
5 success: () => string
6 error: () => string
7 info: () => string
8 warning: () => string
9 loading: () => string
10 promise: () => void
11 dismiss: () => void
12 }
13 fn.success = fn
14 fn.error = fn
15 fn.info = fn
16 fn.warning = fn
17 fn.loading = fn
18 fn.promise = () => {}
19 fn.dismiss = () => {}
20 return { toast: fn }
21 })
22
23 vi.mock('../../../src/renderer/src/lib/ipc', () => ({
24 ipc: {
25 saveEditBatch: vi.fn(async () => ({
26 success: true,
27 dragCount: 0,
28 textCount: 0,
29 propertyCount: 0,
30 deleteCount: 0,
31 addCount: 0
32 }))
33 }
34 }))
35
36 import { ipc } from '../../../src/renderer/src/lib/ipc'
37 import { useEditSessionStore } from '../../../src/renderer/src/store/editSessionStore'
38 import { useEditHistoryStore } from '../../../src/renderer/src/store/editHistoryStore'
39 import type { EditSelectionPayload } from '@arcsin1/presentation-editor-runtime'
40 import type { PreviewIframeHandle } from '../../../src/renderer/src/components/preview/PreviewIframe'
41
42 const PAGE_ID = 'page-1'
43 const HTML_PATH = '/tmp/page.html'
44 const SESSION_ID = 'session-1'
45 const SELECTOR = 'body[data-page-id="page-1"] [data-block-id="b1"]'
46
47 type Layout = Awaited<ReturnType<PreviewIframeHandle['readElementLayout']>>
48
49 function primeStore(
50 readLayout: () => Promise<NonNullable<Layout>>,
51 overrides: Partial<PreviewIframeHandle> = {}
52 ): void {
53 useEditSessionStore.getState().setIframeHandle({
54 readElementLayout: readLayout,
55 readElementHtml: async () => '',
56 restoreEditModeSelection: async () => true,
57 clearEditModeSelection: () => {},
58 hideElement: () => {},
59 injectElement: () => {},
60 applyDragStyle: () => {},
61 applyLayoutIsland: () => {},
62 applyZIndex: () => {},
63 applyChildUpdates: () => {},
64 liveUpdateElement: () => {},
65 applyElementProperties: () => {},
66 ...overrides
67 } as unknown as PreviewIframeHandle)
68 useEditSessionStore.getState().attach({
69 t: ((key: string) => key) as never,
70 requestRefresh: () => {},
71 bumpThumbnail: () => {},
72 getPageContext: () => ({ pageId: PAGE_ID, htmlPath: HTML_PATH, sessionId: SESSION_ID })
73 })
74 }
75
76 // Minimal selection whose only field flush reads is snapshot.metrics.page (the
77 // page-relative position recorded at selection time, used as the movement baseline).
78 const makeSelection = (baseline: { x: number; y: number }): EditSelectionPayload =>
79 ({
80 selector: SELECTOR,
81 label: SELECTOR,
82 elementTag: '',
83 elementText: '',
84 isText: false,
85 style: {},
86 translateX: 0,
87 translateY: 0,
88 snapshot: {
89 selector: SELECTOR,
90 metrics: {
91 page: { x: baseline.x, y: baseline.y, width: 100, height: 100 },
92 viewport: { x: baseline.x, y: baseline.y, width: 100, height: 100 },
93 translateX: 0,
94 translateY: 0
95 }
96 }
97 } as unknown as EditSelectionPayload)
98
99 describe('editSessionStore flush barrier (drag-twice-then-save)', () => {
100 beforeEach(() => {
101 useEditHistoryStore.getState().clear()
102 useEditSessionStore.getState().reset()
103 })
104
105 it('flushPendingDrags overwrites a stale dragEdit with the live second position', async () => {
106 primeStore(async () => ({ isAbsoluteMode: false, x: 250, y: 320, width: 200, height: 80 }))
107 const editHistory = useEditHistoryStore.getState()
108 editHistory.upsertDragEdit({
109 pageId: PAGE_ID,
110 htmlPath: HTML_PATH,
111 selector: SELECTOR,
112 x: 100,
113 y: 100,
114 width: null,
115 height: null,
116 childUpdates: [],
117 isAbsoluteMode: false
118 })
119
120 await useEditSessionStore.getState().flushPendingDrags()
121
122 const snapshot = editHistory.getSnapshotForPage(PAGE_ID)
123 expect(snapshot.dragEdits).toHaveLength(1)
124 expect(snapshot.dragEdits[0].x).toBe(250)
125 expect(snapshot.dragEdits[0].y).toBe(320)
126 })
127
128 it('flushPendingDrags preserves existing childUpdates', async () => {
129 primeStore(async () => ({ isAbsoluteMode: false, x: 10, y: 10, width: 100, height: 100 }))
130 const editHistory = useEditHistoryStore.getState()
131 editHistory.upsertDragEdit({
132 pageId: PAGE_ID,
133 htmlPath: HTML_PATH,
134 selector: SELECTOR,
135 x: 0,
136 y: 0,
137 width: 100,
138 height: 100,
139 childUpdates: [{ path: [0, 1], width: 50, height: 30 }],
140 isAbsoluteMode: false
141 })
142
143 await useEditSessionStore.getState().flushPendingDrags()
144
145 const snapshot = editHistory.getSnapshotForPage(PAGE_ID)
146 expect(snapshot.dragEdits[0].childUpdates).toEqual([{ path: [0, 1], width: 50, height: 30 }])
147 })
148
149 it('flushPendingDrags does not inject a size into a pure drag edit', async () => {
150 primeStore(async () => ({ isAbsoluteMode: false, x: 5, y: 5, width: 999, height: 999 }))
151 const editHistory = useEditHistoryStore.getState()
152 editHistory.upsertDragEdit({
153 pageId: PAGE_ID,
154 htmlPath: HTML_PATH,
155 selector: SELECTOR,
156 x: 0,
157 y: 0,
158 width: null,
159 height: null,
160 childUpdates: [],
161 isAbsoluteMode: false
162 })
163
164 await useEditSessionStore.getState().flushPendingDrags()
165
166 const drag = editHistory.getSnapshotForPage(PAGE_ID).dragEdits[0]
167 expect(drag.x).toBe(5)
168 expect(drag.y).toBe(5)
169 expect(drag.width).toBeNull()
170 expect(drag.height).toBeNull()
171 })
172
173 it('flushPendingDrags refreshes size for a resize edit', async () => {
174 primeStore(async () => ({ isAbsoluteMode: false, x: 5, y: 5, width: 333, height: 222 }))
175 const editHistory = useEditHistoryStore.getState()
176 editHistory.upsertDragEdit({
177 pageId: PAGE_ID,
178 htmlPath: HTML_PATH,
179 selector: SELECTOR,
180 x: 0,
181 y: 0,
182 width: 100,
183 height: 80,
184 childUpdates: [],
185 isAbsoluteMode: false
186 })
187
188 await useEditSessionStore.getState().flushPendingDrags()
189
190 const drag = editHistory.getSnapshotForPage(PAGE_ID).dragEdits[0]
191 expect(drag.width).toBe(333)
192 expect(drag.height).toBe(222)
193 })
194
195 it('keeps flow resize geometry through the save flush', async () => {
196 primeStore(async () => ({
197 isAbsoluteMode: false,
198 x: 25,
199 y: -10,
200 width: 240,
201 height: 75
202 }))
203 useEditSessionStore.getState().handleMoved({
204 selector: SELECTOR,
205 x: 25,
206 y: -10,
207 deltaX: 25,
208 deltaY: -10,
209 layoutMode: 'translate',
210 width: 240,
211 height: 75
212 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
213
214 await useEditSessionStore.getState().flushPendingDrags()
215
216 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits[0]).toMatchObject({
217 x: 25,
218 y: -10,
219 width: 240,
220 height: 75
221 })
222 })
223
224 it('keeps a frozen layout island through the save flush', async () => {
225 const layoutIsland = {
226 selector: 'body[data-page-id="page-1"] #layout-island',
227 width: 640,
228 height: 360,
229 children: [
230 { index: 0, x: 20, y: 30, width: 240, height: 120 },
231 { index: 1, x: 300, y: 30, width: 300, height: 180 }
232 ]
233 }
234 primeStore(async () => ({
235 isAbsoluteMode: true,
236 x: 20,
237 y: 30,
238 width: 260,
239 height: 120,
240 layoutIsland
241 }))
242 useEditSessionStore.getState().handleMoved({
243 selector: SELECTOR,
244 x: 20,
245 y: 30,
246 deltaX: 0,
247 deltaY: 0,
248 layoutMode: 'absolute',
249 width: 260,
250 height: 120,
251 layoutIsland
252 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
253
254 await useEditSessionStore.getState().flushPendingDrags()
255
256 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits[0]).toMatchObject({
257 layoutIsland
258 })
259 })
260
261 it('flushPendingDrags keeps existing size when read-back returns zero', async () => {
262 primeStore(async () => ({ isAbsoluteMode: false, x: 5, y: 5, width: 0, height: 0 }))
263 const editHistory = useEditHistoryStore.getState()
264 editHistory.upsertDragEdit({
265 pageId: PAGE_ID,
266 htmlPath: HTML_PATH,
267 selector: SELECTOR,
268 x: 0,
269 y: 0,
270 width: 120,
271 height: 90,
272 childUpdates: [],
273 isAbsoluteMode: false
274 })
275
276 await useEditSessionStore.getState().flushPendingDrags()
277
278 const drag = editHistory.getSnapshotForPage(PAGE_ID).dragEdits[0]
279 expect(drag.width).toBe(120)
280 expect(drag.height).toBe(90)
281 })
282 })
283
284 describe('editSessionStore drag-then-resize-then-save (same element)', () => {
285 beforeEach(() => {
286 useEditHistoryStore.getState().clear()
287 useEditSessionStore.getState().reset()
288 vi.mocked(ipc.saveEditBatch).mockClear()
289 })
290
291 it('flushPendingDrags keeps the post-resize position for one element dragged then resized', async () => {
292 // After drag+resize the live DOM reports the post-resize layout.
293 primeStore(async () => ({ isAbsoluteMode: false, x: 120, y: 100, width: 200, height: 150 }))
294 const editHistory = useEditHistoryStore.getState()
295 // 1) handleMoved for the drag: x/y set, size null
296 useEditSessionStore.getState().handleMoved({
297 selector: SELECTOR,
298 x: 100,
299 y: 100,
300 deltaX: 100,
301 deltaY: 100,
302 layoutMode: 'translate'
303 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
304 // 2) handleMoved for the resize on the same selector: x shifts (W handle), width/height set
305 useEditSessionStore.getState().handleMoved({
306 selector: SELECTOR,
307 x: 120,
308 y: 100,
309 deltaX: 20,
310 deltaY: 0,
311 layoutMode: 'translate',
312 width: 200,
313 height: 150,
314 childUpdates: []
315 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
316
317 await useEditSessionStore.getState().flushPendingDrags()
318
319 const dragEdits = editHistory.getSnapshotForPage(PAGE_ID).dragEdits
320 expect(dragEdits).toHaveLength(1)
321 expect(dragEdits[0]).toMatchObject({
322 selector: SELECTOR,
323 x: 120,
324 y: 100,
325 width: 200,
326 height: 150
327 })
328 })
329
330 it('save persists the post-resize position to disk for one element dragged then resized', async () => {
331 primeStore(async () => ({ isAbsoluteMode: false, x: 120, y: 100, width: 200, height: 150 }))
332 useEditSessionStore.getState().handleMoved({
333 selector: SELECTOR,
334 x: 100,
335 y: 100,
336 deltaX: 100,
337 deltaY: 100,
338 layoutMode: 'translate'
339 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
340 useEditSessionStore.getState().handleMoved({
341 selector: SELECTOR,
342 x: 120,
343 y: 100,
344 deltaX: 20,
345 deltaY: 0,
346 layoutMode: 'translate',
347 width: 200,
348 height: 150,
349 childUpdates: []
350 } as unknown as Parameters<typeof useEditSessionStore.getState>['handleMoved'][0])
351
352 const result = await useEditSessionStore.getState().save()
353
354 expect(result.saved).toBe(true)
355 expect(vi.mocked(ipc.saveEditBatch)).toHaveBeenCalledWith(
356 expect.objectContaining({
357 dragEdits: expect.arrayContaining([
358 expect.objectContaining({ selector: SELECTOR, x: 120, y: 100, width: 200, height: 150 })
359 ])
360 })
361 )
362 })
363 })
364
365 describe('editSessionStore save re-entry guard', () => {
366 beforeEach(() => {
367 useEditHistoryStore.getState().clear()
368 useEditSessionStore.getState().reset()
369 })
370
371 it('locks isSavingEdits before the flush await and blocks a concurrent save', async () => {
372 primeStore(async () => ({ isAbsoluteMode: false, x: 1, y: 1, width: 1, height: 1 }))
373
374 const first = useEditSessionStore.getState().save()
375 expect(useEditSessionStore.getState().isSavingEdits).toBe(true)
376
377 const second = await useEditSessionStore.getState().save()
378 expect(second.saved).toBe(false)
379
380 await first
381 expect(useEditSessionStore.getState().isSavingEdits).toBe(false)
382 })
383 })
384
385 describe('editSessionStore flush captures an in-flight first drag (P1: drag-once-then-save)', () => {
386 beforeEach(() => {
387 useEditHistoryStore.getState().clear()
388 useEditSessionStore.getState().reset()
389 vi.mocked(ipc.saveEditBatch).mockClear()
390 })
391
392 it('captures the selected element when it has moved from its selection baseline', async () => {
393 // DOM reports the element moved to visualX=250; selection baseline was x=0.
394 primeStore(async () => ({
395 isAbsoluteMode: false,
396 x: 250,
397 y: 0,
398 width: 100,
399 height: 100,
400 visualX: 250,
401 visualY: 0
402 }))
403 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
404 const editHistory = useEditHistoryStore.getState()
405
406 await useEditSessionStore.getState().flushPendingDrags()
407
408 const drags = editHistory.getSnapshotForPage(PAGE_ID).dragEdits
409 expect(drags).toHaveLength(1)
410 expect(drags[0].selector).toBe(SELECTOR)
411 expect(drags[0].x).toBe(250)
412 // A pure drag must not inject a size.
413 expect(drags[0].width).toBeNull()
414 expect(drags[0].height).toBeNull()
415 })
416
417 it('captures the selected element when its first in-flight edit is a resize', async () => {
418 // DOM reports the element stayed at the same visual position but resized.
419 primeStore(async () => ({
420 isAbsoluteMode: false,
421 x: 0,
422 y: 0,
423 width: 150,
424 height: 120,
425 visualX: 0,
426 visualY: 0
427 }))
428 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
429 const editHistory = useEditHistoryStore.getState()
430
431 await useEditSessionStore.getState().flushPendingDrags()
432
433 const drags = editHistory.getSnapshotForPage(PAGE_ID).dragEdits
434 expect(drags).toHaveLength(1)
435 expect(drags[0]).toMatchObject({
436 selector: SELECTOR,
437 x: 0,
438 y: 0,
439 width: 150,
440 height: 120
441 })
442 })
443
444 it('captures actual geometry for an in-flight flow resize', async () => {
445 primeStore(async () => ({
446 isAbsoluteMode: false,
447 x: 30,
448 y: -10,
449 width: 150,
450 height: 75,
451 visualX: 30,
452 visualY: -10
453 }))
454 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
455
456 await useEditSessionStore.getState().flushPendingDrags()
457
458 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits[0]).toMatchObject({
459 x: 30,
460 y: -10,
461 width: 150,
462 height: 75
463 })
464 })
465
466 it('does not capture when the selection has not moved', async () => {
467 primeStore(async () => ({
468 isAbsoluteMode: false,
469 x: 0,
470 y: 0,
471 width: 100,
472 height: 100,
473 visualX: 0,
474 visualY: 0
475 }))
476 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
477
478 await useEditSessionStore.getState().flushPendingDrags()
479
480 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits).toHaveLength(0)
481 })
482
483 it('does not double-capture a selector already covered by a pending dragEdit', async () => {
484 primeStore(async () => ({
485 isAbsoluteMode: false,
486 x: 250,
487 y: 0,
488 width: 100,
489 height: 100,
490 visualX: 250,
491 visualY: 0
492 }))
493 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
494 const editHistory = useEditHistoryStore.getState()
495 editHistory.upsertDragEdit({
496 pageId: PAGE_ID,
497 htmlPath: HTML_PATH,
498 selector: SELECTOR,
499 x: 100,
500 y: 0,
501 width: null,
502 height: null,
503 childUpdates: [],
504 isAbsoluteMode: false
505 })
506
507 await useEditSessionStore.getState().flushPendingDrags()
508
509 const drags = editHistory.getSnapshotForPage(PAGE_ID).dragEdits
510 expect(drags).toHaveLength(1)
511 // The existing dragEdit was flushed (overwritten with live DOM x=250), not duplicated.
512 expect(drags[0].x).toBe(250)
513 })
514
515 it('skips a selection whose selector is pending deletion', async () => {
516 primeStore(async () => ({
517 isAbsoluteMode: false,
518 x: 250,
519 y: 0,
520 width: 100,
521 height: 100,
522 visualX: 250,
523 visualY: 0
524 }))
525 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
526 const editHistory = useEditHistoryStore.getState()
527 editHistory.addDelete({ pageId: PAGE_ID, htmlPath: HTML_PATH, selector: SELECTOR })
528
529 await useEditSessionStore.getState().flushPendingDrags()
530
531 expect(editHistory.getSnapshotForPage(PAGE_ID).dragEdits).toHaveLength(0)
532 })
533
534 it('restores the selected element after replaying pending edits', () => {
535 const restoreEditModeSelection = vi.fn(async () => true)
536 primeStore(
537 async () => ({ isAbsoluteMode: false, x: 0, y: 0, width: 100, height: 100 }),
538 { restoreEditModeSelection }
539 )
540 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
541
542 useEditSessionStore.getState().replayPending()
543
544 expect(restoreEditModeSelection).toHaveBeenCalledWith(SELECTOR)
545 })
546
547 it('save persists the in-flight first drag to disk instead of dropping it', async () => {
548 primeStore(async () => ({
549 isAbsoluteMode: false,
550 x: 250,
551 y: 0,
552 width: 100,
553 height: 100,
554 visualX: 250,
555 visualY: 0
556 }))
557 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
558 // No pending dragEdit: the first `moved` is still in flight across the save.
559
560 const result = await useEditSessionStore.getState().save()
561
562 expect(result.saved).toBe(true)
563 // The in-flight drag was captured and written...
564 expect(vi.mocked(ipc.saveEditBatch)).toHaveBeenCalledWith(
565 expect.objectContaining({
566 dragEdits: expect.arrayContaining([
567 expect.objectContaining({ selector: SELECTOR, x: 250 })
568 ])
569 })
570 )
571 // ...then markPageSaved cleared the page.
572 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits).toHaveLength(0)
573 })
574
575 it('save persists an in-flight first resize to disk instead of dropping it', async () => {
576 primeStore(async () => ({
577 isAbsoluteMode: false,
578 x: 0,
579 y: 0,
580 width: 150,
581 height: 120,
582 visualX: 0,
583 visualY: 0
584 }))
585 useEditSessionStore.setState({ selection: makeSelection({ x: 0, y: 0 }) })
586 // No pending dragEdit: the first resize `moved` is still in flight across the save.
587
588 const result = await useEditSessionStore.getState().save()
589
590 expect(result.saved).toBe(true)
591 expect(vi.mocked(ipc.saveEditBatch)).toHaveBeenCalledWith(
592 expect.objectContaining({
593 dragEdits: expect.arrayContaining([
594 expect.objectContaining({ selector: SELECTOR, width: 150, height: 120 })
595 ])
596 })
597 )
598 expect(useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID).dragEdits).toHaveLength(0)
599 })
600 })
601
602 describe('editSessionStore formula edits', () => {
603 beforeEach(() => {
604 useEditHistoryStore.getState().clear()
605 useEditSessionStore.getState().reset()
606 })
607
608 it('commits formula changes as property edits', () => {
609 const liveUpdateElement = vi.fn()
610 primeStore(
611 async () => ({ isAbsoluteMode: false, x: 0, y: 0, width: 100, height: 100 }),
612 { liveUpdateElement }
613 )
614 useEditSessionStore.getState().selectElement({
615 selector: SELECTOR,
616 label: 'Formula',
617 elementTag: 'div',
618 elementText: '',
619 kind: 'formula',
620 capabilities: ['layout', 'layer', 'appearance', 'formula'],
621 isText: false,
622 text: '',
623 style: {},
624 translateX: 0,
625 translateY: 0,
626 snapshot: {
627 selector: SELECTOR,
628 label: 'Formula',
629 elementTag: 'div',
630 elementText: '',
631 kind: 'formula',
632 capabilities: ['layout', 'layer', 'appearance', 'formula'],
633 metrics: {
634 page: { x: 0, y: 0, width: 100, height: 100 },
635 viewport: { x: 0, y: 0, width: 100, height: 100 },
636 translateX: 0,
637 translateY: 0
638 },
639 computed: {},
640 inline: {},
641 attrs: {},
642 formula: {
643 latex: 'x^2',
644 html: '<span class="katex">old</span>',
645 displayMode: false
646 }
647 }
648 } as unknown as EditSelectionPayload)
649
650 useEditSessionStore.getState().updateDraft(
651 {
652 ...useEditSessionStore.getState().draft,
653 formulaLatex: 'x^3',
654 formulaHtml: '<span class="katex">new</span>',
655 formulaDisplayMode: false
656 },
657 { commit: true, fields: ['formulaLatex', 'formulaHtml', 'formulaDisplayMode'] }
658 )
659
660 const snapshot = useEditHistoryStore.getState().getSnapshotForPage(PAGE_ID)
661 expect(liveUpdateElement).toHaveBeenCalledWith(
662 SELECTOR,
663 expect.objectContaining({
664 formula: expect.objectContaining({ latex: 'x^3', html: '<span class="katex">new</span>' })
665 })
666 )
667 expect(snapshot.propertyEdits).toHaveLength(1)
668 expect(snapshot.propertyEdits[0].patch.formula).toMatchObject({
669 latex: 'x^3',
670 html: '<span class="katex">new</span>',
671 displayMode: false,
672 originalLatex: 'x^2'
673 })
674 })
675 })
676
676 lines TYPESCRIPT