import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { checkCustom, parseValue, serializeValue, validCurrency, validPrice } from '../../assets/src/elements/schema' import { labelsFor } from '../../assets/src/elements/labels' import { HostFailure, mountWidget, settle } from './host' // E8 golem15-seo-schema, E9 golem15-seo-graph and golem15-seo-parent // (UI-SPEC C17, C18, P33-23). beforeAll(async () => { await import('../../assets/src/elements/index') }) afterEach(() => { vi.useRealTimers() document.body.replaceChildren() }) function mountSchema(kind: string, value: string): HTMLElement & { value: string } { const node = document.createElement('golem15-seo-schema') as HTMLElement & { value: string } node.setAttribute('ui-locale', 'en') node.setAttribute('options', JSON.stringify({ kind })) node.value = value document.body.append(node) return node } describe('E8 value helpers', () => { it('round-trips the Service fields as the same JSON string', () => { const raw = JSON.stringify({ type: 'Service', fields: { serviceType: 'Workshops', areaServed: 'Warszawa', price: '150', currency: 'PLN' }, custom: '' }) expect(serializeValue(parseValue(raw))).toBe(raw) expect(serializeValue(parseValue(''))).toBe('') }) it('checks prices, currencies and custom JSON-LD', () => { expect(validPrice('149,99')).toBe(true) expect(validPrice('abc')).toBe(false) expect(validCurrency('PLN')).toBe(true) expect(validCurrency('ZLOTY')).toBe(false) expect(checkCustom('{"@type": "Thing"}')).toEqual({ kind: 'valid', nodes: 1 }) expect(checkCustom('[{"a": 1}, {"b": 2}]')).toEqual({ kind: 'valid', nodes: 2 }) expect(checkCustom('42').kind).toBe('notObject') expect(checkCustom('{"a": "' + 'x'.repeat(17000) + '"}').kind).toBe('tooLarge') const bad = checkCustom('{\n "a": 1,\n oops\n}') expect(bad.kind).toBe('error') if (bad.kind === 'error') { expect(bad.line).toBe(3) expect(bad.column).toBeGreaterThan(0) } }) }) describe('E8 golem15-seo-schema', () => { it('renders the article type options with the default and its note, custom collapsed', () => { const node = mountSchema('article', '') const options = [...node.querySelectorAll('option')].map((o) => o.textContent) expect(options).toEqual(['Default (BlogPosting)', 'BlogPosting (blog post)', 'Article (article)', 'NewsArticle (news)']) expect(node.textContent).toContain(labelsFor('en').noteArticle) expect(node.querySelector('textarea')).toBeNull() expect(node.querySelector('.seo-schema__disclosure')!.getAttribute('aria-expanded')).toBe('false') }) it('shows the Service grid and keeps the last valid price', () => { const node = mountSchema('page', JSON.stringify({ type: 'Service', fields: { price: '100' }, custom: '' })) expect(node.querySelectorAll('.seo-schema__grid input')).toHaveLength(4) const changes: string[] = [] node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value)) const price = node.querySelectorAll('.seo-schema__grid input')[2] price.value = 'abc' price.dispatchEvent(new Event('input')) expect(node.textContent).toContain(labelsFor('en').priceInvalid) expect(JSON.parse(node.value).fields.price).toBe('100') price.value = '149,99' price.dispatchEvent(new Event('input')) expect(JSON.parse(changes[changes.length - 1]).fields.price).toBe('149,99') }) it('opens set custom JSON-LD and reports parse errors with line and column', () => { vi.useFakeTimers() const node = mountSchema('page', JSON.stringify({ type: '', fields: {}, custom: '{"a": 1}' })) const area = node.querySelector('textarea')! expect(area).not.toBeNull() area.value = '{"a": ' area.dispatchEvent(new Event('input')) vi.advanceTimersByTime(300) expect(node.querySelector('.seo-schema__status')!.textContent).toMatch(/^Line 1, column \d+:/) area.value = '{"x": "' + 'y'.repeat(17000) + '"}' area.dispatchEvent(new Event('input')) vi.advanceTimersByTime(300) expect(node.querySelector('.seo-schema__status')!.textContent).toBe(labelsFor('en').jsonTooLarge) }) it('shows a single fixed type for collections', () => { const node = mountSchema('collection', '') expect(node.querySelector('select')).toBeNull() expect(node.querySelector('.seo-schema__fixed')!.textContent).toBe('CollectionPage (listing)') }) }) const answer = { locale: 'en', url: 'https://example.test/blog/post', graph: { '@context': 'https://schema.org', '@graph': [{ '@type': 'BlogPosting' }] }, nodes: [ { id: 'https://example.test/blog/post#article', type: 'BlogPosting', rules: true, findings: [{ level: 'recommended', property: 'image' }] }, { id: 'https://example.test/blog/post#video-1', type: 'VideoObject', rules: true, findings: [{ level: 'required', property: 'uploadDate' }] }, { id: 'https://example.test/blog/post#webpage', type: 'WebPage', rules: false, findings: [] }, ], } describe('E9 golem15-seo-graph', () => { it('asks on connect and renders the nodes, findings, JSON and the Rich Results link', async () => { const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => answer }) await settle() expect(host.posted).toEqual([{ op: 'graph', locale: '' }]) const text = host.node.textContent ?? '' expect(text).toContain('JSON-LD for EN — https://example.test/blog/post') expect(text).toContain('Missing recommended property “image” (BlogPosting)') expect(text).toContain('Missing required property “uploadDate” (VideoObject)') expect(text).toContain('WebPage has no rich-result rules at Google') const link = host.node.querySelector('.seo-graph__link')! expect(link.href).toBe('https://search.google.com/test/rich-results?url=https%3A%2F%2Fexample.test%2Fblog%2Fpost') expect(link.rel).toBe('noopener') host.node.querySelector('.seo-graph__disclosure')!.click() expect(host.node.querySelector('pre')!.textContent).toContain('"@context": "https://schema.org"') expect(host.node.getAttribute('aria-busy')).toBe('false') }) it('shows allGood without findings, re-checks 800 ms after a fill change and follows the locale', async () => { const clean = { ...answer, nodes: [{ id: '#webpage', type: 'WebPage', rules: false, findings: [] }] } const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => clean }) await settle() expect(host.node.textContent).toContain(labelsFor('en').allGood) host.node.setAttribute('fill-values', JSON.stringify({ seo_schema: '{"type":"Article"}' })) await new Promise((r) => setTimeout(r, 850)) await settle() expect(host.posted).toHaveLength(2) window.dispatchEvent(new CustomEvent('summer:ml-locale', { detail: 'pl' })) await settle() expect(host.posted[2]).toEqual({ op: 'graph', locale: 'pl' }) }) it('keeps the last graph and shows the danger line when the action fails', async () => { let fail = false const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => { if (fail) { throw new HostFailure('nope') } return answer }, }) await settle() fail = true host.node.querySelector('.seo-graph__button')!.click() await settle() expect(host.node.querySelector('.seo-graph__error')!.textContent).toBe(labelsFor('en').graphFailed) expect(host.node.textContent).toContain('Missing recommended property “image”') }) }) describe('golem15-seo-parent', () => { it('lists the options of its value with the default and emits the choice', () => { const node = document.createElement('golem15-seo-parent') as HTMLElement & { value: unknown } node.setAttribute('ui-locale', 'en') node.value = { selected: 'acme-section:2', options: [{ value: 'acme-section:1', label: 'Recipes' }, { value: 'acme-section:2', label: 'Mushrooms' }] } document.body.append(node) const select = node.querySelector('select')! expect([...select.options].map((o) => o.textContent)).toEqual(['Default (first category)', 'Recipes', 'Mushrooms']) expect(select.value).toBe('acme-section:2') const changes: string[] = [] node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value)) select.value = '' select.dispatchEvent(new Event('change')) expect(changes).toEqual(['']) // A stored choice that is no longer a parent falls back to the default. node.value = { selected: 'acme-section:9', options: [{ value: 'acme-section:1', label: 'Recipes' }] } expect(node.value).toBe('') }) })