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.
149 lines
6.7 KiB
TypeScript
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)
|
|
})
|
|
})
|