/* eslint-env jest */ import { analyze, scoreTone } from '../analyzer'; const base = { title: 'Zipper Hoodie: 5 Great Reasons to Love It | Shop', description: 'The zipper hoodie keeps you warm on cold days: heavy cotton, a two-way zipper and a hood that stays put. Free returns for thirty days, always.', slug: 'zipper-hoodie', content: '', keyword: 'zipper hoodie', hasFeaturedImage: true, siteHost: 'shop.example', }; const paragraph = (words: number, text = 'word'): string => `

${Array.from({ length: words }, () => text).join(' ')}

`; /** About 800 words with the keyword near 1%, links both ways, alt text and a list. */ const article = `

The zipper hoodie is the warm layer for cold days. ${'Soft cotton keeps the cold out. '.repeat(12)}

Why a zipper hoodie

${paragraph(100, 'warm')}

A zipper hoodie opens fast. ${'The zipper hoodie stays put under a coat. '.repeat(6)}

zipper hoodie in grey More hoodies Care guide ${paragraph(100, 'cosy')} ${paragraph(100, 'layer')} ${paragraph(100, 'zip')} ${paragraph(100, 'hood')} ${paragraph(100, 'cotton')}`; describe('analyze', () => { it('counts the words of a longer keyword once', () => { const text = `

${'The hoodie size chart helps. '.repeat(4)}${'Warm cotton layers. '.repeat(60)}

`; const one = analyze({ ...base, keyword: 'hoodie size', content: text }); const both = analyze({ ...base, keyword: 'hoodie size', keywords: ['hoodie size', 'hoodie'], content: text, }); const mentions = (analysis: typeof one): string => analysis.results.find(item => item.id === 'keywordDensity')?.message ?? ''; expect(mentions(one)).toContain('4 mentions'); expect(mentions(both)).toContain('4 mentions'); }); it('finds an accented keyword in the ASCII slug', () => { const { results } = analyze({ ...base, slug: 'ceainic-din-fonta', keyword: 'ceainic din fontă', }); expect(results.find(item => item.id === 'keywordInPermalink')?.passed).toBe( true ); expect( analyze({ ...base, slug: 'ceainic', keyword: 'ceainic din fontă', }).results.find(item => item.id === 'keywordInPermalink')?.passed ).toBe(false); }); it('weighs the post tests to 89 points, the informative ones to none', () => { const { results } = analyze({ ...base, content: article }); const total = results.reduce((sum, item) => sum + item.max, 0); expect(total).toBe(89); // A number in the title is not asked for. expect(results.map(item => item.id)).not.toContain('titleHasNumber'); expect(results.every(item => item.score <= item.max)).toBe(true); const informative = results .filter(item => item.max === 0 && item.group !== 'aeo') .map(item => item.id); expect(informative).toEqual([ 'linksHasExternals', 'linksNotAllExternals', 'titleSentiment', 'titleHasPowerWords', 'contentHasTOC', ]); }); it('shows the Rank Math tests the Recomaze rules do not ask for, with their note either way', () => { const { results } = analyze({ ...base, title: 'Zipper hoodie in heavy cotton', content: article, }); const rankMathOnly = results.filter(item => item.rankMathOnly); expect(rankMathOnly.map(item => item.id)).toEqual([ 'linksHasExternals', 'linksNotAllExternals', 'titleSentiment', 'titleHasPowerWords', ]); expect(rankMathOnly.every(item => item.max === 0)).toBe(true); expect(rankMathOnly.every(item => item.fix.includes('Recomaze'))).toBe( true ); // Advice is not a comparison: the AI answers keep asking for changes. expect( results.filter(item => item.group === 'aeo' && item.rankMathOnly) ).toHaveLength(0); }); it('applies the Recomaze rules: a density floor at 0.3% and products complete at 200 words', () => { // 2 mentions of a two-word keyword in 500 words = 0.4%: a mention // counts once, whatever the keyword's length. const thin = `

The zipper hoodie. ${'word '.repeat(246)}A zipper hoodie. ${'word '.repeat(246)}

`; const density = analyze({ ...base, content: thin }).results.find( item => item.id === 'keywordDensity' ); expect(density?.passed).toBe(true); expect(density?.score).toBe(3); // 10 occurrences of a two-word keyword in 100 words = 20%. const stuffed = `

${'The zipper hoodie is warm here. '.repeat(10)}${'word '.repeat(40)}

`; const high = analyze({ ...base, content: stuffed }).results.find( item => item.id === 'keywordDensity' ); expect(high?.passed).toBe(false); expect(high?.score).toBe(0); expect(high?.fix).toContain('too often'); const productCopy = `

${'Zipper hoodie words. '.repeat(70)}

`; const product = analyze({ ...base, content: productCopy, postType: 'product', }).results.find(item => item.id === 'lengthContent'); expect(product?.score).toBe(8); const post = analyze({ ...base, content: productCopy }).results.find( item => item.id === 'lengthContent' ); expect(post?.passed).toBe(false); }); it('scores a well-optimised article high', () => { const analysis = analyze({ ...base, content: article }); const failed = analysis.results .filter(item => !item.passed) .map(item => item.id); expect(analysis.score).toBeGreaterThanOrEqual(81); expect(scoreTone(analysis.score)).toBe('great'); expect(failed).not.toContain('keywordInTitle'); expect(failed).not.toContain('keywordInPermalink'); expect(failed).not.toContain('keywordIn10Percent'); expect(failed).not.toContain('keywordInImageAlt'); expect(failed).not.toContain('linksHasInternal'); }); it('fails the keyword tests and asks for a keyword when none is set', () => { const analysis = analyze({ ...base, keyword: '', content: article }); expect(analysis.needsKeyword).toBe(true); expect( analysis.results.find(item => item.id === 'keywordInTitle')?.passed ).toBe(false); expect(analysis.results.some(item => item.id === 'keywordNotUsed')).toBe( false ); expect( analysis.results.filter(item => item.message === 'No focus keyword set.') ).toHaveLength(1); }); it('shows the keyword-used-before check only once known and never scores it', () => { const unknown = analyze({ ...base, content: article }); expect(unknown.results.some(item => item.id === 'keywordNotUsed')).toBe( false ); const used = analyze({ ...base, content: article, keywordUsedElsewhere: true, }); const check = used.results.find(item => item.id === 'keywordNotUsed'); expect(check?.passed).toBe(false); expect(check?.max).toBe(0); expect(used.score).toBe(unknown.score); }); it('runs only the archive tests for terms and normalises the score', () => { const term = analyze({ ...base, content: '', scope: 'term', description: 'All our zipper hoodie styles in one place.', }); expect(term.results).toHaveLength(7); expect(term.results.map(item => item.group)).not.toContain('content'); expect(term.results.reduce((sum, item) => sum + item.max, 0)).toBeLessThan( 100 ); expect(term.score).toBeGreaterThan(50); expect(term.score).toBeLessThanOrEqual(100); }); it('asks for a table of contents only from four H2 sections up', () => { const toc = (content: string) => analyze({ ...base, content }).results.find( item => item.id === 'contentHasTOC' ); const sections = ['Sizes', 'Care', 'Fit', 'Returns'] .map(heading => `

${heading}

${paragraph(60)}`) .join(''); const short = article.replace('', ''); expect(toc(article)?.passed).toBe(true); expect(toc(short)?.passed).toBe(true); expect(toc(short)?.message).toBe( 'Short enough to go without a table of contents.' ); expect(toc(short + sections)?.passed).toBe(false); expect(toc(short + sections)?.message).toBe( '5 sections and no table of contents.' ); expect(toc(article + sections)?.passed).toBe(true); const questions = ['Is it heavy?', 'Does it rust?', 'Which oil?'] .map(question => `

${question}

${paragraph(20)}`) .join(''); expect(toc(short + questions + questions)?.passed).toBe(true); }); it('tells internal links from external ones by host', () => { const analysis = analyze({ ...base, content: `${paragraph(50)}inout`, }); const byId = Object.fromEntries( analysis.results.map(item => [item.id, item.passed]) ); expect(byId.linksHasInternal).toBe(true); expect(byId.linksHasExternals).toBe(true); expect(byId.linksNotAllExternals).toBe(true); }); describe('the AI answers group', () => { const passedById = (content: string): Record => Object.fromEntries( analyze({ ...base, content }) .results.filter(item => item.group === 'aeo') .map(item => [item.id, item.passed]) ); it('weighs nothing and never moves the score', () => { const { results, score } = analyze({ ...base, content: article }); const aeo = results.filter(item => item.group === 'aeo'); expect(aeo.map(item => item.id)).toEqual([ 'aeoAnswerFirst', 'aeoHeadings', 'aeoKeyTakeaways', 'aeoFaq', 'aeoTable', 'aeoNoDashes', 'aeoNoBannedPhrases', 'aeoNoPricesOrStock', ]); expect(aeo.every(item => item.max === 0 && item.score === 0)).toBe(true); expect(score).toBe(analyze({ ...base, content: article }).score); expect( analyze({ ...base, scope: 'term', content: article }).results.some( item => item.group === 'aeo' ) ).toBe(false); }); it('wants the answer first, in under sixty words, with no setup', () => { expect( passedById( '

The zipper hoodie is a cotton layer with a two-way zipper. It keeps the cold out.

' ).aeoAnswerFirst ).toBe(true); expect( passedById('

Have you ever wondered which hoodie to buy?

') .aeoAnswerFirst ).toBe(false); expect( passedById('

In this article we look at hoodies.

').aeoAnswerFirst ).toBe(false); expect(passedById(paragraph(80)).aeoAnswerFirst).toBe(false); }); it('judges the subheadings and the FAQ', () => { const pointed = '

Answer.

How warm is a zipper hoodie?

Zip vs pullover

Wash it cold

'; expect(passedById(pointed).aeoHeadings).toBe(true); expect( passedById( '

Answer.

Introduction

Benefits

Conclusion

' ).aeoHeadings ).toBe(false); expect( passedById( '

Answer.

Materials

Sizing

Care

' ).aeoHeadings ).toBe(false); const faq = '

Answer.

Is it warm?

Yes.

Does it shrink?

No.

Is it unisex?

Yes.

'; expect(passedById(faq).aeoFaq).toBe(true); expect( passedById('

Answer.

FAQ

Is it warm?

').aeoFaq ).toBe(false); expect(passedById('

Answer.

').aeoFaq).toBe(false); // Questions elsewhere in the post are no FAQ entries. expect( passedById( '

Answer.

Which size fits?

How warm is it?

FAQ

Is it warm?

Does it shrink?

Care

Can it be ironed?

' ).aeoFaq ).toBe(false); }); it('asks for a table only where things are compared, and takeaways only on long posts', () => { const compare = analyze({ ...base, title: 'Zipper hoodie vs pullover', content: '

Answer.

', }).results.find(item => item.id === 'aeoTable'); expect(compare?.passed).toBe(false); expect( analyze({ ...base, title: 'Zipper hoodie vs pullover', content: '

Answer.

zip
', }).results.find(item => item.id === 'aeoTable')?.passed ).toBe(true); expect(passedById('

Answer.

').aeoTable).toBe(true); expect(passedById('

Answer.

').aeoKeyTakeaways).toBe(true); expect(passedById(article).aeoKeyTakeaways).toBe(false); expect( passedById(`

Key takeaways

${article}`) .aeoKeyTakeaways ).toBe(true); }); it('finds a takeaways list in the page language, its section, or the shape the agent writes', () => { const list = ''; const takeaways = (top: string): boolean => passedById(`${top}${article}`).aeoKeyTakeaways; // Headings and bold lines in other languages, with or without accents. expect(takeaways(`

Pe scurt

${list}`)).toBe(true); expect(takeaways(`

Ce trebuie sa retii

${list}`)).toBe(true); expect(takeaways(`

Punctele cheie:

${list}`)).toBe( true ); expect(takeaways(`

En bref

Trois points.

${list}`)).toBe( true ); expect(takeaways(`

Das Wichtigste auf einen Blick

${list}`)).toBe( true ); expect(takeaways(`

W skrócie

${list}`)).toBe(true); // The section the agent's articles carry, whatever its heading says. expect( takeaways( `

Lo que hay que saber

${list}
` ) ).toBe(true); // The rewrite's shape: right after the opening paragraph, a heading and // a list, block comments between. expect( takeaways( `

The zipper hoodie is warm.

\n\n

Ce trebuie să știi

\n\n` ) ).toBe(true); // A sentence of body text that says "pe scurt" is no heading. expect( takeaways( `

Pe scurt, hanoracul cu fermoar ține de cald în diminețile reci și se spală ușor la treizeci de grade.

${list}` ) ).toBe(false); // A word inside another word is not the label. expect(takeaways(`

Rezumatul comenzii

${list}`)).toBe(false); expect( analyze({ ...base, content: article }).results.find( item => item.id === 'aeoKeyTakeaways' )?.fix ).toContain("in the page's language"); }); it('flags dashes, filler, hedging, prices and stock', () => { const clean = passedById( '

The hoodie is warm. It costs less than a coat.

' ); expect(clean.aeoNoDashes).toBe(true); expect(clean.aeoNoBannedPhrases).toBe(true); expect(clean.aeoNoPricesOrStock).toBe(true); expect(passedById('

Warm — and light.

').aeoNoDashes).toBe(false); const filler = analyze({ ...base, content: "

In today's fast-paced world, this seamless hoodie is a game-changer; perhaps the best.

", }).results.find(item => item.id === 'aeoNoBannedPhrases'); expect(filler?.passed).toBe(false); expect(filler?.message).toContain("in today's"); expect( passedById('

A cutting-edge, state-of-the-art hoodie.

') .aeoNoBannedPhrases ).toBe(false); expect( passedById('

A handsome saying on the label.

').aeoNoBannedPhrases ).toBe(true); expect( passedById('

Yours for $49.99, in stock now.

').aeoNoPricesOrStock ).toBe(false); expect( passedById('

Only 3 left, order today.

').aeoNoPricesOrStock ).toBe(false); expect( passedById('

Prices start from 120 lei.

').aeoNoPricesOrStock ).toBe(false); }); it('reads other languages, ranges and European prices, and leaves product pages their price', () => { expect( passedById('

Store it at 10–20 °C, as we did from 2020–2024.

') .aeoNoDashes ).toBe(true); for (const price of ['89 €', 'EUR 89', '99 zł', '120 RON']) { expect( passedById(`

It costs ${price} today.

`).aeoNoPricesOrStock ).toBe(false); } expect( analyze({ ...base, postType: 'product', content: '

In stock, 89 € with free delivery.

', }).results.find(item => item.id === 'aeoNoPricesOrStock')?.passed ).toBe(true); expect( passedById('

Tricoul este în stoc, cu livrare în 24 de ore.

') .aeoNoPricesOrStock ).toBe(false); expect( passedById('

Ultimele bucăți pleacă azi.

').aeoNoPricesOrStock ).toBe(false); expect( passedById('

Stocul de bumbac organic crește.

').aeoNoPricesOrStock ).toBe(true); const opening = analyze({ ...base, content: '

Alegerea mărimii potrivite pentru un hanorac ține de trei măsurători.

', }).results.find(item => item.id === 'aeoAnswerFirst'); expect(opening?.passed).toBe(false); expect(opening?.message).toContain('starts with "Alegerea"'); expect( analyze({ ...base, content: '

În acest articol îți arătăm cum se măsoară.

', }).results.find(item => item.id === 'aeoAnswerFirst')?.message ).toContain('starts with "În acest articol"'); const faq = '

Răspuns.

Întrebări frecvente

Se spală la 30 de grade?

Da.

Intră la uscător?

Nu.

Este unisex?

Da.

'; expect(passedById(faq).aeoFaq).toBe(true); expect( passedById( '

Răspuns.

Cum se curăță tigaia?

Materiale

Îngrijire

' ).aeoHeadings ).toBe(true); const table = (title: string): boolean | undefined => analyze({ ...base, title, content: '

Answer.

' }).results.find( item => item.id === 'aeoTable' )?.passed; expect(table('Which size should I pick?')).toBe(true); expect(table('Best way to clean leather')).toBe(true); expect(table('Best cast iron skillets for beginners')).toBe(false); expect(table('Top 5 hoodies of the year')).toBe(false); }); }); it('matches the keyword as whole words, without accents, in content with or without paragraph tags', () => { const inside = analyze({ ...base, keyword: 'cat', title: 'Categories and education', content: '

Categories of education.

', }).results; expect(inside.find(item => item.id === 'keywordInTitle')?.passed).toBe( false ); expect(inside.find(item => item.id === 'keywordDensity')?.passed).toBe( false ); const romanian = analyze({ ...base, keyword: 'cizme de drumetie', title: 'Cizme de drumeție impermeabile', slug: 'cizme-de-drumetie', content: '

Cizmele de drumeţie ţin picioarele uscate. Cizme de drumeție pentru munte.

', }).results; expect(romanian.find(item => item.id === 'keywordInTitle')?.passed).toBe( true ); expect(romanian.find(item => item.id === 'keywordInContent')?.passed).toBe( true ); const german = analyze({ ...base, keyword: 'Größe wählen', slug: 'groesse-waehlen', }).results; expect(german.find(item => item.id === 'keywordInPermalink')?.passed).toBe( true ); const plain = analyze({ ...base, content: `The zipper hoodie keeps you warm.\n\n${'Another short paragraph here. '.repeat(10)}`, }).results.find(item => item.id === 'contentHasShortParagraphs'); expect(plain?.passed).toBe(true); expect(plain?.message).not.toContain('0 words'); }); it('reads a Romanian page by its own words, whatever the site says: the keyword in any form', () => { const results = analyze({ ...base, keyword: 'hanorac', title: 'Cum alegi hanoracul potrivit: ghid de mărimi', description: 'Alegerea mărimii potrivite pentru un hanorac ține de trei măsurători.', slug: 'ghid-marimi-hanorac', content: '

Alegerea mărimii potrivite pentru hanoracul tău ține de trei măsurători.

Cum te măsori pentru hanorace?

Ai nevoie de un centimetru.

', siteLanguage: 'en_US', }).results; [ 'keywordInTitle', 'keywordInMetaDescription', 'keywordInPermalink', 'keywordIn10Percent', 'keywordInContent', 'keywordInSubheadings', 'titleStartWithKeyword', ].forEach(id => expect([id, results.find(item => item.id === id)?.passed]).toEqual([ id, true, ]) ); expect( results.find(item => item.id === 'keywordDensity')?.message ).toContain('2 mentions'); }); it('finds the words of a keyword in any order, but only within one sentence', () => { const skillet = { ...base, keyword: '26 cm cast iron skillet', title: 'Cast iron skillet 26 cm: the pan for every hob', slug: 'cast-iron-skillet-26-cm', }; const found = analyze({ ...skillet, content: '

This cast iron skillet 26 cm heats evenly.

', }).results; ['keywordInTitle', 'keywordInPermalink', 'keywordInContent'].forEach(id => expect([id, found.find(item => item.id === id)?.passed]).toEqual([ id, true, ]) ); const apart = analyze({ ...skillet, content: '

This skillet is cast iron. It measures 26 cm.

', }).results; expect(apart.find(item => item.id === 'keywordInContent')?.passed).toBe( false ); }); it('counts density over every focus keyword, one mention each, and says so', () => { const text = `

${'Zipper hoodie for winter. Fleece jacket too. '.repeat(10)}${'word '.repeat(1000)}

`; const primary = analyze({ ...base, content: text }).results.find( item => item.id === 'keywordDensity' ); expect(primary?.message).toContain('10 mentions in 1070 words'); expect(primary?.score).toBe(3); const both = analyze({ ...base, content: text, keywords: ['zipper hoodie', 'fleece jacket'], }).results.find(item => item.id === 'keywordDensity'); expect(both?.message).toContain('20 mentions'); expect(both?.score).toBe(6); }); it('reads the beginning of a short text whole and the title by its first half', () => { const short = `

${'word '.repeat(60)}The zipper hoodie closes the text.

`; expect( analyze({ ...base, content: short }).results.find( item => item.id === 'keywordIn10Percent' )?.passed ).toBe(true); const title = analyze({ ...base, title: 'Warm zipper hoodie for cold mornings and long evenings', }).results.find(item => item.id === 'titleStartWithKeyword'); expect(title?.passed).toBe(true); }); it('finds the keyword in the featured image alt and in alt texts with an apostrophe', () => { const results = analyze({ ...base, keyword: "men's hiking boots", content: '

Text.

Men\'s hiking boots', }).results; expect(results.find(item => item.id === 'keywordInImageAlt')?.passed).toBe( true ); const featured = analyze({ ...base, content: '

Text.

', imageAlts: ['A zipper hoodie in grey'], }).results.find(item => item.id === 'keywordInImageAlt'); expect(featured?.passed).toBe(true); }); it('marks a test that earned part of its points and keeps its advice', () => { // 2 mentions of a two-word keyword in 500 words: 0.4%, above the floor. const thin = `

The zipper hoodie. ${'word '.repeat(246)}A zipper hoodie. ${'word '.repeat(246)}

`; const density = analyze({ ...base, content: thin }).results.find( item => item.id === 'keywordDensity' ); expect(density?.passed).toBe(true); expect(density?.partial).toBe(true); expect(density?.score).toBe(3); expect(density?.fix).toContain('a few more times'); }); it('passes an article from 600 words on Rank Math points, and asks a product for 200', () => { const length = (words: number, postType?: string) => analyze({ ...base, postType, content: `

${'word '.repeat(words)}

`, }).results.find(item => item.id === 'lengthContent'); expect(length(599)).toMatchObject({ passed: false, score: 0 }); expect(length(599)?.fix).toContain('at least 600 words'); // The pilot's article: four of eight points, and nothing left to do. for (const [words, points] of [ [600, 2], [1100, 3], [1855, 4], [2000, 5], [2500, 8], ]) { expect(length(words)).toMatchObject({ passed: true, partial: false, score: points, fix: '', }); } expect(length(150, 'product')).toMatchObject({ passed: true, partial: true, score: 4, }); expect(length(150, 'product')?.fix).toContain('at least 200 words'); expect(length(200, 'product')).toMatchObject({ partial: false, score: 8 }); }); it('judges a secondary keyword on the text and a subheading only', () => { const content = `

Our zipper hoodie keeps you warm on winter walks.

Which zipper hoodie fits a cold morning?

${paragraph(80)}

A fleece lining helps on cold mornings.

`; const secondary = (keyword: string, scope?: 'term') => analyze({ ...base, content, keyword, role: 'secondary', scope }); const both = secondary('cold morning'); expect(both.results.map(item => item.id)).toEqual([ 'keywordInContent', 'keywordInSubheadings', ]); expect(both.score).toBe(100); // In the text, in no subheading: half the points. expect(secondary('fleece lining').score).toBe(50); expect(secondary('wool socks').score).toBe(0); // An archive's secondary keyword: its description. const term = secondary('cold days', 'term'); expect(term.results.map(item => item.id)).toEqual([ 'keywordInMetaDescription', ]); expect(term.score).toBe(100); // The first keyword keeps every test. expect( analyze({ ...base, content, keyword: 'cold morning' }).results.length ).toBeGreaterThan(10); }); }); describe('the product listing group', () => { const bare = { price: '', image: false, gallery: 0, shortDescription: '', gtin: '', mpn: '', brand: '', categories: 0, reviews: 0, }; const ids = (input: Parameters[0]): string[] => analyze(input) .results.filter(item => item.group === 'product') .map(item => `${item.id}:${item.passed ? 'pass' : 'fail'}`); it('runs on a product with its data only, and never moves the score', () => { const product = { ...base, postType: 'product', content: article }; expect(ids(product)).toEqual([]); const withData = { ...product, product: bare }; expect(ids(withData)).toEqual([ 'productPrice:fail', 'productImages:fail', 'productShortDescription:fail', 'productIdentifier:fail', 'productBrand:fail', 'productCategory:fail', 'productReviews:fail', ]); expect(analyze(withData).score).toBe(analyze(product).score); }); it('passes a product listed the way Google reads it', () => { const complete = { ...base, postType: 'product', product: { price: '39.90', image: true, gallery: 2, shortDescription: 'A pre-seasoned cast iron skillet of 26 cm for gas, induction and the oven, for cooks who sear and bake in one pan.', gtin: '4006381333931', mpn: '', brand: 'Cast Iron Corner', categories: 1, reviews: 3, }, }; expect(ids(complete).every(id => id.endsWith(':pass'))).toBe(true); // A GTIN with a wrong check digit identifies nothing; an MPN does, with a brand. expect( ids({ ...complete, product: { ...complete.product, gtin: '4006381333932' }, }) ).toContain('productIdentifier:fail'); expect( analyze({ ...complete, product: { ...complete.product, gtin: '4006381333932' }, }).results.find(item => item.id === 'productIdentifier')?.message ).toContain('not a valid barcode'); expect( ids({ ...complete, product: { ...complete.product, gtin: '', mpn: 'SK-26' }, }) ).toContain('productIdentifier:pass'); }); });