Both elements sent their first summer-action from connectedCallback and kept it in flight forever when the host dropped it (the v0.5.1 widget host appends the element before it keeps a reference). A shared ActionQueue no longer trusts that an emitted action was taken: one the host did not mark busy synchronously is retried once on the next task, a second drop shows an error, and a disconnect forgets the request in flight. The first request is sent on a microtask. Element tests drive a fake host that mirrors WidgetField's contract, including the append-first ordering, and the robots.txt tester gets its own tests.
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
|
import { HostFailure, mountWidget, settle } from './host'
|
|
|
|
// E3 golem15-seo-serp (UI-SPEC C6): the effective title, truncation at a
|
|
// word boundary within the pixel budget (mocked canvas: 10 px per
|
|
// character), and the Q-4 keyphrase stem matching.
|
|
|
|
HTMLCanvasElement.prototype.getContext = function () {
|
|
return { font: '', measureText: (text: string) => ({ width: Array.from(text).length * 10 }) } as unknown as CanvasRenderingContext2D
|
|
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
|
|
let serp: typeof import('../../assets/src/elements/serp')
|
|
let measure: typeof import('../../assets/src/elements/measure')
|
|
let labels: typeof import('../../assets/src/elements/labels')
|
|
|
|
beforeAll(async () => {
|
|
measure = await import('../../assets/src/elements/measure')
|
|
serp = await import('../../assets/src/elements/serp')
|
|
labels = await import('../../assets/src/elements/labels')
|
|
await import('../../assets/src/elements/index')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.replaceChildren()
|
|
})
|
|
|
|
type ContextLocale = import('../../assets/src/elements/serp').ContextLocale
|
|
|
|
function contextLocale(locale: Partial<ContextLocale> = {}): ContextLocale {
|
|
return { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
|
|
}
|
|
|
|
/**
|
|
* A serp element given one context locale directly (rendering tests); the
|
|
* host protocol is covered by 'E3 host protocol' below.
|
|
*/
|
|
function preview(locale: Partial<ContextLocale>, fill: Record<string, unknown> = {}): HTMLElement {
|
|
const node = document.createElement('golem15-seo-serp')
|
|
node.setAttribute('ui-locale', 'en')
|
|
node.setAttribute('fill-values', JSON.stringify(fill))
|
|
document.body.append(node)
|
|
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [contextLocale(locale)] } } }))
|
|
return node
|
|
}
|
|
|
|
function shownTitle(node: HTMLElement): string {
|
|
return node.querySelector('.serp-card__title')?.textContent ?? ''
|
|
}
|
|
|
|
describe('E3 effective title', () => {
|
|
it('an empty SEO title applies the type template to defaultTitle', () => {
|
|
const node = preview({ titleTemplate: '%title% — Blog | %site%', defaultTitle: 'Grzyby leśne' })
|
|
expect(shownTitle(node)).toBe('Grzyby leśne — Blog | Atlas')
|
|
expect(node.querySelector('.seo-serp__notes')?.textContent).toContain('%title% — Blog | %site%')
|
|
})
|
|
|
|
it('without a type template the general template the context names applies', () => {
|
|
const node = preview({ titleTemplate: '%title% | %site%', defaultTitle: 'Grzyby' })
|
|
expect(shownTitle(node)).toBe('Grzyby | Atlas')
|
|
})
|
|
|
|
it('an empty template leaves defaultTitle', () => {
|
|
expect(shownTitle(preview({ titleTemplate: '', defaultTitle: 'Plain' }))).toBe('Plain')
|
|
})
|
|
|
|
it('a template that renders empty falls back to defaultTitle', () => {
|
|
expect(shownTitle(preview({ titleTemplate: '%site%', siteName: '', defaultTitle: 'Only title' }))).toBe('Only title')
|
|
})
|
|
|
|
it('an SEO title wins over every template', () => {
|
|
const node = preview({ titleTemplate: '%title% | %site%' }, { seo_title: { en: 'Own title' } })
|
|
expect(shownTitle(node)).toBe('Own title')
|
|
expect(node.querySelector('.seo-serp__notes')?.textContent ?? '').not.toContain('%title%')
|
|
})
|
|
|
|
it('renderTemplate trims the separators an empty placeholder leaves, like the Go side', () => {
|
|
expect(serp.renderTemplate('%title% | %site%', { title: 'About', site: '' })).toBe('About')
|
|
expect(serp.renderTemplate(' %title% · %site% ', { title: 'A', site: 'B' })).toBe('A · B')
|
|
expect(serp.renderTemplate('', { title: 'A', site: 'B' })).toBe('')
|
|
})
|
|
|
|
it('a missing description shows the noDescription line', () => {
|
|
const node = preview({ defaultDescription: null })
|
|
const desc = node.querySelector('.serp-card__description')
|
|
expect(desc?.classList.contains('serp-card__description--missing')).toBe(true)
|
|
expect(desc?.textContent).toBe(labels.labelsFor('en').noDescription)
|
|
})
|
|
})
|
|
|
|
describe('E3 truncation', () => {
|
|
it('cuts at the last word boundary within 580px and appends " …"', () => {
|
|
const long = 'Atlas grzybów jadalnych i trujących w polskich lasach liściastych oraz iglastych'
|
|
const cut = measure.truncate(long, measure.TITLE_FONT, measure.TITLE_MAX_PX)
|
|
expect(cut.endsWith(' …')).toBe(true)
|
|
const kept = cut.slice(0, -2)
|
|
expect(long.startsWith(kept)).toBe(true)
|
|
expect(long[kept.length]).toBe(' ')
|
|
expect(measure.textWidth(cut, measure.TITLE_FONT)).toBeLessThanOrEqual(580)
|
|
// The next word would not fit.
|
|
const next = long.indexOf(' ', kept.length + 1)
|
|
expect(measure.textWidth(long.slice(0, next) + ' …', measure.TITLE_FONT)).toBeGreaterThan(580)
|
|
})
|
|
|
|
it('a title that fits is unchanged', () => {
|
|
expect(measure.truncate('Short title', measure.TITLE_FONT, 580)).toBe('Short title')
|
|
expect(measure.truncate('x'.repeat(58), measure.TITLE_FONT, 580)).toBe('x'.repeat(58))
|
|
})
|
|
|
|
it('a single word too wide is cut inside the word', () => {
|
|
const cut = measure.truncate('x'.repeat(100), measure.TITLE_FONT, 580)
|
|
expect(cut).toBe('x'.repeat(56) + ' …')
|
|
})
|
|
|
|
it('the preview card truncates the title and the description', () => {
|
|
const node = preview({ titleTemplate: '', defaultTitle: 'word '.repeat(30).trim(), defaultDescription: 'opis '.repeat(300).trim() })
|
|
const title = shownTitle(node)
|
|
expect(title.endsWith(' …')).toBe(true)
|
|
expect(Array.from(title).length * 10).toBeLessThanOrEqual(580)
|
|
const desc = node.querySelector('.serp-card__description')?.textContent ?? ''
|
|
expect(Array.from(desc).length * 10).toBeLessThanOrEqual(920)
|
|
})
|
|
})
|
|
|
|
describe('E3 keyphrase stem matching (Q-4)', () => {
|
|
it('grzyby matches grzybów', () => {
|
|
expect(serp.phraseIn('grzyby', 'Atlas grzybów')).toBe(true)
|
|
expect(serp.phraseIn('grzybów', 'Grzyby jadalne')).toBe(true)
|
|
})
|
|
|
|
it('grzyb does not match grzbiet', () => {
|
|
expect(serp.phraseIn('grzyb', 'grzbiet górski')).toBe(false)
|
|
expect(serp.wordsMatch('grzyb', 'grzbiet')).toBe(false)
|
|
})
|
|
|
|
it('diacritics and case are ignored', () => {
|
|
expect(serp.phraseIn('ŁÓDŹ', 'lodz i okolice')).toBe(true)
|
|
expect(serp.phraseIn('zazolc gesla', 'Zażółć GĘŚLĄ jaźń')).toBe(true)
|
|
})
|
|
|
|
it('URL hyphens equal spaces', () => {
|
|
expect(serp.phraseIn('grzyby lesne', '/atlas/grzyby-lesne')).toBe(true)
|
|
expect(serp.phraseIn('grzyby-leśne', 'grzyby leśne w Polsce')).toBe(true)
|
|
})
|
|
|
|
it('the phrase must appear in order', () => {
|
|
expect(serp.phraseIn('lesne grzyby', 'grzyby leśne')).toBe(false)
|
|
expect(serp.phraseIn('', 'anything')).toBe(false)
|
|
})
|
|
|
|
it('short words match only when equal', () => {
|
|
expect(serp.wordsMatch('kot', 'koty')).toBe(false)
|
|
expect(serp.wordsMatch('kot', 'kot')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('E3 host protocol', () => {
|
|
const answer = (payload: unknown): unknown => {
|
|
const p = payload as { op: string; locale?: string }
|
|
if (p.op === 'context') {
|
|
return { op: 'context', host: 'example.test', locales: [contextLocale()] }
|
|
}
|
|
return { op: 'duplicates', locale: p.locale, title: [], description: [] }
|
|
}
|
|
|
|
it('loads its context from a host that appends the element before wiring it (CR-04)', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { order: 'append-first', attrs: { 'ui-locale': 'en' }, answer })
|
|
await settle()
|
|
expect(host.posted[0]).toEqual({ op: 'context' })
|
|
expect(host.node.hasAttribute('aria-busy')).toBe(false)
|
|
expect(shownTitle(host.node)).toBe('Post')
|
|
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
|
|
})
|
|
|
|
it('loads its context from a host that wires the element first, once', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
|
|
await settle()
|
|
expect(host.emitted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
|
|
expect(shownTitle(host.node)).toBe('Post')
|
|
})
|
|
|
|
it('retries an action the host dropped once', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
|
|
host.drop(1)
|
|
await settle()
|
|
expect(host.emitted.slice(0, 2)).toEqual([{ op: 'context' }, { op: 'context' }])
|
|
expect(host.posted[0]).toEqual({ op: 'context' })
|
|
expect(shownTitle(host.node)).toBe('Post')
|
|
})
|
|
|
|
it('shows the context error when the host never takes the action', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { wired: false, attrs: { 'ui-locale': 'en' }, answer })
|
|
await settle()
|
|
expect(host.emitted).toEqual([{ op: 'context' }, { op: 'context' }])
|
|
expect(host.node.hasAttribute('aria-busy')).toBe(false)
|
|
expect(host.node.querySelector('.seo-serp__error')?.textContent).toBe(labels.labelsFor('en').contextFailed)
|
|
})
|
|
|
|
it('a duplicate check asked while a request runs is sent after busy clears', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) }, answer })
|
|
await settle()
|
|
const before = host.posted.length
|
|
expect(host.posted[before - 1]).toMatchObject({ op: 'duplicates', title: 'First' })
|
|
host.node.setAttribute('busy', '')
|
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
|
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
|
expect(host.posted.length).toBe(before)
|
|
host.node.removeAttribute('busy')
|
|
await settle()
|
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
|
|
})
|
|
|
|
it('state=error unblocks the next request', async () => {
|
|
let failNext = false
|
|
const host = mountWidget('golem15-seo-serp', {
|
|
attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) },
|
|
answer: (payload) => {
|
|
if (failNext && (payload as { op: string }).op === 'duplicates') {
|
|
failNext = false
|
|
throw new HostFailure('duplicates')
|
|
}
|
|
return answer(payload)
|
|
},
|
|
})
|
|
await settle()
|
|
failNext = true
|
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
|
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
|
await settle()
|
|
expect(host.node.getAttribute('state')).toBe('error')
|
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
|
|
expect(host.node.querySelector('.seo-serp__checking')).toBeNull()
|
|
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Third' } }))
|
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
|
await settle()
|
|
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Third' })
|
|
expect(host.node.hasAttribute('state')).toBe(false)
|
|
})
|
|
|
|
it('a request in flight when the element is disconnected does not block it after reconnecting', async () => {
|
|
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en' }, answer })
|
|
await Promise.resolve()
|
|
expect(host.busy()).toBe(true)
|
|
host.node.remove()
|
|
await settle()
|
|
expect(host.node.hasAttribute('aria-busy')).toBe(true)
|
|
document.body.append(host.node)
|
|
await settle()
|
|
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(2)
|
|
expect(shownTitle(host.node)).toBe('Post')
|
|
})
|
|
}, 5000)
|