- the blogeditor and mlblogeditor types serve headingLevels and genericAnchors through RequestOptions, per schema request - J1: H2 to H4 with a pasted or loaded H1 parsed as H2 while the H1 setting is on, H1 to H4 while it is off; H5 and H6 still parse - J2: the outline bar and panel (skipped, empty, link-only, H1 in Source, generic link texts), 300 ms after a change, collapsed, read-only - J3: nofollow/sponsored/ugc checkboxes for absolute http(s) links only, and a non-blocking warning for a generic link text - J4: one form per picked file with the slugged file name, alt required unless decorative (alt=""); Insert uploads the renamed file and the image is inserted on the upload result - assets/dist rebuilt
210 lines
11 KiB
TypeScript
210 lines
11 KiB
TypeScript
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<HTMLButtonElement>('.blogeditor__outline-toggle')!
|
||
}
|
||
|
||
function openOutline(el: HTMLElement): HTMLElement {
|
||
click(outlineToggle(el))
|
||
return el.querySelector<HTMLElement>('.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('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>', { 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('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>')
|
||
insertAtBlock(editor, 1, 'y')
|
||
expect(el.value).toBe('<h1 class="lead">Title</h1><p>yx</p><h5>Small</h5>')
|
||
insertAtBlock(editor, 0, 'A ')
|
||
expect(el.value).toBe('<h2 class="lead">A Title</h2><p>yx</p><h5>Small</h5>')
|
||
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('<h1>Title</h1>', { options: OFF })
|
||
const commands = Array.from(el.querySelectorAll<HTMLButtonElement>('.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('<h1>A Title</h1>')
|
||
})
|
||
})
|
||
|
||
describe('J2 outline', () => {
|
||
it('starts collapsed, never takes focus and counts headings and problems', () => {
|
||
const { el } = mount('<h3>Deep</h3><h2></h2><h2><a href="/x">Only link</a></h2><p><a href="/y">click here</a></p>', { options: ON })
|
||
const toggle = outlineToggle(el)
|
||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||
expect(document.activeElement).not.toBe(toggle)
|
||
expect(el.querySelector<HTMLElement>('.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('<p>Tekst</p>', { 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('<h2>One</h2>', { options: ON })
|
||
openOutline(el)
|
||
insertAtBlock(editor, 0, 'x')
|
||
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
|
||
el.showSource(true)
|
||
fill(el.querySelector('textarea'), '<h1>Top</h1><h2>Two</h2>')
|
||
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('<h1>Top</h1><h2>Two</h2>', { 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('<p>Partner</p>', { options: ON })
|
||
editor.commands.setTextSelection({ from: 1, to: 8 })
|
||
click(button(el, 'link'))
|
||
const linkForm = form(el, 'link')!
|
||
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
|
||
expect(linkForm.querySelector('legend')!.textContent).toBe('Link attributes')
|
||
expect(box('nofollow').disabled).toBe(true)
|
||
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(false)
|
||
fill(linkForm.querySelector('input[name="url"]'), 'https://partner.test/')
|
||
expect(box('nofollow').disabled).toBe(false)
|
||
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(true)
|
||
box('nofollow').checked = true
|
||
box('sponsored').checked = true
|
||
click(linkForm.querySelector('[data-action="apply"]'))
|
||
expect(el.value).toBe('<p><a rel="nofollow sponsored" href="https://partner.test/">Partner</a></p>')
|
||
|
||
editor.commands.setTextSelection(3)
|
||
click(button(el, 'link'))
|
||
const again = form(el, 'link')!
|
||
expect(again.querySelector<HTMLInputElement>('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('<p><a href="/blog/partner">Partner</a></p>')
|
||
})
|
||
|
||
it('warns about a generic link text without blocking Apply', () => {
|
||
const { el, editor } = mount('<p>Click here!</p>', { options: ON })
|
||
editor.commands.setTextSelection({ from: 1, to: 12 })
|
||
click(button(el, 'link'))
|
||
const linkForm = form(el, 'link')!
|
||
const warning = linkForm.querySelector<HTMLElement>('[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('<p><a href="/blog/x">Click here!</a></p>')
|
||
})
|
||
|
||
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<HTMLInputElement>('input[type="file"]')!
|
||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||
input.dispatchEvent(new Event('change'))
|
||
}
|
||
const answer = (el: HTMLElement, detail: Record<string, string>) => 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<HTMLInputElement>('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<HTMLInputElement>('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('<p><img src="/kreska.png" alt=""></p>')
|
||
})
|
||
})
|