diff --git a/tests/block.test.ts b/tests/block.test.ts new file mode 100644 index 0000000..9978ddd --- /dev/null +++ b/tests/block.test.ts @@ -0,0 +1,120 @@ +// FaqBlock (D-52, UI-SPEC L6) rendered on the server: the SFC is compiled +// with vue/compiler-sfc and rendered with vue/server-renderer, with the +// Nuxt composables it calls (useFaq, useSeoAnswer, useRequestURL, +// useSchemaNode) stubbed on globalThis and FaqList stubbed as a component. +// headingLevel picks the title's element (0 a

), the section stays +// labelled by the title, and the FAQPage node it hands to the page graph +// carries the answers as plain text. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { after, test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { compileScript, parse } from 'vue/compiler-sfc' +import { createSSRApp, h, ref, type Component } from 'vue' +import { renderToString } from 'vue/server-renderer' + +const root = new URL('..', import.meta.url) +const dir = mkdtempSync(join(tmpdir(), 'faq-block-')) +after(() => rmSync(dir, { recursive: true, force: true })) + +async function loadBlock(): Promise { + const source = readFileSync(new URL('app/components/Faq/Block.vue', root), 'utf8') + const { descriptor } = parse(source, { filename: 'Block.vue' }) + const script = compileScript(descriptor, { id: 'faq-block', inlineTemplate: true, templateOptions: { ssr: true } }) + const code = script.content + .replaceAll(`from 'vue'`, `from '${import.meta.resolve('vue')}'`) + .replaceAll(`from "vue"`, `from '${import.meta.resolve('vue')}'`) + .replaceAll(`from 'vue/server-renderer'`, `from '${import.meta.resolve('vue/server-renderer')}'`) + .replaceAll(`from "vue/server-renderer"`, `from '${import.meta.resolve('vue/server-renderer')}'`) + .replaceAll(`'../../utils/faqSchema.ts'`, `'${new URL('app/utils/faqSchema.ts', root).href}'`) + const file = join(dir, 'Block.ts') + writeFileSync(file, code) + return (await import(pathToFileURL(file).href)).default as Component +} + +const faq = { + id: 1, + slug: 'grzyby', + name: 'Pytania o grzyby', + description: '

Najczęstsze pytania

', + content: [ + { question: ' Czy chaga jest bezpieczna? ', answer: '

Tak, w umiarkowanych dawkach.

' }, + { question: 'Jak parzyć?', answer: '' }, + ], +} + +const g = globalThis as Record +let nodes: unknown[] = [] + +function stub(data: unknown, error: unknown = null) { + nodes = [] + Object.assign(g, { + useFaq: async () => ({ data: ref(data), error: ref(error) }), + useSeoAnswer: () => ref({ action: 'ok', url: 'https://example.test/faq#top' }), + useRequestURL: () => new URL('https://fallback.test/page'), + useSchemaNode: (fn: () => unknown) => nodes.push(fn()), + }) +} + +const FaqList: Component = { props: ['items', 'exclusive'], setup: (p: { items: unknown[] }) => () => h('div', { class: 'faq-list' }, String(p.items.length)) } +const Block = await loadBlock() + +async function render(props: Record): Promise { + const app = createSSRApp({ render: () => h(Block, { slug: 'grzyby', ...props }, { fallback: () => h('em', 'fallback') }) }) + app.component('FaqList', FaqList) + return renderToString(app) +} + +test('headingLevel 2 to 4 renders that heading, out-of-range levels are clamped', async () => { + stub(faq) + for (const [level, tag] of [[2, 'h2'], [3, 'h3'], [4, 'h4'], [6, 'h4'], [1, 'h2']] as const) { + const html = await render({ headingLevel: level }) + assert.match(html, new RegExp(`<${tag} id="([^"]+)" class="faq-block__title">Pytania o grzyby`), `level ${level}`) + } +}) + +test('headingLevel 0 renders the title as a

that still labels the section', async () => { + stub(faq) + const html = await render({ headingLevel: 0 }) + const title = html.match(/

Pytania o grzyby<\/p>/) + assert.ok(title, html) + assert.match(html, new RegExp(`

{ + stub(faq) + const html = await render({ showHeading: false }) + assert.match(html, /
{ + stub(faq) + await render({}) + assert.equal(nodes.length, 1) + assert.deepEqual(nodes[0], { + '@type': 'FAQPage', + '@id': 'https://example.test/faq#faq', + url: 'https://example.test/faq', + mainEntity: [ + { '@type': 'Question', name: 'Czy chaga jest bezpieczna?', acceptedAnswer: { '@type': 'Answer', text: 'Tak, w umiarkowanych dawkach.' } }, + { '@type': 'Question', name: 'Jak parzyć?', acceptedAnswer: { '@type': 'Answer', text: 'Woda 80°C & 5 min' } }, + ], + }) +}) + +test('schema false, a missing FAQ or one without items: no node; the fallback slot instead of an empty block', async () => { + stub(faq) + await render({ schema: false }) + assert.deepEqual(nodes, [null]) + stub(null, new Error('404')) + const missing = await render({}) + assert.match(missing, /fallback<\/em>/) + assert.doesNotMatch(missing, /faq-block/) + assert.deepEqual(nodes, [null]) + stub({ ...faq, content: [] }) + assert.match(await render({}), /fallback<\/em>/) +})