diff --git a/tests/elements/css.ts b/tests/elements/css.ts new file mode 100644 index 0000000..1adb5ba --- /dev/null +++ b/tests/elements/css.ts @@ -0,0 +1,27 @@ +/// +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 { + const out: Record = {} + 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 +} diff --git a/tests/elements/robots.test.ts b/tests/elements/robots.test.ts new file mode 100644 index 0000000..936e6ba --- /dev/null +++ b/tests/elements/robots.test.ts @@ -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() + } + }) +}) diff --git a/tests/elements/robotstxt.test.ts b/tests/elements/robotstxt.test.ts new file mode 100644 index 0000000..0bed804 --- /dev/null +++ b/tests/elements/robotstxt.test.ts @@ -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) + }) +}) diff --git a/tests/elements/serp.test.ts b/tests/elements/serp.test.ts new file mode 100644 index 0000000..cfd0591 --- /dev/null +++ b/tests/elements/serp.test.ts @@ -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, fill: Record = {}): 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) + }) +}) diff --git a/tests/elements/text.test.ts b/tests/elements/text.test.ts new file mode 100644 index 0000000..4751d3f --- /dev/null +++ b/tests/elements/text.test.ts @@ -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']) + }) +}) diff --git a/tests/elements/transfer.test.ts b/tests/elements/transfer.test.ts new file mode 100644 index 0000000..560adf6 --- /dev/null +++ b/tests/elements/transfer.test.ts @@ -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('.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() + }) +})