import { runContentChecks, overallStatus, wordCount, tagCount, extractUrls, hostOf, DEFAULT_HYPE_WORDS, type ContentCheck, } from '../checks'; /** Build a string of exactly `n` whitespace-delimited words. */ const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' '); const find = (checks: ContentCheck[], id: string) => checks.find((c) => c.id === id); describe('word/tag helpers', () => { it('wordCount handles empty, whitespace, and normal input', () => { expect(wordCount(undefined)).toBe(0); expect(wordCount('')).toBe(0); expect(wordCount(' ')).toBe(0); expect(wordCount('one two three')).toBe(3); expect(wordCount(words(462))).toBe(462); }); it('tagCount splits on commas and ignores empties', () => { expect(tagCount(undefined)).toBe(0); expect(tagCount('')).toBe(0); expect(tagCount('a, b, c')).toBe(3); expect(tagCount('a,,b, ,c,')).toBe(3); }); }); describe('runContentChecks — field presence', () => { it('emits no checks for an empty fields object (no approvedHosts)', () => { expect(runContentChecks({})).toEqual([]); }); it('only emits a check for a field that is present', () => { const checks = runContentChecks({ blog: words(450) }); expect(checks.map((c) => c.id).sort()).toEqual(['blog-words', 'no-hype']); }); }); describe('word-count bands', () => { it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => { expect(find(runContentChecks({ blog: words(450) }), 'blog-words')).toMatchObject({ status: 'pass', detail: '450 words' }); expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words' }); expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn'); // inclusive edges expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass'); expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.status).toBe('pass'); }); it('linkedin: default band 150–250', () => { expect(find(runContentChecks({ linkedin: words(200) }), 'linkedin-words')!.status).toBe('pass'); expect(find(runContentChecks({ linkedin: words(100) }), 'linkedin-words')!.status).toBe('warn'); }); it('headline: default band 8–12 words', () => { expect(find(runContentChecks({ headline: words(10) }), 'headline-words')!.status).toBe('pass'); expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.status).toBe('warn'); expect(find(runContentChecks({ headline: words(20) }), 'headline-words')!.status).toBe('warn'); }); it('honours a custom band override', () => { const c = find(runContentChecks({ blog: words(100) }, { blogWords: [50, 150] }), 'blog-words'); expect(c!.status).toBe('pass'); }); }); describe('tag count and meta length', () => { it('tags: default band 4–6', () => { expect(find(runContentChecks({ tags: 'a,b,c,d,e' }), 'tags-count')).toMatchObject({ status: 'pass', detail: '5 tags' }); expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.status).toBe('warn'); expect(find(runContentChecks({ tags: 'a,b,c,d,e,f,g' }), 'tags-count')!.status).toBe('warn'); }); it('meta description: default band 150–160 chars', () => { const inBand = 'x'.repeat(155); const tooShort = 'x'.repeat(120); expect(find(runContentChecks({ metaDescription: inBand }), 'meta-length')).toMatchObject({ status: 'pass', detail: '155 chars' }); expect(find(runContentChecks({ metaDescription: tooShort }), 'meta-length')!.status).toBe('warn'); }); }); describe('[link] placeholder', () => { it('passes when present, warns when absent', () => { expect(find(runContentChecks({ linkedin: 'read more [link] now' }), 'link-placeholder')!.status).toBe('pass'); expect(find(runContentChecks({ linkedin: 'no placeholder here' }), 'link-placeholder')).toMatchObject({ status: 'warn', detail: 'no [link] found', }); }); it('is skipped when requireLink is false', () => { expect(find(runContentChecks({ linkedin: 'no placeholder' }, { requireLink: false }), 'link-placeholder')).toBeUndefined(); }); }); describe('no-hype', () => { it('fails when a hype word appears (case-insensitive) across headline/blog/linkedin', () => { const c = find(runContentChecks({ headline: 'A REVOLUTIONARY launch', blog: words(450) }), 'no-hype'); expect(c!.status).toBe('fail'); expect(c!.detail).toContain('revolutionary'); }); it('detects multi-word and hyphenated hype phrases', () => { expect(find(runContentChecks({ blog: 'the best-in-class widget' }), 'no-hype')!.status).toBe('fail'); expect(find(runContentChecks({ blog: 'the most attractive option' }), 'no-hype')!.status).toBe('fail'); }); it('passes clean prose', () => { expect(find(runContentChecks({ blog: 'a measured, factual paragraph about widgets' }), 'no-hype')!.status).toBe('pass'); }); it('honours a custom hype list', () => { expect(find(runContentChecks({ blog: 'totally banana content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('fail'); // default hype words no longer apply when overridden expect(find(runContentChecks({ blog: 'revolutionary content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('pass'); }); it('ships a non-empty default hype list', () => { expect(DEFAULT_HYPE_WORDS.length).toBeGreaterThan(5); expect(DEFAULT_HYPE_WORDS).toContain('game-changing'); }); }); describe('approved sources', () => { const approvedHosts = ['example.com', 'gov.uk']; it('is skipped entirely when approvedHosts is omitted', () => { expect(find(runContentChecks({ sources: 'https://example.com/a' }), 'approved-sources')).toBeUndefined(); }); it('passes when every host is approved (www. stripped)', () => { const c = find(runContentChecks({ sources: 'https://www.example.com/a https://gov.uk/b' }, { approvedHosts }), 'approved-sources'); expect(c!.status).toBe('pass'); }); it('fails when any host is not on the allow-list', () => { const c = find(runContentChecks({ sources: 'https://example.com/a https://sketchy.io/x' }, { approvedHosts }), 'approved-sources'); expect(c!.status).toBe('fail'); expect(c!.detail).toContain('sketchy.io'); expect(c!.detail).not.toContain('example.com'); }); it('fails forum/social hosts even if listed', () => { const c = find( runContentChecks({ sources: 'https://reddit.com/r/x' }, { approvedHosts: ['reddit.com'] }), 'approved-sources', ); expect(c!.status).toBe('fail'); expect(c!.detail).toContain('reddit.com'); }); it('warns when there is no URL to check', () => { expect(find(runContentChecks({ sources: 'no links here' }, { approvedHosts }), 'approved-sources')).toMatchObject({ status: 'warn', detail: 'no sources to check', }); // sources field absent but allow-list present → still a warn (nothing to check) expect(find(runContentChecks({}, { approvedHosts }), 'approved-sources')!.status).toBe('warn'); }); it('extractUrls + hostOf normalise correctly', () => { expect(extractUrls('see https://a.com/x and http://b.org')).toEqual(['https://a.com/x', 'http://b.org']); expect(extractUrls('none')).toEqual([]); expect(hostOf('https://www.Example.com/path')).toBe('example.com'); expect(hostOf('not a url')).toBe(''); }); }); describe('overallStatus', () => { const mk = (status: ContentCheck['status']): ContentCheck => ({ id: status, label: status, status }); it('fail beats warn beats pass; empty is pass', () => { expect(overallStatus([])).toBe('pass'); expect(overallStatus([mk('pass'), mk('pass')])).toBe('pass'); expect(overallStatus([mk('pass'), mk('warn')])).toBe('warn'); expect(overallStatus([mk('warn'), mk('fail')])).toBe('fail'); }); it('rolls up a realistic mixed run', () => { const checks = runContentChecks( { blog: words(450), linkedin: 'a revolutionary post [link]', tags: 'a,b,c,d,e' }, {}, ); // blog pass, linkedin word-count warn (2 words), link pass, hype fail, tags pass expect(overallStatus(checks)).toBe('fail'); }); }); describe('runContentChecks — locations (768w.16.15.3 jump-to-issue)', () => { it('omits locations on a passing check — there is nothing to jump to', () => { const checks = runContentChecks({ blog: words(450) }); expect(find(checks, 'blog-words')!.status).toBe('pass'); expect(find(checks, 'blog-words')!.locations).toBeUndefined(); expect(find(checks, 'no-hype')!.status).toBe('pass'); expect(find(checks, 'no-hype')!.locations).toBeUndefined(); }); it('points a failing band check at its field with no span to highlight', () => { const checks = runContentChecks({ blog: words(10) }); const c = find(checks, 'blog-words')!; expect(c.status).toBe('warn'); expect(c.locations).toEqual([{ field: 'blog' }]); // a word count is not a substring — nothing to highlight inside the field expect(c.locations![0].matches).toBeUndefined(); }); it('maps each band check to its own field', () => { const checks = runContentChecks({ headline: words(1), linkedin: words(2), tags: 'a', metaDescription: 'short', }); expect(find(checks, 'headline-words')!.locations).toEqual([{ field: 'headline' }]); expect(find(checks, 'linkedin-words')!.locations).toEqual([{ field: 'linkedin' }]); expect(find(checks, 'tags-count')!.locations).toEqual([{ field: 'tags' }]); expect(find(checks, 'meta-length')!.locations).toEqual([{ field: 'metaDescription' }]); }); it('locates a missing [link] placeholder at the linkedin field', () => { const checks = runContentChecks({ linkedin: words(200) }); const c = find(checks, 'link-placeholder')!; expect(c.status).toBe('warn'); expect(c.locations).toEqual([{ field: 'linkedin' }]); }); it('locates hype words in the SPECIFIC field they appear in, not the joined haystack', () => { const checks = runContentChecks({ headline: 'a calm headline', blog: 'this is revolutionary work', linkedin: 'nothing to see [link]', }); const c = find(checks, 'no-hype')!; expect(c.status).toBe('fail'); // only the blog is implicated — headline/linkedin must NOT be offered as jumps expect(c.locations).toEqual([{ field: 'blog', matches: ['revolutionary'] }]); }); it('reports one location per implicated field when hype spans several', () => { const checks = runContentChecks({ headline: 'groundbreaking news', blog: 'a revolutionary idea', }); const c = find(checks, 'no-hype')!; expect(c.locations).toEqual([ { field: 'headline', matches: ['groundbreaking'] }, { field: 'blog', matches: ['revolutionary'] }, ]); // detail stays the flat human sentence across all fields expect(c.detail).toBe('revolutionary, groundbreaking'); }); it('highlights unapproved sources by URL, not bare host, so the span exists in the text', () => { const checks = runContentChecks( { sources: 'https://good.com/a https://reddit.com/r/x' }, { approvedHosts: ['good.com'] }, ); const c = find(checks, 'approved-sources')!; expect(c.status).toBe('fail'); expect(c.locations).toEqual([{ field: 'sources', matches: ['https://reddit.com/r/x'] }]); // the approved URL is not offered for highlight expect(c.locations![0].matches).not.toContain('https://good.com/a'); }); it('locates the sources field when there are no sources to check', () => { const checks = runContentChecks({ sources: '' }, { approvedHosts: ['good.com'] }); const c = find(checks, 'approved-sources')!; expect(c.status).toBe('warn'); expect(c.locations).toEqual([{ field: 'sources' }]); }); it('every non-passing check carries a location a UI can act on', () => { const checks = runContentChecks( { blog: words(10), linkedin: 'revolutionary', tags: 'a', sources: 'https://reddit.com/x' }, { approvedHosts: ['good.com'] }, ); const stranded = checks.filter((c) => c.status !== 'pass' && !c.locations?.length).map((c) => c.id); // name the offenders — a bare toBeTruthy() would just say "expected truthy" expect(stranded).toEqual([]); expect(checks.some((c) => c.status !== 'pass')).toBe(true); }); }); describe('source-overlap check', () => { const SRC = 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported, adding another category to a tax architecture that already captures selected products across the Gulf region.'; it('is skipped when no sourceTexts are supplied', () => { expect(find(runContentChecks({ blog: 'anything' }), 'source-overlap')).toBeUndefined(); expect(find(runContentChecks({ blog: 'anything' }, { sourceTexts: [] }), 'source-overlap')).toBeUndefined(); }); it('fails when the blog copies a long verbatim run from a source', () => { const c = find( runContentChecks( { blog: 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported.' }, { sourceTexts: [SRC] }, ), 'source-overlap', ); expect(c!.status).toBe('fail'); expect(c!.locations?.[0].field).toBe('blog'); }); it('passes a genuine paraphrase', () => { const c = find( runContentChecks( { blog: 'From 2026 the Kingdom widens its sugary-drink levy, a reminder Gulf tax rules vary by market.' }, { sourceTexts: [SRC] }, ), 'source-overlap', ); expect(c!.status).toBe('pass'); }); });