Files
sm-seo-plugin/tests/elements/text.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

129 lines
5.3 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { rule } from './css'
// E1 golem15-seo-text (UI-SPEC C4): the status thresholds with a mocked
// canvas measureText, and the counter line's layout contract at 320px.
// The mocked canvas: width(text) answers the pixel width of a string.
let width: (text: string) => number = (text) => text.length * 10
HTMLCanvasElement.prototype.getContext = function () {
return { font: '', measureText: (text: string) => ({ width: width(text) }) } as unknown as CanvasRenderingContext2D
} as unknown as typeof HTMLCanvasElement.prototype.getContext
type Measure = typeof import('../../assets/src/elements/measure')
let m: Measure
let labels: typeof import('../../assets/src/elements/labels')
beforeAll(async () => {
m = await import('../../assets/src/elements/measure')
labels = await import('../../assets/src/elements/labels')
await import('../../assets/src/elements/index')
})
afterEach(() => {
width = (text) => text.length * 10
document.body.replaceChildren()
})
/** A string of n characters (with spaces, so it reads like text). */
function chars(n: number): string {
return Array.from({ length: n }, (_, i) => (i % 6 === 5 ? ' ' : 'a')).join('').replace(/ $/, 'a')
}
describe('E1 status thresholds', () => {
it('title: 29 characters are short, 30 are good', () => {
width = () => 300
expect(m.status(chars(29), 'title').status).toBe('short')
expect(m.status(chars(30), 'title').status).toBe('good')
})
it('title: 580 px is good, 581 px is long', () => {
width = () => 580
expect(m.status(chars(40), 'title')).toMatchObject({ status: 'good', px: 580, maxPx: 580, chars: 40 })
width = () => 581
expect(m.status(chars(40), 'title')).toMatchObject({ status: 'long', px: 581 })
})
it('title: pixels decide long before characters decide short', () => {
width = () => 581
expect(m.status(chars(10), 'title').status).toBe('long')
})
it('description: 119 characters are short, 120 are good', () => {
width = () => 700
expect(m.status(chars(119), 'description').status).toBe('short')
expect(m.status(chars(120), 'description').status).toBe('good')
})
it('description: 920 px is good, 921 px is long', () => {
width = () => 920
expect(m.status(chars(130), 'description')).toMatchObject({ status: 'good', maxPx: 920 })
width = () => 921
expect(m.status(chars(130), 'description').status).toBe('long')
})
it('an empty or blank value is empty for both measures', () => {
for (const measure of ['title', 'description'] as const) {
expect(m.status('', measure).status).toBe('empty')
expect(m.status(' ', measure)).toMatchObject({ status: 'empty', chars: 0 })
}
})
it('characters are counted as code points after trimming', () => {
width = () => 100
expect(m.status(' Zażółć gęślą ', 'title').chars).toBe(12)
})
it('the element shows the status and the counter in both languages', () => {
for (const locale of ['en', 'pl']) {
const L = labels.labelsFor(locale)
const node = document.createElement('golem15-seo-text') as HTMLElement & { value: string }
node.setAttribute('ui-locale', locale)
node.setAttribute('options', JSON.stringify({ measure: 'title' }))
document.body.append(node)
width = () => 400
node.value = chars(29)
expect(node.dataset.status).toBe('short')
expect(node.querySelector('.seo-text__status-text')?.textContent).toBe(L.shortTitle)
width = () => 581
node.value = chars(35)
expect(node.dataset.status).toBe('long')
expect(node.querySelector('.seo-text__status-text')?.textContent).toBe(L.longTitle)
expect(node.querySelector('.seo-text__count')?.textContent).toContain('581')
}
})
it('typing emits summer-change with the right-trimmed value', () => {
const node = document.createElement('golem15-seo-text') as HTMLElement & { value: string }
document.body.append(node)
const seen: string[] = []
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
const input = node.querySelector('input') as HTMLInputElement
input.value = 'Grzyby leśne '
input.dispatchEvent(new Event('input'))
expect(seen).toEqual(['Grzyby leśne'])
expect(node.value).toBe('Grzyby leśne')
})
})
describe('E1 counter line at 320px', () => {
// The layout contract that makes the two parts wrap without overlapping.
it('the status and the count are separate items of a wrapping flex line with a gap', () => {
const counter = rule('golem15-seo-text .seo-text__counter')
expect(counter.display).toBe('flex')
expect(counter['flex-wrap']).toBe('wrap')
expect(Number.parseFloat(counter.gap)).toBeGreaterThan(0)
for (const part of ['golem15-seo-text .seo-text__status', 'golem15-seo-text .seo-text__count']) {
const decls = rule(part)
expect(decls.position ?? 'static').toBe('static')
expect(decls.width).toBeUndefined()
expect(decls['white-space']).toBeUndefined()
}
const node = document.createElement('golem15-seo-text')
node.style.width = '320px'
document.body.append(node)
const line = node.querySelector('.seo-text__counter') as HTMLElement
expect(Array.from(line.children).map((c) => c.className)).toEqual(['seo-text__status', 'seo-text__count'])
})
})