import { afterEach, describe, expect, it } from 'vitest' import { act } from 'react' import { byId, click, setup } from './harness' import type { Harness } from './harness' import { OTHER_GROUP, groupOf, labelKey, sortGroups } from '../src/schema' import { schemas } from '../mock/fixtures' let h: Harness afterEach(() => h?.cleanup()) const select = async (id: string) => { await act(async () => void click(byId(id))) await h.settle() } const q = (sel: string) => h.shadow.querySelector(sel)! const qa = (sel: string) => [...h.shadow.querySelectorAll(sel)] const label = (id: string) => h.editor.ctrl.labelFor(id) describe('x-graph label / group helpers', () => { it('read the label property and the group from the schema; sort groups with the catch-all last', () => { expect(labelKey({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('title') expect(groupOf({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('Commerce') expect(labelKey({ 'x-graph': {} })).toBeUndefined() expect(labelKey(undefined)).toBeUndefined() expect(groupOf({ 'x-graph': { group: 3 } })).toBeUndefined() expect(sortGroups(['Layout', OTHER_GROUP, 'Commerce', 'Layout', 'Content'])).toEqual(['Commerce', 'Content', 'Layout', OTHER_GROUP]) expect(labelKey(schemas.footer)).toBe('companyName') }) }) describe('node titles (x-graph.label)', () => { it('loads every title with ONE tree request, not one request per block', async () => { h = await setup() await h.settle() await h.settle() expect(h.cms.requests.filter((r) => r.startsWith('GET /api/tree/')).length).toBe(1) expect(h.cms.requests.filter((r) => /^GET \/api\/nodes\/[^/]+$/.test(r))).toEqual([]) expect(['s1', 'h1', 'b1', 'f1', 'l1'].map(label)).toEqual(['Welcome', 'Edit this headline', 'Get in touch', 'Mock AB', 'Two columns']) expect(label('t1')).toBeUndefined() // the text type declares no label }) it('shows the title in the outline (type as the secondary line), falling back to type + text', async () => { h = await setup() await h.settle() await h.settle() await h.press('Panel') const items = qa('.cms-orow__main') const s1 = items.find((b) => b.textContent!.includes('Welcome'))! expect(s1.querySelector('.cms-outline__type')!.textContent).toBe('Welcome') expect(s1.querySelector('.cms-outline__text')!.textContent).toBe('section') const t1 = items.find((b) => b.querySelector('.cms-outline__type')!.textContent === 'text')! expect(t1.querySelector('.cms-outline__text')!.textContent).toContain('A paragraph') }) it('titles the toolbar chip (type and group in its tooltip), the hover tag and the dialog', async () => { h = await setup() await h.settle() await h.settle() await select('s1') expect(q('.cms-bar__name').textContent).toBe('Welcome') expect(q('.cms-bar__name').getAttribute('title')).toBe('section · Layout') h.editor.ctrl.store.set({ hovered: 'f1' }) await h.settle() const tag = q('.cms-frame__tag') expect(tag.textContent).toContain('Mock AB') expect(tag.textContent).toContain('footer') expect(tag.querySelector('em')!.textContent).toBe('Layout') await h.press('Properties') expect(q('.cms-dialog__head').textContent).toContain('Properties · Welcome') await select('t1') expect(q('.cms-bar__name').textContent).toBe('text') // no label key: the type }) it('titles follow edits live, and new nodes get theirs lazily', async () => { h = await setup() await h.settle() await h.settle() await select('s1') await h.editor.ctrl.setData('s1', { title: 'Renamed' }) await h.settle() await h.settle() expect(label('s1')).toBe('Renamed') expect(q('.cms-bar__name').textContent).toBe('Renamed') const res = await h.editor.ctrl.insert('s2', 'button', { data: { label: 'Click me' } }) await h.settle() const id = res!.results[0].node! label(id) // first ask kicks off the fetch ... await h.settle() await h.settle() expect(label(id)).toBe('Click me') // ... and it is there once loaded }) it('long titles are shortened', async () => { h = await setup() await h.settle() await h.editor.ctrl.setData('h1', { text: 'x'.repeat(80) }) await h.settle() await h.settle() expect(label('h1')!.length).toBe(40) expect(label('h1')!.endsWith('…')).toBe(true) }) }) describe('groups in the UI', () => { it('the palette is grouped by x-graph.group (collapsible), blueprints last', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await h.settle() const secs = qa('.cms-group-sec') expect(secs.map((s) => s.querySelector('summary')!.textContent!.replace(/\s*\d+$/, '').trim())).toEqual(['Content', 'Layout', 'Blueprints']) const inGroup = (g: string) => [...secs.find((s) => s.querySelector('summary')!.textContent!.startsWith(g))!.querySelectorAll('.cms-block')].map((x) => x.dataset.type) expect(inGroup('Content')).toEqual(['heading', 'text', 'button']) expect(inGroup('Layout')).toEqual(expect.arrayContaining(['section', 'layout', 'footer'])) expect(secs.every((s) => (s as HTMLDetailsElement).open)).toBe(true) await act(async () => void ((secs[0].querySelector('summary') as HTMLElement).click())) expect((secs[0] as HTMLDetailsElement).open).toBe(false) }) it('the insert picker groups the allowed types too', async () => { h = await setup() await h.settle() await select('s1') await h.press('Add inside') await h.settle() await h.settle() expect(qa('.cms-dialog h4').map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) expect(qa('.cms-dialog .cms-tile').map((x) => x.dataset.type)).toEqual(['heading', 'text', 'button', 'section', 'layout', undefined]) // + the blueprint tile }) }) describe('the block list shows titles and groups straight from /api/types', () => { it('seeds every schema from the one types request - no per-type schema fetches for the palette', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await h.settle() // /api/types carries each type's schema: types that are not on the page are never fetched individually expect(h.cms.requests.filter((r) => r.startsWith('GET /api/schemas/page'))).toEqual([]) expect(h.cms.requests.filter((r) => r === 'GET /api/types').length).toBe(1) expect(h.editor.ctrl.schemaOf('page')).toBeDefined() expect(h.editor.ctrl.groupOfType('page')).toBe('Pages') }) it('palette items show the schema title with the type id underneath (disambiguates look-alikes)', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() const heading = q('.cms-block[data-type="heading"]') expect(heading.querySelector('span')!.textContent).toBe('Heading') expect(heading.querySelector('small')!.textContent).toBe('heading') expect(heading.getAttribute('title')).toBe('Add Heading') expect(h.editor.ctrl.typeTitle('footer')).toBe('Footer') expect(h.editor.ctrl.typeTitle('unknown-type')).toBe('unknown-type') }) it('page-root (staticRoot) types are not offered, even where a container would accept anything', async () => { h = await setup() await h.settle() expect(h.editor.ctrl.isStaticRoot('page')).toBe(true) expect(h.editor.ctrl.isStaticRoot('heading')).toBe(false) await h.press('Panel') await h.settle() expect(qa('.cms-block[data-type="page"]')).toHaveLength(0) expect(qa('.cms-group-sec summary').map((s) => s.textContent)).not.toContain('Pages') }) it('the insert picker shows titles too', async () => { h = await setup() await h.settle() await select('s1') await h.press('Add inside') await h.settle() const tile = q('.cms-dialog .cms-tile[data-type="layout"]') expect(tile.querySelector('span')!.textContent).toBe('Layout') expect(tile.querySelector('small')!.textContent).toBe('layout') }) it('ingestTypes tolerates entries without a schema (they stay "Other" until loaded)', async () => { h = await setup() await h.settle() h.editor.ctrl.ingestTypes([{ type: 'mystery' }, 'bare-string', { type: 'with', schema: { title: 'With', 'x-graph': { group: 'Odd' } } }]) expect(h.editor.ctrl.groupOfType('mystery')).toBe('Other') expect(h.editor.ctrl.groupOfType('with')).toBe('Odd') expect(h.editor.ctrl.typeTitle('with')).toBe('With') }) })