Files
sm-seo-plugin/tests/elements/serp.test.ts
Jakub Zych dcfbdc05ed test: element backstops
vitest + happy-dom tests of the UI-SPEC backstops: E1 status thresholds
(29/30 characters, 580/581 px; 119/120 and 920/921 for descriptions)
with a mocked measureText and the counter's wrap contract; the E2 grammar
over every directive combination, the emitted values and the stacking
contract with unclipped en and pl labels; the E3 effective title,
word-boundary truncation and keyphrase stem matching; each E4 lint rule
and a clean file; E6 apply gating. happy-dom has no layout engine, so the
narrow-width rows check the stylesheet's layout contract.
2026-10-27 13:26:59 +01:00

149 lines
6.7 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from 'vitest'
// 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()
})
/** A serp element answered with one context locale. */
function preview(locale: Partial<import('../../assets/src/elements/serp').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))
const actions: unknown[] = []
node.addEventListener('summer-action', (ev) => actions.push((ev as CustomEvent).detail.payload))
document.body.append(node)
expect(actions).toEqual([{ op: 'context' }])
const loc = { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [loc] } } }))
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)
})
})