test: editor outline facts, flags and debounce; link and image form rules

- outline: facts from the editor and the Source tab, skipped, empty and
  link-only headings, the H1 flag only in the Source tab while level 1 is
  not offered, generic texts, the 300 ms debounce without edits
- link form: rel follows the URL as typed, only checked tokens written,
  none for internal links, the generic warning never blocks
- image form: the slug rule examples, decorative clears and locks alt
This commit is contained in:
Jakub Zych
2026-11-04 19:55:57 +01:00
parent e7832a8d26
commit 311c28af3a
2 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform'
import { isExternalUrl } from '../../assets/src/blogeditor/linkform'
import { button, chooseImage, click, fill, form, mount } from './helpers'
// J3 link form and J4 image form (UI-SPEC J3, J4): the rel checkboxes
// follow the URL as it is typed, the generic-text warning comes and goes
// without blocking, the file-name slug rule's examples, and a decorative
// image clears and locks its alt. The first cases are in additions.test.ts.
afterEach(() => {
document.body.replaceChildren()
})
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'czytaj więcej'] })
function openLink(text: string) {
const m = mount(`<p>${text}</p>`, { options: ON })
m.editor.commands.setTextSelection({ from: 1, to: 1 + text.length })
click(button(m.el, 'link'))
const linkForm = form(m.el, 'link')!
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
return { ...m, linkForm, box, url: linkForm.querySelector<HTMLInputElement>('input[name="url"]')! }
}
describe('J3 rel checkboxes', () => {
it('enable for an absolute http(s) URL and disable again for an internal one', () => {
const { box, url } = openLink('Partner')
for (const token of ['nofollow', 'sponsored', 'ugc']) expect(box(token).disabled, token).toBe(true)
fill(url, 'https://partner.test')
for (const token of ['nofollow', 'sponsored', 'ugc']) expect(box(token).disabled, token).toBe(false)
box('ugc').checked = true
fill(url, '/blog/partner')
expect(box('ugc').disabled).toBe(true)
})
it('writes only the checked tokens, in order, for an external link', () => {
const { el, box, url, linkForm } = openLink('Partner')
fill(url, 'https://partner.test/x')
box('ugc').checked = true
box('nofollow').checked = true
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toMatch(/^<p><a rel="nofollow ugc" href="https:\/\/partner\.test\/x">Partner<\/a><\/p>$/)
})
it('writes no rel for an internal link even when a box was checked', () => {
const { el, box, url, linkForm } = openLink('Partner')
fill(url, 'https://partner.test')
box('sponsored').checked = true
fill(url, '/oferta')
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="/oferta">Partner</a></p>')
})
})
describe('J3 generic link text', () => {
it('warns for a listed text in any case or punctuation and stays quiet otherwise', () => {
const generic = openLink('Czytaj więcej…')
expect(generic.linkForm.querySelector<HTMLElement>('[data-warning]')!.hidden).toBe(false)
fill(generic.url, '/blog/a')
click(generic.linkForm.querySelector('[data-action="apply"]'))
expect(generic.el.value).toBe('<p><a href="/blog/a">Czytaj więcej…</a></p>')
document.body.replaceChildren()
const specific = openLink('Badania nad chagą')
expect(specific.linkForm.querySelector<HTMLElement>('[data-warning]')!.hidden).toBe(true)
})
it('reads external URLs by scheme only', () => {
for (const [u, want] of [
['https://a.test/x?y=1', true],
['http://a.test', true],
['/kontakt', false],
['./a', false],
['ftp://a.test', false],
['javascript:alert(1)', false],
['', false],
] as const) {
expect(isExternalUrl(u), u).toBe(want)
}
})
})
describe('J4 file names', () => {
it('follows the slug rule examples', () => {
for (const [name, want] of [
['Chaga – zdjęcie 1.JPEG', 'chaga-zdjecie-1.jpeg'],
[' Kurki w koszyku .png', 'kurki-w-koszyku.png'],
['IMG_2024.06.01.webp', 'img-2024-06-01.webp'],
['___.gif', 'image.gif'],
['bez-rozszerzenia', 'bez-rozszerzenia'],
] as const) {
expect(mediaFileName(name), name).toBe(want)
}
expect(slugFileStem('Łódź & Kraków')).toBe('lodz-krakow')
expect(slugFileStem('--a--b--')).toBe('a-b')
})
it('clears and locks the alt of a decorative image and frees it again', () => {
const { el } = mount('', { options: ON })
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', { value: [new File([new Uint8Array([137, 80, 78, 71])], 'linia.png', { type: 'image/png' })], configurable: true })
input.dispatchEvent(new Event('change'))
const imageForm = form(el, 'image')!
const alt = imageForm.querySelector<HTMLInputElement>('input[name="alt"]')!
const decorative = imageForm.querySelector<HTMLInputElement>('input[name="decorative"]')!
fill(alt, 'Linia')
decorative.checked = true
decorative.dispatchEvent(new Event('change'))
expect(alt.value).toBe('')
expect(alt.disabled).toBe(true)
decorative.checked = false
decorative.dispatchEvent(new Event('change'))
expect(alt.disabled).toBe(false)
chooseImage(el, { alt: 'Linia' })
})
})

View File

@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { labelsFor } from '../../assets/src/blogeditor/labels'
import { factsFromEditor, factsFromSource, headingFlags, isGenericAnchor, normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline'
import { fill, insertAtBlock, mount } from './helpers'
// J2 outline (UI-SPEC J2): the facts read from the editor and from the
// Source tab, the flags per heading and the debounce. The rendered panel
// is in additions.test.ts.
afterEach(() => {
vi.useRealTimers()
document.body.replaceChildren()
})
const en = labelsFor('en')
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more'] })
const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] })
describe('outline facts', () => {
it('reads headings and link texts from HTML source', () => {
const facts = factsFromSource('<h2> Intro text </h2><h4><a href="/a">Only</a> <a href="/b">links</a></h4><h3></h3><p>See <a href="/c">click here</a> and <a href="/d"> </a></p>')
expect(facts.headings).toEqual([
{ level: 2, text: 'Intro text', linkOnly: false, pos: -1 },
{ level: 4, text: 'Only links', linkOnly: true, pos: -1 },
{ level: 3, text: '', linkOnly: false, pos: -1 },
])
expect(facts.links).toEqual(['Only', 'links', 'click here'])
})
it('reads the same facts from the editor document, adjacent marks of one link joined', () => {
const { editor } = mount('<h2>Intro</h2><h3><a href="/x">All link</a></h3><p><a href="/y">click </a><a href="/y"><strong>here</strong></a> <a href="/z">more</a></p>', { options: ON })
const facts = factsFromEditor(editor)
expect(facts.headings.map((h) => [h.level, h.text, h.linkOnly])).toEqual([
[2, 'Intro', false],
[3, 'All link', true],
])
expect(facts.headings.every((h) => h.pos >= 0)).toBe(true)
// A link inside a heading is a link text too.
expect(facts.links).toEqual(['All link', 'click here', 'more'])
})
})
describe('heading flags', () => {
const facts = factsFromSource('<h1>Top</h1><h3>Skip</h3><h2></h2><h2><a href="/x">Link</a></h2><h4>Deep</h4>')
it('flags skipped levels from the page title H1, empty and link-only headings', () => {
const flags = headingFlags(facts, en, { allowsH1: true, source: true })
expect(flags[0]).toEqual([])
expect(flags[1]).toEqual(['Skipped level: H1 → H3'])
expect(flags[2]).toEqual([en.flagEmpty])
expect(flags[3]).toEqual([en.flagLinkOnly])
expect(flags[4]).toEqual(['Skipped level: H2 → H4'])
})
it('flags an H1 only in the Source tab while level 1 is not offered', () => {
expect(headingFlags(facts, en, { allowsH1: false, source: true })[0]).toEqual([en.flagH1])
expect(headingFlags(facts, en, { allowsH1: false, source: false })[0]).toEqual([])
expect(headingFlags(facts, en, { allowsH1: true, source: true })[0]).toEqual([])
expect(headingFlags({ headings: [], links: [] }, en, { allowsH1: false, source: true })).toEqual([])
})
})
describe('generic link texts', () => {
it('normalises and matches the generic list', () => {
expect(normalizeAnchor(' Kliknij TUTAJ!!! ')).toBe('kliknij tutaj')
expect(normalizeAnchor('»Więcej«')).toBe('więcej')
expect(isGenericAnchor('Click here.', ['click here'])).toBe(true)
expect(isGenericAnchor('Click here to read the study', ['click here'])).toBe(false)
expect(isGenericAnchor('!!!', [''])).toBe(false)
})
})
describe('outline timing', () => {
it('recomputes the Source tab facts only after the 300 ms debounce and never edits the value', () => {
vi.useFakeTimers()
expect(OUTLINE_DEBOUNCE_MS).toBe(300)
const { el, editor, changes } = mount('<h2>One</h2>', { options: OFF })
const toggle = el.querySelector<HTMLButtonElement>('.blogeditor__outline-toggle')!
insertAtBlock(editor, 0, 'a')
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS)
el.showSource(true)
fill(el.querySelector('textarea'), '<h2>One</h2><h2>Two</h2><h2>Three</h2>')
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 50)
expect(toggle.textContent).toBe('Outline · 1 heading')
fill(el.querySelector('textarea'), '<h2>One</h2><h2>Two</h2>')
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 50)
expect(toggle.textContent).toBe('Outline · 1 heading')
vi.advanceTimersByTime(50)
expect(toggle.textContent).toBe('Outline · 2 headings')
expect(changes).toHaveLength(3)
})
})