Files
TREK/client/tests/unit/studio/lockedElement.test.tsx
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00

162 lines
5.4 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { fireEvent, render } from '../../helpers/render'
import { StudioCanvas } from '../../../src/components/Studio/StudioCanvas'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* A locked element (#1973).
*
* Locking used to switch the element's hit target off entirely, which made it a
* dead end: it could not be selected, so the inspector never showed it, so the
* unlock button was unreachable — the only way back was undo. Locked has to
* mean "cannot be moved", never "cannot be reached".
*/
const shape = (over: Partial<BookElement> = {}): BookElement => ({
id: 's1', kind: 'shape', frame: { x: 10, y: 10, w: 40, h: 30 },
rotation: 0, opacity: 1, locked: false,
shape: 'rect', fill: '#000000', gradient: 'none',
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
...over,
} as BookElement)
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 })
function load(el: BookElement) {
useStudioStore.getState().load({
version: 1, title: 'T', page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }],
} as BookDocument)
}
function draw() {
const spread = useStudioStore.getState().doc!.spreads[0]
return render(
<StudioCanvas
spread={spread}
spreadIndex={0}
page={page}
zoom={1}
pxPerMm={96 / 25.4}
bookView={false}
dropLabel=""
/>,
)
}
/** The hit target sits above the sheet, one per element. */
function hitTarget(container: HTMLElement): HTMLElement {
const sheet = container.querySelector('.st-sheet')!
return sheet.children[sheet.children.length - 1] as HTMLElement
}
beforeEach(() => {
useStudioStore.getState().load({
version: 1, title: 'T', page, spreads: [],
} as unknown as BookDocument)
})
describe('a locked element', () => {
it('can still be selected', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
expect(useStudioStore.getState().selection).toEqual(['s1'])
})
/*
* The whole reason the previous behaviour was a trap: selection is what puts
* the element in the inspector, and the inspector is where the unlock is.
*/
it('can therefore be unlocked again', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
const store = useStudioStore.getState()
store.commit(d => ({
...d,
spreads: d.spreads.map(sp => ({
...sp,
elements: sp.elements.map(e => (store.selection.includes(e.id) ? { ...e, locked: false } : e)),
})),
}))
expect(useStudioStore.getState().doc!.spreads[0].elements[0].locked).toBe(false)
})
it('does not move when dragged', () => {
load(shape({ locked: true }))
const { container } = draw()
const target = hitTarget(container)
fireEvent.pointerDown(target, { clientX: 100, clientY: 100 })
fireEvent.pointerMove(container.querySelector('.st-stage')!, { clientX: 300, clientY: 260 })
expect(useStudioStore.getState().doc!.spreads[0].elements[0].frame).toMatchObject({ x: 10, y: 10 })
})
it('offers no resize handles, since a drag on one would be refused', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
expect(container.querySelectorAll('.st-handle')).toHaveLength(0)
// But it is selected, and the outline says so.
expect(container.querySelectorAll('.st-select')).toHaveLength(1)
})
/*
* Delete was the one gesture that ignored the flag: the element was still
* selectable, so a stray Delete took away exactly what had been pinned down.
*/
it('is not deleted by the Delete key', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
fireEvent.keyDown(document, { key: 'Delete' })
expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(1)
})
it('is not deleted by the quick bar either', () => {
load(shape({ locked: true }))
const { container, getByTitle } = draw()
fireEvent.pointerDown(hitTarget(container))
fireEvent.pointerUp(container.querySelector('.st-stage')!)
fireEvent.click(getByTitle('Delete'))
expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(1)
})
it('leaves an unlocked element deletable', () => {
load(shape({ locked: false }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
fireEvent.keyDown(document, { key: 'Delete' })
expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(0)
})
it('leaves an unlocked element movable, with its handles', () => {
load(shape({ locked: false }))
const { container } = draw()
const stage = container.querySelector('.st-stage')!
fireEvent.pointerDown(hitTarget(container))
// The handles hide while a gesture is open — chrome that follows your hand
// around is noise — so the press has to be released before they appear.
fireEvent.pointerUp(stage)
expect(useStudioStore.getState().selection).toEqual(['s1'])
expect(container.querySelectorAll('.st-handle').length).toBeGreaterThan(0)
})
})