import { afterEach, describe, expect, it, vi } from 'vitest' import { readOptions } from '../../assets/src/blogeditor/element' import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform' import { labelsFor, plural } from '../../assets/src/blogeditor/labels' import { isExternalUrl } from '../../assets/src/blogeditor/linkform' import { normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline' import { button, chooseImage, click, fill, form, insertAtBlock, mount, status } from './helpers' afterEach(() => { vi.useRealTimers() document.body.replaceChildren() }) const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more', 'kliknij tutaj'] }) const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] }) function outlineToggle(el: HTMLElement): HTMLButtonElement { return el.querySelector('.blogeditor__outline-toggle')! } function openOutline(el: HTMLElement): HTMLElement { click(outlineToggle(el)) return el.querySelector('.blogeditor__outline-panel')! } describe('options', () => { it('reads headingLevels and genericAnchors with defaults', () => { expect(readOptions(null)).toEqual({ headingLevels: [2, 3, 4], genericAnchors: expect.arrayContaining(['click here', 'czytaj więcej']) }) expect(readOptions('not json').headingLevels).toEqual([2, 3, 4]) expect(readOptions(JSON.stringify({ headingLevels: [4, 1, 9, 'x', 2.5, 2], genericAnchors: [' Read More! ', 3] }))).toEqual({ headingLevels: [1, 2, 4], genericAnchors: ['read more'], }) }) }) describe('J1 heading levels', () => { it('offers H2 to H4 and parses an H1 as an H2 while the H1 setting is on', () => { const { el, editor } = mount('

Title

x

Small
', { options: ON }) expect(el.querySelector('button[data-command="heading1"]')).toBeNull() expect(button(el, 'heading2')).toBeTruthy() const first = editor.state.doc.firstChild! expect(first.type.name).toBe('heading') expect(first.attrs.level).toBe(2) // Untouched, the value is the exact string (D-03). expect(el.value).toBe('

Title

x

Small
') insertAtBlock(editor, 1, 'y') expect(el.value).toBe('

Title

yx

Small
') insertAtBlock(editor, 0, 'A ') expect(el.value).toBe('

A Title

yx

Small
') expect(editor.can().toggleHeading({ level: 1 as 2 })).toBe(false) }) it('offers H1 to H4 and keeps an H1 while the H1 setting is off', () => { const { el, editor } = mount('

Title

', { options: OFF }) const commands = Array.from(el.querySelectorAll('.blogeditor__group button')).map((b) => b.dataset.command) expect(commands.slice(0, 5)).toEqual(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4']) expect(button(el, 'heading1').getAttribute('aria-label')).toBe('Heading 1') insertAtBlock(editor, 0, 'A ') expect(el.value).toBe('

A Title

') }) }) describe('J2 outline', () => { it('starts collapsed, never takes focus and counts headings and problems', () => { const { el } = mount('

Deep

Only link

click here

', { options: ON }) const toggle = outlineToggle(el) expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(document.activeElement).not.toBe(toggle) expect(el.querySelector('.blogeditor__outline-panel')!.hidden).toBe(true) expect(toggle.textContent).toBe('Outline · 3 headings4 problems') const panel = openOutline(el) expect(toggle.getAttribute('aria-expanded')).toBe('true') const items = Array.from(panel.querySelectorAll('li')) expect(items.map((li) => li.querySelector('.blogeditor__outline-level')!.textContent)).toEqual(['H3', 'H2', 'H2']) expect(items[0]!.textContent).toContain('Skipped level: H1 → H3') expect(items[1]!.textContent).toContain('Empty heading') expect(items[2]!.textContent).toContain('Heading is only a link') expect(items[0]!.style.getPropertyValue('--blogeditor-outline-depth')).toBe('1') expect(panel.querySelector('.blogeditor__outline-links')!.textContent).toBe('LinksLink text “click here” does not say where it leads') }) it('shows outlineEmpty without headings and uses Polish plurals', () => { const { el } = mount('

Tekst

', { options: ON, 'ui-locale': 'pl' }) expect(outlineToggle(el).textContent).toBe('Konspekt · 0 nagłówków') expect(openOutline(el).textContent).toBe('Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.') const pl = labelsFor('pl') expect(plural(pl, pl.outlineCount, 1)).toBe('1 nagłówek') expect(plural(pl, pl.outlineCount, 3)).toBe('3 nagłówki') expect(plural(pl, pl.outlineCount, 5)).toBe('5 nagłówków') expect(plural(pl, pl.outlineProblems, 22)).toBe('22 problemy') }) it('recomputes 300 ms after a change and flags an H1 in the Source tab while the setting is on', () => { vi.useFakeTimers() const { el, editor, changes } = mount('

One

', { options: ON }) openOutline(el) insertAtBlock(editor, 0, 'x') expect(outlineToggle(el).textContent).toBe('Outline · 1 heading') el.showSource(true) fill(el.querySelector('textarea'), '

Top

Two

') vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 1) expect(outlineToggle(el).textContent).toBe('Outline · 1 heading') vi.advanceTimersByTime(1) expect(outlineToggle(el).textContent).toBe('Outline · 2 headings1 problem') expect(el.querySelector('.blogeditor__outline-panel li')!.textContent).toContain('H1 in the text: saved as H2') // The outline never edits: only the two user edits changed the value. expect(changes).toHaveLength(2) }) it('does not flag an H1 while the setting is off', () => { const { el } = mount('

Top

Two

', { options: OFF }) el.showSource(true) expect(outlineToggle(el).textContent).toBe('Outline · 2 headings') }) }) describe('J3 link form', () => { it('offers rel only for absolute http(s) URLs and writes the checked tokens', () => { const { el, editor } = mount('

Partner

', { options: ON }) editor.commands.setTextSelection({ from: 1, to: 8 }) click(button(el, 'link')) const linkForm = form(el, 'link')! const box = (token: string) => linkForm.querySelector(`input[name="rel-${token}"]`)! expect(linkForm.querySelector('legend')!.textContent).toBe('Link attributes') expect(box('nofollow').disabled).toBe(true) expect(linkForm.querySelector('.blogeditor__hint')!.hidden).toBe(false) fill(linkForm.querySelector('input[name="url"]'), 'https://partner.test/') expect(box('nofollow').disabled).toBe(false) expect(linkForm.querySelector('.blogeditor__hint')!.hidden).toBe(true) box('nofollow').checked = true box('sponsored').checked = true click(linkForm.querySelector('[data-action="apply"]')) expect(el.value).toBe('

Partner

') editor.commands.setTextSelection(3) click(button(el, 'link')) const again = form(el, 'link')! expect(again.querySelector('input[name="rel-sponsored"]')!.checked).toBe(true) fill(again.querySelector('input[name="url"]'), '/blog/partner') click(again.querySelector('[data-action="apply"]')) expect(el.value).toBe('

Partner

') }) it('warns about a generic link text without blocking Apply', () => { const { el, editor } = mount('

Click here!

', { options: ON }) editor.commands.setTextSelection({ from: 1, to: 12 }) click(button(el, 'link')) const linkForm = form(el, 'link')! const warning = linkForm.querySelector('[data-warning]')! expect(warning.hidden).toBe(false) expect(warning.getAttribute('aria-live')).toBe('polite') expect(warning.textContent).toBe('“Click here!” does not say where the link leads. Use words that describe the target page.') fill(linkForm.querySelector('input[name="url"]'), '/blog/x') click(linkForm.querySelector('[data-action="apply"]')) expect(el.value).toBe('

Click here!

') }) it('treats only absolute http(s) URLs as external', () => { for (const url of ['https://a.test', 'HTTP://a.test/x']) expect(isExternalUrl(url), url).toBe(true) for (const url of ['/a', 'a.test', 'mailto:a@b.test', '//a.test', 'https://', '#x']) expect(isExternalUrl(url), url).toBe(false) expect(normalizeAnchor(' Read MORE… ')).toBe('read more') }) }) describe('J4 image naming', () => { const pick = (el: HTMLElement, files: File[]) => { const input = el.querySelector('input[type="file"]')! Object.defineProperty(input, 'files', { value: files, configurable: true }) input.dispatchEvent(new Event('change')) } const answer = (el: HTMLElement, detail: Record) => el.dispatchEvent(new CustomEvent('summer-upload-result', { detail })) it('slugs the file name before the upload (UI-SPEC J4 backstop)', () => { expect(mediaFileName('Lwia Grzywa – w lesie!.JPG')).toBe('lwia-grzywa-w-lesie.jpg') expect(mediaFileName('.png')).toBe('image.png') expect(slugFileStem('')).toBe('image') expect(slugFileStem('ŻÓŁĆ gęślą jaźń')).toBe('zolc-gesla-jazn') expect(slugFileStem('a'.repeat(81))).toHaveLength(80) expect(slugFileStem('a'.repeat(79) + ' b')).toBe('a'.repeat(79)) const { el, uploads } = mount('', { options: ON }) pick(el, [new File([new Uint8Array([255, 216, 255])], 'Lwia Grzywa – w lesie!.JPG', { type: 'image/jpeg' })]) const imageForm = form(el, 'image')! const name = imageForm.querySelector('input[name="fileName"]')! expect(name.value).toBe('lwia-grzywa-w-lesie') expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.jpg') fill(name, 'Lew w Lesie') name.dispatchEvent(new Event('blur')) expect(name.value).toBe('lew-w-lesie') chooseImage(el, { alt: 'Lew' }) expect(uploads[0]!.file.name).toBe('lew-w-lesie.jpg') expect(uploads[0]!.file.type).toBe('image/jpeg') expect(status(el)).toBe('Uploading lew-w-lesie.jpg…') }) it('inserts alt="" for a decorative image', () => { const { el, uploads } = mount('', { options: ON }) pick(el, [new File([new Uint8Array([137, 80, 78, 71])], 'kreska.png', { type: 'image/png' })]) const imageForm = form(el, 'image')! const alt = imageForm.querySelector('input[name="alt"]')! fill(alt, 'temporary') chooseImage(el, { decorative: true }) expect(alt.value).toBe('') expect(alt.disabled).toBe(true) answer(el, { id: uploads[0]!.id, url: '/kreska.png' }) expect(el.value).toBe('

') }) })