- E8: the page and profile type options, an invalid currency - E9: the checking line with aria-busy, noRules per type, one queued request, the danger line and its recovery - E10: both lists capped at 10 - the parent select's value parsing, empty default and disabled state
201 lines
9.8 KiB
TypeScript
201 lines
9.8 KiB
TypeScript
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<HTMLInputElement>('.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('renders the page type options and a fixed ProfilePage for profiles', () => {
|
|
const page = mountSchema('page', '')
|
|
const en = labelsFor('en')
|
|
expect([...page.querySelectorAll('option')].map((o) => o.textContent)).toEqual(['Default (WebPage)', en.tWebPage, en.tAboutPage, en.tContactPage, en.tService])
|
|
document.body.replaceChildren()
|
|
const profile = mountSchema('profile', '')
|
|
expect(profile.querySelector('select')).toBeNull()
|
|
expect(profile.querySelector('.seo-schema__fixed')!.textContent).toBe(en.tProfilePage)
|
|
})
|
|
|
|
it('flags an invalid currency and keeps the Service fields out of a non-Service value', () => {
|
|
const node = mountSchema('page', JSON.stringify({ type: 'Service', fields: { currency: 'PLN' }, custom: '' }))
|
|
const currency = node.querySelectorAll<HTMLInputElement>('.seo-schema__grid input')[3]
|
|
currency.value = 'ZLOTY'
|
|
currency.dispatchEvent(new Event('input'))
|
|
expect(node.textContent).toContain(labelsFor('en').currencyInvalid)
|
|
expect(JSON.parse(node.value).fields.currency).toBe('PLN')
|
|
expect(serializeValue(parseValue(JSON.stringify({ type: 'AboutPage', fields: {}, custom: '' })))).toBe(JSON.stringify({ type: 'AboutPage', fields: {}, custom: '' }))
|
|
})
|
|
|
|
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<HTMLAnchorElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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('')
|
|
})
|
|
})
|