Files
TREK/client/tests/unit/studio/studioClipboardLayouts.test.ts
T
Maurice 00018959c6 fix(studio): a full page takes no new element, the opacity slider commits a move only, a theme draft survives saves
Every add in the Studio (a sticker, a shape, an icon, a text, a frame, a line,
a travel element, a photo from the content panel or a drop), a text style and
a grid of frames appended without looking at the page's cap of 90 elements,
which paste and duplicate already kept. The save then failed with a 400 and
every later save of the book failed the same way. Each of them now leaves a
full page as it is and selects nothing, as a paste does.

The opacity slider committed on every key let go on it, so the Tab that moved
the focus there wrote the value it was last moved to, an empty undo step at
best and another colour's opacity at worst. It commits once after it moved,
on the key or the pointer let go or when the focus leaves.

The Theme panel threw away colours, faces and motifs being tried whenever the
book came back from a save by another editor or the assistant, because the
same theme read anew is a new object. The draft now follows the book's theme
only when the theme itself changed.
2026-10-24 04:19:25 +00:00

188 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, bookBadgeElementSchema, bookLayoutSchema, bookPageSetupSchema,
bookPhotoElementSchema, bookShapeElementSchema, bookTextElementSchema,
applySavedBookLayout as applySavedLayout, bookLayoutFromSpread as layoutFromSpread,
bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName,
} from '@trek/shared'
import { pastedElements } from '../../../src/components/Studio/studioClipboard'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* Copying between pages and a book's own layouts (#2316).
*
* What matters is what lands in the document: fresh ids on every copy, because
* the book is edited by several people at once; nothing pasted off the paper;
* and a layout that keeps the design while the page it is applied to keeps its
* pictures, its words and its own day and coordinates.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 })
const text = (id: string, words: string, size = 10, over: Record<string, unknown> = {}): BookElement =>
bookTextElementSchema.parse({ id, kind: 'text', frame: { x: 20, y: 20, w: 80, h: 20 }, text: words, size, ...over }) as BookElement
const photo = (id: string, photoId: number | null, x = 10): BookElement =>
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x, y: 60, w: 60, h: 60 }, photoId, frameStyle: 'polaroid' }) as BookElement
const shape = (id: string): BookElement =>
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x: 0, y: 150, w: 420, h: 60 } }) as BookElement
const badge = (id: string, variant: string, words: string): BookElement =>
bookBadgeElementSchema.parse({ id, kind: 'badge', variant, frame: { x: 5, y: 5, w: 30, h: 8 }, text: words }) as BookElement
const spread = (id: string, elements: BookElement[], role: BookSpread['role'] = 'inner'): BookSpread =>
({ id, role, background: null, elements, parked: [], entryId: null })
function load(doc: Partial<BookDocument>) {
useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [], ...doc } as BookDocument)
}
describe('pastedElements', () => {
it('FE-STUDIO-CLIP-001: every paste gets fresh ids and arrives unlocked where it was', () => {
const clip = { fromSpreadId: 'a', elements: [{ ...photo('p1', 5), locked: true } as BookElement] }
const first = pastedElements(clip, spread('b', []), page)
const second = pastedElements(clip, spread('b', []), page)
expect(first[0].id).not.toBe('p1')
expect(first[0].id).not.toBe(second[0].id)
expect(first[0]).toMatchObject({ locked: false, frame: { x: 10, y: 60, w: 60, h: 60 } })
})
it('FE-STUDIO-CLIP-002: back on its own page the copy steps aside, onto a single page it is pulled inside', () => {
const onRight = { ...photo('p1', 5, 300) } as BookElement
const same = pastedElements({ fromSpreadId: 'a', elements: [onRight] }, spread('a', []), page)
expect(same[0].frame).toMatchObject({ x: 304, y: 64 })
const cover = pastedElements({ fromSpreadId: 'a', elements: [onRight] }, spread('c', [], 'cover'), page)
expect(cover[0].frame).toMatchObject({ x: 150, w: 60 })
const wide = pastedElements({ fromSpreadId: 'a', elements: [shape('s1')] }, spread('c', [], 'cover'), page)
expect(wide[0].frame).toMatchObject({ x: 0, w: 210 })
})
})
describe('saved layouts', () => {
it('FE-STUDIO-LAYOUT-001: a kept layout is the design without the pictures, and reads through the contract', () => {
const layout = layoutFromSpread(spread('a', [photo('p1', 5), text('t1', 'Harbour', 22)]), page, 'Layout 1')
expect(layout.elements[0]).toMatchObject({ kind: 'photo', photoId: null, frameStyle: 'polaroid' })
expect(layout).toMatchObject({ role: 'inner', pageWidth: 210, pageHeight: 210, name: 'Layout 1' })
expect(bookLayoutSchema.safeParse(layout).success).toBe(true)
expect(layoutRole(spread('c', [], 'cover'))).toBe('single')
})
it('FE-STUDIO-LAYOUT-002: names count on past the ones taken', () => {
const l = (name: string) => ({ ...layoutFromSpread(spread('a', []), page, name) })
expect(nextLayoutName([], 'Layout')).toBe('Layout 1')
expect(nextLayoutName([l('Layout 2')], 'Layout')).toBe('Layout 3')
})
it('FE-STUDIO-LAYOUT-003: applying keeps the page\'s pictures, words and marks, in the layout\'s design', () => {
const drawn = spread('a', [
shape('s1'), photo('p1', 1), photo('p2', 2, 80),
text('t-title', 'Old title', 24), text('t-body', 'Old story', 10, { binding: { source: 'entry.story' } }),
text('t-label', 'CHAPTER', 8), badge('b1', 'day', 'DAY 1'),
])
const layout = layoutFromSpread(drawn, page, 'Layout 1')
const target = spread('b', [
photo('q1', 11), text('u-title', 'Reykjavík', 22), badge('c1', 'day', 'DAY 2'),
])
const out = applySavedLayout(target, layout, page)
const kinds = out.elements.map(e => e.kind)
expect(kinds).toEqual(['shape', 'photo', 'photo', 'text', 'text', 'badge'])
const photos = out.elements.filter(e => e.kind === 'photo') as Extract<BookElement, { kind: 'photo' }>[]
expect(photos.map(p => p.photoId)).toEqual([11, null])
expect(photos[0].frameStyle).toBe('polaroid')
const texts = out.elements.filter(e => e.kind === 'text') as Extract<BookElement, { kind: 'text' }>[]
// The title takes the largest box; the bound story box had nothing to take and is left out; the label stays.
expect(texts.map(t => [t.text, t.size])).toEqual([['Reykjavík', 24], ['CHAPTER', 8]])
const day = out.elements.find(e => e.kind === 'badge') as Extract<BookElement, { kind: 'badge' }>
expect(day.text).toBe('DAY 2')
expect(new Set(out.elements.map(e => e.id)).size).toBe(out.elements.length)
expect(out.elements.some(e => ['s1', 'p1', 't-title', 'b1'].includes(e.id))).toBe(false)
})
it('FE-STUDIO-LAYOUT-004: what the layout has no room for is parked, and a new trim scales the design', () => {
const layout = { ...layoutFromSpread(spread('a', [photo('p1', 1)]), page, 'L'), pageWidth: 105, pageHeight: 105 }
const out = applySavedLayout(spread('b', [photo('q1', 11), photo('q2', 12), text('u', 'Words')]), layout, page)
expect(out.elements[0].frame).toMatchObject({ x: 20, y: 120, w: 120, h: 120 })
expect(out.parked.map(e => (e.kind === 'photo' ? e.photoId : e.kind))).toEqual([12, 'text'])
})
it('FE-STUDIO-LAYOUT-005: the card draws the layout on this book\'s page, as a single page when it is one', () => {
const layout = { ...layoutFromSpread(spread('c', [photo('p1', 1)], 'cover'), page, 'L'), pageWidth: 105 }
const preview = layoutPreview(layout, page)
expect(preview.role).toBe('first')
expect(preview.elements[0].frame.x).toBe(20)
})
})
describe('studio store: clipboard and layouts', () => {
beforeEach(() => useStudioStore.setState({ clipboard: null }))
it('FE-STUDIO-STORE-CLIP-001: copy on one page, paste on another, as one undo step with the copy selected', () => {
load({ spreads: [spread('a', [photo('p1', 5), text('t1', 'Keep')]), spread('b', [])] })
const s = useStudioStore.getState()
s.copy(0, ['p1'])
s.paste(1)
const after = useStudioStore.getState()
const pasted = after.doc!.spreads[1].elements
expect(pasted).toHaveLength(1)
expect(pasted[0]).toMatchObject({ kind: 'photo', photoId: 5 })
expect(after.selection).toEqual([pasted[0].id])
after.undo()
expect(useStudioStore.getState().doc!.spreads[1].elements).toHaveLength(0)
})
it('FE-STUDIO-STORE-CLIP-002: nothing copied, nothing pasted; an unknown id copies nothing', () => {
load({ spreads: [spread('a', [photo('p1', 5)])] })
const s = useStudioStore.getState()
s.paste(0)
s.copy(0, ['missing'])
expect(useStudioStore.getState().clipboard).toBeNull()
expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(1)
})
it('FE-STUDIO-STORE-LAYOUT-001: save, apply to another page, delete', () => {
load({ spreads: [spread('a', [photo('p1', 1), text('t1', 'Title', 24)]), spread('b', [photo('q1', 9)])] })
expect(useStudioStore.getState().saveLayout(0, 'Layout 1')).toBe(true)
const layout = useStudioStore.getState().doc!.layouts![0]
useStudioStore.getState().applyLayout(1, layout.id)
const applied = useStudioStore.getState().doc!.spreads[1].elements
expect(applied.map(e => e.kind)).toEqual(['photo', 'text'])
expect(applied[0]).toMatchObject({ photoId: 9 })
useStudioStore.getState().removeLayout(layout.id)
expect(useStudioStore.getState().doc!.layouts).toEqual([])
})
it('FE-STUDIO-STORE-LAYOUT-003: what is locked stays where it is, at its place in the paint order', () => {
const panel = { ...shape('panel'), locked: true } as BookElement
const pinned = { ...photo('pin', 7, 300), locked: true } as BookElement
load({
spreads: [spread('a', [photo('p1', 1), text('t1', 'Title', 24)]), spread('b', [panel, photo('q1', 9), pinned])],
})
expect(useStudioStore.getState().saveLayout(0, 'Layout 1')).toBe(true)
const layout = useStudioStore.getState().doc?.layouts?.[0]
if (!layout) throw new Error('no layout kept')
useStudioStore.getState().applyLayout(1, layout.id)
const applied = useStudioStore.getState().doc?.spreads[1].elements ?? []
expect(applied[0]).toEqual(panel)
expect(applied.find(e => e.id === 'pin')).toEqual(pinned)
expect(applied.filter(e => e.kind === 'photo' && !e.locked).map(e => e.kind === 'photo' && e.photoId)).toEqual([9])
})
it('FE-STUDIO-STORE-ADD-001: a full page takes no new element, and nothing is selected for it', () => {
const full = Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => ({ ...shape('s'), id: `s${i}` }) as BookElement)
load({ spreads: [spread('a', full), spread('b', [])] })
const before = useStudioStore.getState().doc
expect(useStudioStore.getState().addElement(0, text('extra', 'One more'))).toBe(false)
expect(useStudioStore.getState().doc).toBe(before)
expect(useStudioStore.getState().selection).toEqual([])
expect(useStudioStore.getState().addElement(1, text('first', 'Fits'))).toBe(true)
expect(useStudioStore.getState().selection).toEqual(['first'])
})
it('FE-STUDIO-STORE-LAYOUT-002: an empty page is not a layout, and a full book says no', () => {
const full = Array.from({ length: MAX_BOOK_LAYOUTS }, (_, i) => layoutFromSpread(spread('x', []), page, `L${i}`))
load({ spreads: [spread('a', []), spread('b', [photo('p', 1)])], layouts: full })
expect(useStudioStore.getState().saveLayout(0, 'Empty')).toBe(false)
expect(useStudioStore.getState().saveLayout(1, 'One too many')).toBe(false)
expect(useStudioStore.getState().doc!.layouts).toHaveLength(MAX_BOOK_LAYOUTS)
})
})