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.
This commit is contained in:
2026-10-27 13:26:59 +01:00
parent 9461c74a80
commit dcfbdc05ed
6 changed files with 618 additions and 0 deletions

27
tests/elements/css.ts Normal file
View File

@@ -0,0 +1,27 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
// The element stylesheet for layout backstops. Vitest stubs CSS imports, so
// the file is read from disk (vitest runs from the plugin root). happy-dom
// has no layout engine: the backstops check the layout contract instead.
export const css = readFileSync(`${process.cwd()}/assets/src/elements/styles.css`, 'utf8')
/** The declarations of every rule (outside @media) whose selector list names selector. */
export function rule(selector: string, sheet = css): Record<string, string> {
const out: Record<string, string> = {}
const body = sheet.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, '')
for (const match of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = match[1].split(',').map((s) => s.trim())
if (!selectors.includes(selector)) {
continue
}
for (const decl of match[2].split(';')) {
const colon = decl.indexOf(':')
if (colon > 0) {
out[decl.slice(0, colon).trim()] = decl.slice(colon + 1).trim()
}
}
}
return out
}

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { advancedCount, emptyRobots, parseRobots, serializeRobots, type Robots } from '../../assets/src/elements/robots'
import { labelsFor } from '../../assets/src/elements/labels'
import { rule } from './css'
// E2 golem15-seo-robots (UI-SPEC C5): the value grammar shared with
// classes/robots_value.go, the element's emitted values, and the stacking
// contract of the two segmented groups at 360px.
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
/** Every combination of the grammar's directives (representative arguments). */
function combinations(): Robots[] {
const out: Robots[] = []
for (const index of ['', 'index', 'noindex'] as const) {
for (const follow of ['', 'follow', 'nofollow'] as const) {
for (let flags = 0; flags < 8; flags++) {
for (const maxSnippet of [null, -1, 0, 160]) {
for (const maxImagePreview of ['', 'none', 'standard', 'large'] as const) {
for (const maxVideoPreview of [null, -1, 30]) {
for (const unavailableAfter of ['', '2027-01-31']) {
out.push({
index,
follow,
noarchive: (flags & 1) !== 0,
nosnippet: (flags & 2) !== 0,
noimageindex: (flags & 4) !== 0,
maxSnippet,
maxImagePreview,
maxVideoPreview,
unavailableAfter,
})
}
}
}
}
}
}
}
return out
}
describe('E2 value grammar', () => {
it('every directive combination round-trips in the fixed order', () => {
const all = combinations()
expect(all.length).toBe(3 * 3 * 8 * 4 * 4 * 3 * 2)
const order = ['index', 'noindex', 'follow', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview', 'max-video-preview', 'unavailable_after']
for (const r of all) {
const value = serializeRobots(r)
expect(parseRobots(value)).toEqual(r)
const names = value === '' ? [] : value.split(',').map((d) => d.split(':')[0])
const positions = names.map((n) => order.indexOf(n))
expect(positions).toEqual([...positions].sort((a, b) => a - b))
}
})
it('any order, case and spacing reads back in the fixed order', () => {
expect(serializeRobots(parseRobots(' MAX-SNIPPET:5 , nofollow,NOINDEX ') as Robots)).toBe('noindex,nofollow,max-snippet:5')
expect(serializeRobots(parseRobots('unavailable_after:2027-01-01,noarchive,index') as Robots)).toBe('index,noarchive,unavailable_after:2027-01-01')
})
it('all-inherit is the empty string', () => {
expect(serializeRobots(emptyRobots())).toBe('')
expect(parseRobots('')).toEqual(emptyRobots())
expect(parseRobots(' ')).toEqual(emptyRobots())
expect(advancedCount(emptyRobots())).toBe(0)
})
it('malformed values are refused like the Go grammar', () => {
for (const bad of [',', 'index,', 'index,noindex', 'follow,nofollow', 'noarchive,noarchive', 'index:1', 'noarchive:yes', 'max-snippet', 'max-snippet:abc', 'max-snippet:-2', 'max-snippet:1.5', 'max-image-preview:huge', 'unavailable_after:2027-13-01', 'unavailable_after:27-01-01', 'none', 'noodp']) {
expect(parseRobots(bad), bad).toBeNull()
}
})
it('the element emits the fixed-order value and "" when both groups inherit', () => {
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
document.body.append(node)
node.value = 'max-snippet:20'
const seen: string[] = []
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
const segment = (key: string, i: number) => node.querySelector(`[data-focus-key="${key}-${i}"]`) as HTMLButtonElement
segment('follow', 2).click()
segment('index', 2).click()
expect(seen).toEqual(['nofollow,max-snippet:20', 'noindex,nofollow,max-snippet:20'])
expect(segment('index', 2).getAttribute('aria-checked')).toBe('true')
node.value = 'noindex'
seen.length = 0
segment('index', 0).click()
expect(seen).toEqual([''])
expect(node.value).toBe('')
})
it('a disabled element emits nothing', () => {
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
node.setAttribute('disabled', '')
document.body.append(node)
const seen: string[] = []
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
;(node.querySelector('[data-focus-key="index-2"]') as HTMLButtonElement).click()
expect(seen).toEqual([])
})
})
describe('E2 at 360px', () => {
it('the two groups wrap onto their own lines with a gap', () => {
const groups = rule('golem15-seo-robots .seo-robots__groups')
expect(groups.display).toBe('flex')
expect(groups['flex-wrap']).toBe('wrap')
expect(Number.parseFloat(groups.gap)).toBeGreaterThan(0)
expect(rule('golem15-seo-robots .seo-robots__group')['flex-direction']).toBe('column')
})
it('no segment label is clipped in either language', () => {
const segment = rule('golem15-seo-robots .seo-robots__segment')
for (const clip of ['overflow', 'text-overflow', 'width', 'max-width']) {
expect(segment[clip], clip).toBeUndefined()
}
for (const locale of ['en', 'pl']) {
const L = labelsFor(locale)
const node = document.createElement('golem15-seo-robots')
node.setAttribute('ui-locale', locale)
node.style.width = '360px'
document.body.append(node)
const texts = Array.from(node.querySelectorAll('.seo-robots__segment')).map((s) => s.textContent)
expect(texts).toEqual([L.inherit, L.index, L.noindex, L.inherit, L.follow, L.nofollow])
for (const text of texts) {
expect(text).not.toMatch(/…$/)
}
expect(node.querySelectorAll('.seo-robots__groups > .seo-robots__group').length).toBe(2)
node.remove()
}
})
})

View File

@@ -0,0 +1,92 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { byteSize, findingText, lintRobots, MAX_FINDINGS, MAX_ROBOTS_BYTES } from '../../assets/src/elements/robotstxt'
import { labelsFor } from '../../assets/src/elements/labels'
// E4 golem15-seo-robotstxt (UI-SPEC C10): each lint rule fires on its
// sample line, none fires on a clean file, findings never block.
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
const clean = [
'# Rules for every crawler',
'User-agent: *',
'Disallow: /admin',
'Allow: /',
'Disallow: *.pdf$',
'Disallow:',
'',
'User-agent: Googlebot # inline comment',
'Allow: /public',
'Host: example.test',
'Clean-param: utm_source',
].join('\n')
describe('E4 lint', () => {
it('a clean file has no finding', () => {
expect(lintRobots(clean)).toEqual([])
expect(lintRobots(clean.replace(/\n/g, '\r\n'))).toEqual([])
expect(lintRobots('')).toEqual([])
})
it.each([
['noColon', 'User-agent: *\nDisallow /private', { kind: 'noColon', line: 2 }],
['noGroup', 'Disallow: /private\nUser-agent: *', { kind: 'noGroup', line: 1 }],
['badPath', 'User-agent: *\nDisallow: private', { kind: 'badPath', line: 2 }],
['ownSitemap', 'User-agent: *\nSitemap: https://example.test/sitemap.xml', { kind: 'ownSitemap', line: 2 }],
['crawlDelay', 'User-agent: *\nCrawl-delay: 10', { kind: 'crawlDelay', line: 2 }],
['unknownSuggest', 'User-agent: *\nDissallow: /x', { kind: 'unknownSuggest', line: 2, word: 'Dissallow', suggestion: 'Disallow' }],
['unknown', 'User-agent: *\nNoindex-everything: /x', { kind: 'unknown', line: 2, word: 'Noindex-everything' }],
])('%s fires on its sample line', (kind, file, want) => {
const findings = lintRobots(file)
expect(findings).toEqual([want])
for (const locale of ['en', 'pl']) {
const text = findingText(labelsFor(locale), findings[0])
expect(text).toContain(String(want.line))
expect(text).not.toMatch(/:(line|word|suggestion)\b/)
}
expect(kind).toBe(want.kind)
})
it('a misspelt agent line suggests User-agent', () => {
expect(lintRobots('User-agnet: *')).toEqual([{ kind: 'unknownSuggest', line: 1, word: 'User-agnet', suggestion: 'User-agent' }])
})
it('the element lists at most ten findings and then "and more"', () => {
const node = document.createElement('golem15-seo-robotstxt') as HTMLElement & { value: string }
node.setAttribute('ui-locale', 'en')
document.body.append(node)
node.value = Array.from({ length: MAX_FINDINGS + 3 }, () => 'Crawl-delay: 5').join('\n')
const panel = node.querySelector('.seo-robotstxt__lint') as HTMLElement
expect(panel.textContent).toContain(labelsFor('en').moreFindings.replace(':count', '3'))
node.value = clean
expect(panel.textContent).not.toContain('Crawl-delay')
})
it('findings never block: the element still emits every edit', () => {
const node = document.createElement('golem15-seo-robotstxt') as HTMLElement & { value: string }
document.body.append(node)
const seen: string[] = []
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
const area = node.querySelector('textarea') as HTMLTextAreaElement
area.value = 'Disallow /broken'
area.dispatchEvent(new Event('input'))
expect(seen).toEqual(['Disallow /broken'])
})
it('the size meter counts UTF-8 bytes against 500 KB', () => {
expect(byteSize('abc')).toBe(3)
expect(byteSize('żółw')).toBe(7)
const node = document.createElement('golem15-seo-robotstxt') as HTMLElement & { value: string }
document.body.append(node)
node.value = 'x'.repeat(MAX_ROBOTS_BYTES)
expect(node.classList.contains('seo-robotstxt--over')).toBe(false)
node.value = 'x'.repeat(MAX_ROBOTS_BYTES + 1)
expect(node.classList.contains('seo-robotstxt--over')).toBe(true)
})
})

148
tests/elements/serp.test.ts Normal file
View File

@@ -0,0 +1,148 @@
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)
})
})

128
tests/elements/text.test.ts Normal file
View File

@@ -0,0 +1,128 @@
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'])
})
})

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { applyState, ROW_CAP, shownRows, type PreviewData, type PreviewRow } from '../../assets/src/elements/transfer'
import { labelsFor } from '../../assets/src/elements/labels'
// E6 golem15-seo-transfer (UI-SPEC C13): Apply is enabled only after a
// preview with no refused row and at least one change.
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
function row(line: number, change: PreviewRow['change']): PreviewRow {
return { line, change, address: '/a' + line, field: 'target', before: '', after: '/b', note: change === 'refused' ? 'Loop' : '' }
}
function previewData(rows: PreviewRow[]): PreviewData {
const summary = { new: 0, changed: 0, unchanged: 0, refused: 0, warning: 0 }
for (const r of rows) {
summary[r.change]++
}
return { op: 'preview', token: 'tok-1', dataset: 'redirects', summary, rows, total: rows.length, count: summary.new + summary.changed }
}
/** A transfer element answered with a preview; returns it and its actions. */
function screen(data: PreviewData): { node: HTMLElement; actions: unknown[] } {
const node = document.createElement('golem15-seo-transfer')
node.setAttribute('ui-locale', 'en')
const actions: unknown[] = []
node.addEventListener('summer-action', (ev) => actions.push((ev as CustomEvent).detail.payload))
document.body.append(node)
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data } }))
return { node, actions }
}
function applyButton(node: HTMLElement): HTMLButtonElement {
const buttons = node.querySelectorAll<HTMLButtonElement>('.seo-transfer__actions button')
return buttons[buttons.length - 1]
}
describe('E6 apply gating', () => {
it('one refused row disables Apply and shows refusedBlocks', () => {
const { node } = screen(previewData([row(2, 'new'), row(3, 'changed'), row(4, 'refused')]))
expect(applyButton(node).disabled).toBe(true)
expect(node.textContent).toContain(labelsFor('en').refusedBlocks)
const first = node.querySelector('tbody tr')
expect(first?.className).toBe('seo-transfer__tr--refused')
})
it('zero changes disables Apply and shows the nothing note, not refusedBlocks', () => {
const { node } = screen(previewData([row(2, 'unchanged')]))
expect(applyButton(node).disabled).toBe(true)
expect(node.textContent).toContain(labelsFor('en').nothing)
expect(node.textContent).not.toContain(labelsFor('en').refusedBlocks)
})
it('a clean preview enables Apply, which sends the preview token', () => {
const { node, actions } = screen(previewData([row(2, 'new'), row(3, 'unchanged')]))
const apply = applyButton(node)
expect(apply.disabled).toBe(false)
expect(node.textContent).not.toContain(labelsFor('en').refusedBlocks)
apply.click()
expect(actions).toContainEqual({ op: 'apply', token: 'tok-1' })
})
it('applyState and shownRows', () => {
expect(applyState(previewData([row(1, 'refused'), row(2, 'new')]))).toBe('refused')
expect(applyState(previewData([row(1, 'unchanged')]))).toBe('nothing')
expect(applyState(previewData([row(1, 'changed')]))).toBe('ready')
const many = [...Array.from({ length: ROW_CAP + 5 }, (_, i) => row(i + 2, 'new')), row(999, 'refused')]
const shown = shownRows(many)
expect(shown.length).toBe(ROW_CAP)
expect(shown[0]).toEqual(row(999, 'refused'))
})
it('a refused preview answer shows its message and no Apply', () => {
const { node } = screen({ op: 'preview', error: 'stale', message: 'The data changed since the preview.' } as unknown as PreviewData)
expect(node.textContent).toContain('The data changed since the preview.')
expect(node.querySelector('.seo-transfer__actions')).toBeNull()
})
})