Files
sm-seo-plugin/tests/elements/schema.test.ts
Jakub Zych 075cf17007 test: element tests for E8 types and currency, E9 states and queue, E10 list caps, the parent select
- 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
2026-11-04 19:54:06 +01:00

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('')
})
})