import { afterEach, describe, expect, it } from 'vitest' import { act } from 'react' import { fireEvent } from '@testing-library/react' import { byId, click, setup } from './harness' import type { Harness } from './harness' let h: Harness afterEach(() => h?.cleanup()) const select = async (id: string) => { await act(async () => void click(byId(id))) await h.settle() } const data = (id: string) => h.cms.graph.node(id).data const editOps = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').map((l) => (l.request as any).ops) const q = (sel: string) => h.shadow.querySelector(sel)! const chip = (field: string, label: string) => [...h.shadow.querySelectorAll(`[data-style-field="${field}"] .cms-chip`)].find((c) => c.textContent!.includes(label))! describe('Style panel (schema-driven)', () => { it('loads schemas in the background and matches editors on the schema, not the node type', async () => { h = await setup() await h.settle() expect(h.editor.ctrl.schemaOf('layout')).toBeDefined() expect(h.editor.ctrl.wrapperType()).toBe('layout') await select('l1') expect(h.shadow.querySelector('[aria-label="Style"]')).not.toBeNull() await select('t1') // a text block has no style fields expect(h.shadow.querySelector('[aria-label="Style"]')).toBeNull() }) it('chips write one token through set; reset clears it; the page re-renders with the new class', async () => { h = await setup() await h.settle() await select('l1') await h.press('Style') expect(q('[data-style-field="gap"] .cms-chip.is-on').textContent).toBe('md') await act(async () => void chip('gap', 'lg').click()) await h.settle() expect(data('l1').gap).toBe('lg') expect(byId('l1').className).toContain('k6n-gap-lg') expect(editOps().at(-1)[0]).toMatchObject({ op: 'set', id: 'l1', data: { gap: 'lg' } }) await act(async () => void q('[aria-label="Reset Gap"]').click()) await h.settle() expect('gap' in data('l1')).toBe(false) }) it('flow chips hide legacy aliases; columns shows only for grid-like flows', async () => { h = await setup() await h.settle() await select('l1') await h.press('Style') const labels = [...h.shadow.querySelectorAll('[data-style-field="flow"] .cms-chip')].map((c) => c.textContent) expect(labels).toEqual(['Row', 'Stack', 'Actions']) expect(h.shadow.querySelector('[data-style-field="columns"]')).not.toBeNull() await act(async () => void chip('flow', 'Stack').click()) await h.settle() expect(h.shadow.querySelector('[data-style-field="columns"]')).toBeNull() }) it('box model: per-side selects, overrides show the inherited value, shared fields appear once', async () => { h = await setup() await h.settle() await select('l1') await h.press('Style') const sel = (label: string) => h.shadow.querySelector(`select[aria-label="${label}"]`) // l1: padding sm, paddingTop lg (own), margin sm expect(sel('padding top')!.value).toBe('lg') expect(sel('padding bottom')!.value).toBe('') // inherits expect(sel('padding bottom')!.options[0].text).toBe('sm (padding)') expect(sel('padding all sides')!.value).toBe('sm') // one select for left+right, other edge shows a link expect(h.shadow.querySelectorAll('.cms-box__link').length).toBeGreaterThan(0) await act(async () => void fireEvent.change(sel('padding bottom')!, { target: { value: 'xl' } })) await h.settle() expect(data('l1').paddingBottom).toBe('xl') await act(async () => void fireEvent.change(sel('padding top')!, { target: { value: '' } })) await h.settle() expect('paddingTop' in data('l1')).toBe(false) expect(sel('padding top')!.options[0].text).toBe('sm (padding)') }) it('breakpoint tabs edit responsive..* and leave the base alone', async () => { h = await setup() await h.settle() await select('l1') await h.press('Style') await act(async () => void [...h.shadow.querySelectorAll('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click()) expect(h.shadow.querySelector('[data-style-field="gap"]')).toBeNull() // spacing has no per-breakpoint form await act(async () => void chip('flow', 'Stack').click()) await h.settle() expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } }) expect(data('l1').flow).toBe('row') expect(byId('l1').className).toContain('k6n-layout--t-stack') await act(async () => void q('[aria-label="Reset Flow"]').click()) await h.settle() expect('responsive' in data('l1')).toBe(false) }) it('rapid clicks on one field coalesce into a single undo step', async () => { h = await setup() await h.settle() await select('l1') await h.press('Style') for (const t of ['sm', 'lg', 'xl']) await act(async () => void chip('gap', t).click()) await h.settle() expect(data('l1').gap).toBe('xl') await h.press('Undo (Ctrl+Z)') expect(data('l1').gap).toBe('md') // back to before the first click }) }) describe('wrap in layout (give any block spacing)', () => { it('wraps in one atomic batch, opens the Style panel, and undoes in one step', async () => { h = await setup() await h.settle() await select('t2') const edits = editOps().length await h.press('Wrap in layout') await h.settle() const wrapper = byId('t2').closest('[data-cms-type="layout"]')! expect(wrapper).not.toBeNull() expect(wrapper.parentElement!.closest('[data-cms]')!.getAttribute('data-cms')).toBe('s2') // took t2's place expect(editOps().length).toBe(edits + 1) expect(editOps().at(-1).map((o: any) => o.op)).toEqual(['insert', 'move']) expect(h.editor.ctrl.store.get().selected).toBe(wrapper.getAttribute('data-cms')) expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('Style') await h.press('Undo (Ctrl+Z)') expect(byId('t2').closest('[data-cms-type="layout"]')).toBeNull() expect(byId('t2').parentElement!.getAttribute('data-cms')).toBe('s2') }) it('is not offered for blocks that already have style fields', async () => { h = await setup() await h.settle() await select('l1') expect(h.shadow.querySelector('[aria-label="Wrap in layout"]')).toBeNull() }) }) describe('on-page spacing handles', () => { const grips = () => [...h.shadow.querySelectorAll('.cms-grip-h')] const ptr = (el: Element, type: string, x: number, y: number) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }))) it('draws grips only for what the selected block can express', async () => { h = await setup() await h.settle() await select('l1') const labels = grips().map((g) => g.getAttribute('aria-label')!.split(':')[0]) expect(labels).toEqual(expect.arrayContaining(['padding top', 'padding bottom', 'padding (all sides)', 'margin top', 'margin bottom', 'margin (all sides)'])) await select('t1') expect(grips()).toHaveLength(0) }) it('dragging snaps to a token, previews live, and commits a single coalesced set', async () => { h = await setup() await h.settle() // jsdom has no layout/CSS vars: give the page a measured scale and a computed padding-top of 32px. const px: Record = { none: 0, xs: 8, sm: 16, md: 32, lg: 64, xl: 96 } h.editor.ctrl.measure = (v) => px[v.replace('--k6n-sp-', '')] ?? null byId('l1').style.paddingTop = '32px' await select('l1') const top = () => grips().find((g) => g.getAttribute('aria-label')!.startsWith('padding top'))! expect(top().getAttribute('aria-label')).toBe('padding top: 32px') await ptr(top(), 'pointerdown', 50, 100) await ptr(top(), 'pointermove', 50, 140) // +40px -> 72 -> nearest lg (64) expect(byId('l1').style.getPropertyValue('padding-top')).toBe('64px') // live preview expect(h.shadow.querySelector('.cms-tip')!.textContent).toContain('lg') await ptr(top(), 'pointerup', 50, 140) await h.settle() expect(data('l1').paddingTop).toBe('lg') // paddingTop, not the shared padding expect(data('l1').padding).toBe('sm') expect(byId('l1').className).toContain('k6n-pt-lg') expect(byId('l1').style.getPropertyValue('padding-top')).toBe('') // preview removed }) it('a click without movement changes nothing; Escape/cancel removes the preview', async () => { h = await setup() await h.settle() h.editor.ctrl.measure = () => 10 await select('l1') const before = editOps().length const g = grips()[0] await ptr(g, 'pointerdown', 10, 10) await ptr(g, 'pointerup', 10, 10) await h.settle() expect(editOps().length).toBe(before) }) })