import { afterEach, describe, expect, it } from 'vitest' import { act } from 'react' import { byId, setup } from './harness' import type { Harness } from './harness' let h: Harness afterEach(() => h?.cleanup()) const q = (sel: string) => h.shadow.querySelector(sel)! const qa = (sel: string) => [...h.shadow.querySelectorAll(sel)] const kids = (id: string) => h.cms.graph.children(id).map((e) => e.to) const setInput = async (el: HTMLInputElement, v: string) => { const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! await act(async () => { set.call(el, v) el.dispatchEvent(new Event('input', { bubbles: true })) }) await h.settle() } describe('node search (existing nodes, type filter)', () => { it('lists nodes and filters by type and text', async () => { h = await setup() await h.settle() const all = await h.editor.ctrl.api.searchNodes({}) expect(all.total).toBeGreaterThan(5) expect(all.items.map((n) => n.id)).toContain('orphan1') const texts = await h.editor.ctrl.api.searchNodes({ type: 'text' }) expect(texts.items.length).toBeGreaterThan(0) expect(texts.items.every((n) => n.dataType === 'text')).toBe(true) const q1 = await h.editor.ctrl.api.searchNodes({ q: 'sparrow' }) expect(q1.items.map((n) => n.id).sort()).toEqual(['orphan1', 'orphan2']) const both = await h.editor.ctrl.api.searchNodes({ type: 'button', q: 'sparrow' }) expect(both.items.map((n) => n.id)).toEqual(['orphan2']) }) it('the panel shows a Nodes section with a type filter; linking adds a shared block', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await h.settle() expect(q('[aria-label="Search nodes"]')).not.toBeNull() expect(q('[aria-label="Filter by type"]')).not.toBeNull() const rows = () => qa('.cms-noderow') expect(rows().length).toBeGreaterThan(0) await setInput(q('[aria-label="Search nodes"]') as HTMLInputElement, 'sparrow') await new Promise((r) => setTimeout(r, 400)) // search debounce (bare: act() holds component timers) await h.settle() expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan1'), expect.stringContaining('orphan2')]) // filter to buttons: only orphan2 stays const sel = q('[aria-label="Filter by type"]') as HTMLSelectElement await act(async () => { sel.value = 'button' sel.dispatchEvent(new Event('change', { bubbles: true })) }) await new Promise((r) => setTimeout(r, 400)) // debounce again (bare: act() holds component timers) await h.settle() expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan2')]) // link it after the selected block: select t2 (child of s2), link lands in s2 await act(async () => void byId('t2').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }))) await h.settle() const before = kids('s2') await act(async () => void qa('.cms-noderow button')[0].click()) await h.settle() await h.settle() expect(kids('s2')).toHaveLength(before.length + 1) expect(h.cms.graph.node('orphan2')).toBeDefined() // shared: the node itself survives }) }) describe('slots / dropzones (x-graph.connections)', () => { it('section exposes its sidebar slot; plain children still win when they accept the type', async () => { h = await setup() await h.settle() await h.editor.ctrl.fillHandles() const slots = h.editor.ctrl.slotsFor('s1') expect(slots.map((s) => s.name)).toEqual(['sidebar']) expect(slots[0].def.to).toEqual(['text', 'button', 'footer']) expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'footer')).toBe(true) expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'section')).toBe(false) expect(h.editor.ctrl.acceptsInSlot('s1', 'nope', 'text')).toBe(false) // footer fits no plain-children rule, so drops resolve to the slot expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar') // heading fits plain children: no slot needed expect(h.editor.ctrl.handleForDrop('s1', 'heading')).toBeUndefined() }) it('inserting into a slot lands the block there and the outline shows it grouped', async () => { h = await setup() await h.settle() const res = await h.editor.ctrl.insertIntoSlot('s1', 'sidebar', 'footer', 0) expect(res).not.toBeNull() await h.settle() await h.editor.ctrl.fillHandles() await h.settle() expect(h.editor.ctrl.handleOf(res!.results[0].node!)).toBe('sidebar') expect(h.editor.ctrl.slotChildren('s1', 'sidebar').map((b) => b.id)).toEqual([res!.results[0].node!]) await h.press('Panel') await h.settle() const heads = qa('.cms-oslot__head').map((x) => x.textContent) expect(heads.some((t) => t!.includes('sidebar'))).toBe(true) }) it('linking an existing node into a slot shares it (no copy)', async () => { h = await setup() await h.settle() const res = await h.editor.ctrl.linkExisting('s1', 'orphan1', { handle: 'sidebar' }) expect(res).not.toBeNull() expect(kids('s1')).toContain('orphan1') await h.editor.ctrl.fillHandles() expect(h.editor.ctrl.handleOf('orphan1')).toBe('sidebar') // still reachable as its own node (shared, not copied) expect(h.cms.graph.node('orphan1').data.body).toContain('sparrows') }) it('outline drops commit through the same engine: reorder, insert, link', async () => { h = await setup() await h.settle() const line = { x: 0, y: 0, w: 0, h: 0 } // reorder b1 before h1 within s1 await h.editor.ctrl.dropBlock('b1', { parentId: 's1', index: 0, line }) expect(kids('s1')).toEqual(['b1', 'h1', 't1']) // insert a new heading at index 1 await h.editor.ctrl.dropNew({ parentId: 's1', index: 1, line }, { type: 'heading' }) expect(kids('s1')).toHaveLength(4) expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('heading') // link the orphan into the sidebar slot await h.editor.ctrl.dropExisting({ parentId: 's1', handle: 'sidebar', index: 0, line }, 'orphan2') await h.editor.ctrl.fillHandles() expect(h.editor.ctrl.handleOf('orphan2')).toBe('sidebar') }) it('the slot add button opens an insert picker limited to the slot types', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await h.settle() const add = qa('[aria-label="Add to sidebar"]') expect(add.length).toBeGreaterThan(0) await act(async () => void add[0].click()) await h.settle() await h.settle() const tiles = qa('.cms-dialog .cms-tile').map((x) => x.getAttribute('data-type')) expect(tiles.sort()).toEqual(['button', 'footer', 'text']) await act(async () => void q('.cms-dialog .cms-tile[data-type="footer"]').click()) await h.settle() await h.editor.ctrl.fillHandles() const footers = h.editor.ctrl.slotChildren('s1', 'sidebar') expect(footers).toHaveLength(1) expect(h.cms.graph.node(footers[0].id).dataType).toBe('footer') }) })