import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' // Kachel „Öffnungszeiten live“ in public/funktionen.js (Spec 3.1, Ruling R20). Die Kachel steht inline in funktionen.js (keine neuen Importe, // test/termineView.test.js und test/termineFragebogenView.test.js zählen sie) und macht keine Abfrage über /api/funktionen hinaus. // Eigener Loader: ALLE Importe von funktionen.js (absolut /x.js oder relativ ./x.js) zeigen auf die Dateien in public/, damit ein künftiger // Import die Tests nicht stillschweigend bricht. const dir = mkdtempSync(join(tmpdir(), 'fn-zeiten-tile-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const quelle = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${fileUrl(name)}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) const { initFunktionen } = await import(pathToFileURL(file).href) // ---------------------------------------------------------------------------------------------------------------- Hilfen const zeitenFeature = (state, extra = {}) => ({ state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null, unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra, }) const projekt = (id, state = 'off', feature = {}, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) }, analyse: { comingSoon: true }, zeitenStatus: null, zeitenLeer: true, ...extra, }) const ANGEBOT = (inklusive = true, grund = null) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Haken', haken: true }] }, leistungsbeschreibung: { version: 'v', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } }, }) async function render(projects, extra = {}) { const aufrufe = [] globalThis.fetch = async (url) => { aufrufe.push(String(url)) return { ok: true, status: 200, json: async () => ({ projects, ...extra }) } } const root = { innerHTML: '', querySelector: () => null } await initFunktionen(root) return { html: root.innerHTML, aufrufe } } const kachel = (html) => { const von = html.indexOf('data-fn-tile="zeiten"') assert.ok(von >= 0, 'die Kachel fehlt') return html.slice(von, html.indexOf('', von)) } const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() const knoepfe = (html) => [...html.matchAll(/]*>([^<]*)<\/button>/g)].map((m) => m[1].trim()) // ---------------------------------------------------------------------------------------------------------------- Tests test('ohne verfuegbar keine Kachel', async () => { const P = [projekt('a')] const ohne = (await render(P)).html assert.doesNotMatch(ohne, /data-fn-tile="zeiten"/) assert.doesNotMatch(ohne, /Öffnungszeiten/) const aus = (await render(P, { zeiten: { verfuegbar: false } })).html assert.equal(aus, ohne, 'verfuegbar: false sieht aus wie ein Server ohne Zeiten') assert.equal(ohne.match(/Kommt bald/g).length, 2, 'Terminplaner und Analyse, wie bisher') // auch ein beschädigtes Angebot macht keine Kachel for (const kaputt of [null, 'ja', { verfuegbar: 'ja' }, { inklusive: true }]) assert.doesNotMatch((await render(P, { zeiten: kaputt })).html, /data-fn-tile="zeiten"/, JSON.stringify(kaputt)) }) test('Kachel steht zwischen Terminplaner und Analyse, mit Angebot genau eine zusätzliche Kachel', async () => { const { html } = await render([projekt('a')], { zeiten: ANGEBOT(true) }) const stellen = ['data-fn-tile="news"', 'Terminplaner', 'data-fn-tile="zeiten"', 'Analyse'].map((s) => html.indexOf(s)) assert.ok(stellen.every((s) => s >= 0), stellen.join()) assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y)) assert.equal(html.match(/class="fn-tile[ "]/g).length, 4, 'News, Terminplaner (Kommt bald), Zeiten, Analyse (Kommt bald)') assert.equal(html.match(/Kommt bald/g).length, 2) assert.match(kachel(html), /

Öffnungszeiten live<\/h3>/) assert.match(kachel(html), / { const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(true) })).html) assert.match(nurText(k), /Im Wartungs-Abo enthalten/) assert.match(k, /fn-pill fn-pill-off">AusEinschalten<\/button>/) assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig/) }) test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', async () => { const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, 'nicht_aktiv') })).html) assert.match(nurText(k), /Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist/) assert.deepEqual(knoepfe(k), []) assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig|31 €' } })).html) assert.doesNotMatch(bose, //) // ohne Preis im Angebot gilt der Rückfall 30 € assert.match(nurText(kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '' } })).html)), /30 € einmalig/) }) test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', async () => { const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(null, null) })).html) assert.match(nurText(k), /Gerade nicht prüfbar – bitte später erneut/) assert.deepEqual(knoepfe(k), []) assert.doesNotMatch(k, /30 €|Im Wartungs-Abo enthalten/) }) test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', async () => { const zeigen = async (feature, extra) => kachel((await render([projekt('p1', 'setup', feature, extra)], { zeiten: ANGEBOT(true) })).html) const angestossen = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: false }) assert.match(angestossen, /fn-pill fn-pill-setup">In Einrichtung { const status = { offen: true, kurz: 'Geöffnet', text: 'Jetzt geöffnet · schließt um 18 Uhr' } const { html, aufrufe } = await render([projekt('p1', 'active', { einbau: 'angestossen' }, { zeitenStatus: status, zeitenLeer: false })], { zeiten: ANGEBOT(true) }) assert.deepEqual(aufrufe, ['/api/funktionen'], 'der Status kommt in der Liste, die Kachel fragt nichts nach') const k = kachel(html) assert.match(k, /fn-pill fn-pill-on">Aktiv' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html) assert.doesNotMatch(bose, / { const wahl = async (liste, angebot = ANGEBOT(true)) => kachel((await render(liste, { zeiten: angebot })).html).match(/data-project="([^"]+)"/)?.[1] assert.equal(await wahl([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })]), 'b') assert.equal(await wahl([projekt('a'), projekt('b', 'setup'), projekt('c', 'disabled')]), 'b') assert.equal(await wahl([projekt('a'), projekt('b', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], ANGEBOT(false, 'kein_abo')), 'b') assert.equal(await wahl([projekt('a'), projekt('b')]), 'a') assert.match(nurText(kachel((await render([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)), /aktiv für 1 von 2 Websites/) // ohne Website: ein Satz statt eines Knopfs const keine = kachel((await render([], { zeiten: ANGEBOT(true) })).html) assert.deepEqual(knoepfe(keine), []) assert.match(nurText(keine), /Sobald deine Website angelegt ist/) }) test('aboEndeAt: Behalten-Hinweis mit Datum', async () => { const zeigen = async (feature, angebot = ANGEBOT(false, 'kein_abo'), state = 'active') => kachel((await render([projekt('p1', state, feature, { zeitenLeer: false })], { zeiten: angebot })).html) const k = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }) assert.match(nurText(k), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €/) assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten', 'Für 30 € behalten']) assert.match(k, /