Module: im Abo-Modell Reiter mit Kurzlabels an die Etage melden (C2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:37:12 +00:00
1 parent b91ba1d5cb
commit a50c5ef6c9
6 files changed
+366 -17

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { KURZLABELS, meldeReiter, reiterEintraege } from '../public/fn-reiter.js'
import { h } from '../public/termine-dom.js'
import { initTermine } from '../public/termine.js'
import { initShop, REITER as SHOP_REITER, vergissAuswahl as vergissShop } from '../public/shop.js'
import { initBuchungen, REITER as BUCHUNGEN_REITER, vergissAuswahl as vergissBuchungen } from '../public/buchungen.js'
import { hoereAufKn, mitDom, routen } from './support/modulAbo.js'
// Reiter der Funktionsmodule im Abo-Modell (P4 Task 1, Spec 5.2/5.4, C2): keine eigene Leiste, kn:reiter an die Etage, Kurzlabels.
// Ohne modell === 'abo' bleibt die eigene Leiste mit den langen Labels und es gibt kein Ereignis.
test('KURZLABELS: genau diese Einträge (C2)', () => {
assert.deepEqual(KURZLABELS, { termine: { team: 'Team' }, shop: { uebersicht: 'Start', rechtliches: 'Recht' }, buchungen: { unterkuenfte: 'Preise' } })
})
test('reiterEintraege: Kurzlabel nur mit Eintrag, fremde Felder fallen weg', () => {
const liste = [{ key: 'termine', label: 'Termine', render() {} }, { key: 'team', label: 'Team & Plätze', render() {} }]
assert.deepEqual(reiterEintraege('termine', liste), [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }])
assert.deepEqual(reiterEintraege('unbekannt', liste.slice(1)), [{ key: 'team', label: 'Team & Plätze' }])
assert.deepEqual(reiterEintraege('analyse', [{ key: 'heatmap', label: 'Heatmap' }]), [{ key: 'heatmap', label: 'Heatmap' }], 'Analyse: kein Kurzlabel nötig')
assert.deepEqual(reiterEintraege('constructor', [{ key: 'toString', label: 'X' }]), [{ key: 'toString', label: 'X' }], 'keine Treffer aus dem Prototyp')
})
test('meldeReiter: das Ereignis steigt auf und trägt Einträge, aktiv und bei; ohne Element tut es nichts', mitDom(async (fx) => {
const k = hoereAufKn(fx)
const el = h('div')
document.body.append(el)
const bei = () => {}
assert.equal(meldeReiter(el, { eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }), true)
assert.deepEqual(k.reiter, [{ eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }])
assert.equal(meldeReiter(null, { eintraege: [] }), false)
assert.equal(meldeReiter({}, { eintraege: [] }), false, 'ohne dispatchEvent')
}))
// ----------------------------------------------------------------------------------------------------- die drei Module
const projekt = (features) => ({ projectId: 'p1', projectName: 'Mein Laden', features })
const funktionen = (modell, kopf, features) => ({ body: { ...(modell === undefined ? {} : { modell }), projects: [projekt(features)], ...kopf } })
const FAELLE = [
{
name: 'Terminplaner',
init: initTermine,
vorher: () => {},
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { termine: { verfuegbar: true } }, { termine: { state: 'active', siteKey: 'abcdefghijklmnop' } }),
'GET /api/termine/einstellungen?projectId=p1': { body: { state: 'active', einstellung: {}, fehlt: [] } },
}),
alt: ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'],
kurz: ['Termine', 'Leistungen', 'Team', 'Einstellungen'],
},
{
name: 'Webshop',
init: initShop,
vorher: vergissShop,
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { shop: { verfuegbar: true }, kundenart: 'unternehmen' }, { shop: {} }),
'GET /api/shop/status?projectId=p1': { body: { verfuegbar: true, state: 'active', anfrage: null } },
}),
alt: ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches'],
kurz: ['Start', 'Artikel', 'Bestellungen', 'Einstellungen', 'Recht'],
},
{
name: 'Buchungskalender',
init: initBuchungen,
vorher: vergissBuchungen,
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { unterkunft: { verfuegbar: true } }, { unterkunft: { state: 'active' } }),
'GET /api/unterkunft/status?projectId=p1': { body: { state: 'active' } },
}),
alt: ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen'],
kurz: ['Anfragen', 'Kalender', 'Preise', 'Rechnungen', 'Einstellungen'],
},
]
async function oeffne(fx, f, modell) {
f.vorher?.()
routen(fx, f.tabelle(modell))
const root = h('div')
document.body.append(root)
await f.init(root, { projectId: 'p1' })
await fx.ruhe()
return root
}
for (const f of FAELLE) {
test(`${f.name}, Abo: keine eigene Leiste, kn:reiter mit Kurzlabels, bei() wechselt`, mitDom(async (fx) => {
const k = hoereAufKn(fx)
const root = await oeffne(fx, f, 'abo')
assert.equal(fx.finde(root, '[role="tablist"]'), null)
assert.equal(fx.finde(root, '[role="tab"]'), null)
const panel = fx.finde(root, '[role="tabpanel"]')
assert.ok(panel, 'das Panel bleibt')
assert.equal(panel.hasAttribute('aria-labelledby'), false)
assert.ok(panel.getAttribute('aria-label'), 'das Panel trägt aria-label')
const erst = k.reiter.at(-1)
assert.deepEqual(erst.eintraege.map((e) => e.label), f.alt)
assert.deepEqual(erst.eintraege.map((e) => e.kurz ?? e.label), f.kurz)
const andere = erst.eintraege.find((e) => e.key !== erst.aktiv).key
erst.bei(andere)
await fx.ruhe()
assert.equal(k.reiter.at(-1).aktiv, andere)
assert.equal(panel.getAttribute('aria-label'), erst.eintraege.find((e) => e.key === andere).label)
}))
test(`${f.name}, ohne modell (fehlt, „Abo“, „abo “): eigene Leiste mit langen Labels, kein Ereignis`, mitDom(async (fx) => {
for (const modell of [undefined, 'Abo', 'abo ']) {
const k = hoereAufKn(fx)
const root = await oeffne(fx, f, modell)
assert.deepEqual(fx.alle(root, '[role="tab"]').map((t) => fx.text(t)), f.alt, String(modell))
assert.ok(fx.finde(root, '[role="tablist"]'))
assert.equal(k.reiter.length, 0, String(modell))
}
}))
}
test('die langen Labels der Reiterlisten bleiben unverändert (Pins buchungenUi und shopView)', () => {
assert.deepEqual(SHOP_REITER.map((r) => r.label), FAELLE[1].alt)
assert.deepEqual(BUCHUNGEN_REITER.map((r) => r.label), FAELLE[2].alt)
})
test('Webshop im Abo-Modell, Zustand off: die Reiterliste ist leer', mitDom(async (fx) => {
const k = hoereAufKn(fx)
vergissShop()
routen(fx, {
'GET /api/funktionen': funktionen('abo', { shop: { verfuegbar: true }, kundenart: 'unternehmen' }, { shop: { state: 'off' } }),
'GET /api/shop/status?projectId=p1': { body: { verfuegbar: true, state: 'off', anfrage: null } },
})
const root = h('div')
document.body.append(root)
await initShop(root, { projectId: 'p1' })
await fx.ruhe()
assert.ok(k.reiter.length > 0, 'die Ansicht meldet sich')
assert.deepEqual(k.reiter.at(-1).eintraege, [])
}))
+148
View File
@@ -0,0 +1,148 @@
// Testhilfen fuer die Funktionsmodule im Abo-Modell (P4 „Funktionsmodule“): Fake-DOM mit CustomEvent, Antworten nach Adresse, Attrappe fuer den
// Einschalt-Ablauf (zeigeEinschalten aus public/neu/funktion-einschalten.js), Wurzeln fuer die innerHTML-Module (News, Analyse).
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { installFakeDom } from './fakeDom.js'
/** Wörter, die im Abo-Modell auf keiner Oberfläche der Funktionen stehen dürfen (Spec: keine Preise für Funktionen). */
export const KEIN_PREIS = /€|einmalig|Preis|Zahlungspflichtig|Bestellseite|Behalten/
/** CustomEvent fuer fakeDom: type, detail, bubbles, target, ergebnisse, preventDefault, stopPropagation. */
class FakeCustomEvent {
constructor(type, init = {}) {
this.type = type
this.detail = init.detail ?? null
this.bubbles = init.bubbles === true
this.target = null
this.currentTarget = null
this.defaultPrevented = false
this.stopped = false
this.ergebnisse = []
}
preventDefault() { this.defaultPrevented = true }
stopPropagation() { this.stopped = true }
}
/** Testrumpf: Fake-DOM an, CustomEvent der Fake-Klasse, nach dem Test alles zurück. fn(fx). */
export const mitDom = (fn) => async () => {
const fx = installFakeDom()
const davor = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent')
Object.defineProperty(globalThis, 'CustomEvent', { value: FakeCustomEvent, writable: true, configurable: true, enumerable: true })
try {
await fn(fx)
await fx.ruhe()
} finally {
fx.restore()
if (davor) Object.defineProperty(globalThis, 'CustomEvent', davor)
else delete globalThis.CustomEvent
}
}
/** Lauscht am document.body auf kn:reiter. => { reiter: [detail, …] } (in der Reihenfolge des Eintreffens). */
export function hoereAufKn(fx) {
const k = { reiter: [] }
fx.document.body.addEventListener('kn:reiter', (ev) => { k.reiter.push(ev.detail) })
return k
}
/**
* Ersetzt fetch: Antworten nach 'METHODE /pfad?query'. Wert: { status?, body } | Array (der Reihe nach, der letzte gilt weiter) |
* (aufruf) => Wert. Unbekannte Adressen: 404 und Eintrag in fx.unbekannt. Jeder Aufruf steht in fx.fetchLog.
*/
export function routen(fx, tabelle) {
const felder = new Map(Object.entries(tabelle).map(([schluessel, wert]) => [schluessel, Array.isArray(wert) ? [...wert] : wert]))
fx.unbekannt = []
globalThis.fetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fx.fetchLog.push(aufruf)
const schluessel = `${aufruf.method} ${aufruf.url}`
let eintrag = felder.get(schluessel)
if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
if (eintrag === undefined) {
fx.unbekannt.push(schluessel)
eintrag = { status: 404, body: { error: `nicht vorgesehen: ${schluessel}` } }
}
const { status = 200, body = {} } = eintrag
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
}
/**
* Attrappe fuer zeigeEinschalten. fn(platz, { key, projectId, viewAs, onFertig }) zeichnet nichts und merkt den Aufruf.
* fertig(i, ergebnis) ruft onFertig des i-ten Aufrufs.
*/
export function einschaltenAttrappe() {
const aufrufe = []
const fn = (wurzel, o = {}) => {
aufrufe.push({ wurzel, key: o.key, projectId: o.projectId, viewAs: o.viewAs, onFertig: o.onFertig })
return Promise.resolve()
}
const fertig = (i = 0, ergebnis = { feature: { state: 'setup' }, ticketWoid: null }) => aufrufe[i].onFertig(ergebnis)
return { fn, aufrufe, fertig }
}
function attrappe() {
const handlers = {}
return {
innerHTML: '',
textContent: '',
disabled: false,
checked: false,
value: '',
dataset: {},
classList: { add() {}, remove() {}, toggle() {} },
handlers,
addEventListener(typ, fn) { handlers[typ] = fn },
querySelector: () => null,
querySelectorAll: () => [],
}
}
/**
* Wurzel fuer die innerHTML-Module (News, Analyse): { root, stage, kn }. root.querySelector(stageSelektor) liefert stage, '.an' die Attrappe `an`.
* stage.els['#id'] entsteht beim ersten querySelector('#id'). root.dispatchEvent legt die Meldungen 'kn:reiter' (ev.detail) in kn.reiter ab.
*/
export function htmlWurzel(stageSelektor) {
const els = {}
const attribute = {}
const stage = {
els,
attribute,
innerHTML: '',
querySelector: (selektor) => (els[selektor] ||= attrappe()),
querySelectorAll: () => [],
setAttribute: (name, wert) => { attribute[name] = String(wert) },
getAttribute: (name) => (Object.hasOwn(attribute, name) ? attribute[name] : null),
}
const an = attrappe()
const kn = { reiter: [] }
const root = {
innerHTML: '',
dataset: {},
querySelector: (selektor) => (selektor === stageSelektor ? stage : selektor === '.an' ? an : null),
dispatchEvent(ev) {
if (ev?.type === 'kn:reiter') kn.reiter.push(ev.detail)
return true
},
}
return { root, stage, kn }
}
/**
* public/funktionen.js laden, alle statischen Importe (absolut /x.js oder relativ ./x.js) auf die Dateien in public/ umbiegen
* (Muster funktionenZeitenTile.test.js). => das Modul.
*/
export async function ladeFunktionenModul() {
const dir = mkdtempSync(join(tmpdir(), 'fn-modul-abo-'))
const dateiUrl = (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}'${dateiUrl(name)}'`)
const datei = join(dir, 'funktionen.mjs')
writeFileSync(datei, src)
return import(pathToFileURL(datei).href)
}