import { afterEach, beforeAll, describe, expect, it } from 'vitest' import { HostFailure, mountWidget, settle } from './host' // 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() }) type ContextLocale = import('../../assets/src/elements/serp').ContextLocale function contextLocale(locale: Partial = {}): ContextLocale { return { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale } } /** * A serp element given one context locale directly (rendering tests); the * host protocol is covered by 'E3 host protocol' below. */ 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)) document.body.append(node) node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [contextLocale(locale)] } } })) 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) }) }) describe('E3 host protocol', () => { const answer = (payload: unknown): unknown => { const p = payload as { op: string; locale?: string } if (p.op === 'context') { return { op: 'context', host: 'example.test', locales: [contextLocale()] } } return { op: 'duplicates', locale: p.locale, title: [], description: [] } } it('loads its context from a host that appends the element before wiring it (CR-04)', async () => { const host = mountWidget('golem15-seo-serp', { order: 'append-first', attrs: { 'ui-locale': 'en' }, answer }) await settle() expect(host.posted[0]).toEqual({ op: 'context' }) expect(host.node.hasAttribute('aria-busy')).toBe(false) expect(shownTitle(host.node)).toBe('Post') expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1) }) it('loads its context from a host that wires the element first, once', async () => { const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer }) await settle() expect(host.emitted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1) expect(shownTitle(host.node)).toBe('Post') }) it('retries an action the host dropped once', async () => { const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer }) host.drop(1) await settle() expect(host.emitted.slice(0, 2)).toEqual([{ op: 'context' }, { op: 'context' }]) expect(host.posted[0]).toEqual({ op: 'context' }) expect(shownTitle(host.node)).toBe('Post') }) it('shows the context error when the host never takes the action', async () => { const host = mountWidget('golem15-seo-serp', { wired: false, attrs: { 'ui-locale': 'en' }, answer }) await settle() expect(host.emitted).toEqual([{ op: 'context' }, { op: 'context' }]) expect(host.node.hasAttribute('aria-busy')).toBe(false) expect(host.node.querySelector('.seo-serp__error')?.textContent).toBe(labels.labelsFor('en').contextFailed) }) it('a duplicate check asked while a request runs is sent after busy clears', async () => { const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) }, answer }) await settle() const before = host.posted.length expect(host.posted[before - 1]).toMatchObject({ op: 'duplicates', title: 'First' }) host.node.setAttribute('busy', '') host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } })) await new Promise((resolve) => setTimeout(resolve, 900)) expect(host.posted.length).toBe(before) host.node.removeAttribute('busy') await settle() expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' }) }) it('state=error unblocks the next request', async () => { let failNext = false const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) }, answer: (payload) => { if (failNext && (payload as { op: string }).op === 'duplicates') { failNext = false throw new HostFailure('duplicates') } return answer(payload) }, }) await settle() failNext = true host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } })) await new Promise((resolve) => setTimeout(resolve, 900)) await settle() expect(host.node.getAttribute('state')).toBe('error') expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' }) expect(host.node.querySelector('.seo-serp__checking')).toBeNull() host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Third' } })) await new Promise((resolve) => setTimeout(resolve, 900)) await settle() expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Third' }) expect(host.node.hasAttribute('state')).toBe(false) }) it('a request in flight when the element is disconnected does not block it after reconnecting', async () => { const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en' }, answer }) await Promise.resolve() expect(host.busy()).toBe(true) host.node.remove() await settle() expect(host.node.hasAttribute('aria-busy')).toBe(true) document.body.append(host.node) await settle() expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(2) expect(shownTitle(host.node)).toBe('Post') }) }, 5000)