diff --git a/tests/blogeditor/forms.test.ts b/tests/blogeditor/forms.test.ts new file mode 100644 index 0000000..f830a3a --- /dev/null +++ b/tests/blogeditor/forms.test.ts @@ -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(`

${text}

`, { 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(`input[name="rel-${token}"]`)! + return { ...m, linkForm, box, url: linkForm.querySelector('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(/^

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('

Partner

') + }) +}) + +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('[data-warning]')!.hidden).toBe(false) + fill(generic.url, '/blog/a') + click(generic.linkForm.querySelector('[data-action="apply"]')) + expect(generic.el.value).toBe('

Czytaj więcej…

') + document.body.replaceChildren() + const specific = openLink('Badania nad chagą') + expect(specific.linkForm.querySelector('[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('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('input[name="alt"]')! + const decorative = imageForm.querySelector('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' }) + }) +}) diff --git a/tests/blogeditor/outline.test.ts b/tests/blogeditor/outline.test.ts new file mode 100644 index 0000000..b9fa900 --- /dev/null +++ b/tests/blogeditor/outline.test.ts @@ -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('

Intro text

Only links

See click here and

') + 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('

Intro

All link

click here more

', { 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('

Top

Skip

Link

Deep

') + + 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('

One

', { options: OFF }) + const toggle = el.querySelector('.blogeditor__outline-toggle')! + insertAtBlock(editor, 0, 'a') + vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS) + el.showSource(true) + fill(el.querySelector('textarea'), '

One

Two

Three

') + vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 50) + expect(toggle.textContent).toBe('Outline · 1 heading') + fill(el.querySelector('textarea'), '

One

Two

') + 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) + }) +})