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()
+ })
+})