Kundenansicht neu P2: Wortlaut-Orakel für die Texte der Fassung 5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:50:49 +00:00
1 parent 91e5bca1c5
commit b77c3291c5
2 files changed
+147

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { sichtbarerText, ohneKopf, ladeAlteApp } from './support/knWortlautOrakel.js'
import { aboAntwort, abo, VORSCHAU, LIMITS } from './support/knDaten.js'
test('sichtbarerText: Tags, Skripte und jeder Leerraum fallen weg, Entitäten werden Text', () => {
assert.equal(sichtbarerText('<p>Hallo&nbsp;<b>Welt</b> &amp; Co<!-- Hinweis für Entwickler --><script>x()</script></p>'), 'HalloWelt&Co')
assert.equal(sichtbarerText('50,00 € '), '50,00€')
})
test('ohneKopf streicht nur den Kopf, nicht die Absätze danach', () => {
assert.equal(sichtbarerText(ohneKopf('<header class="kb-head"><h1>A</h1><p>B</p></header><p>C</p>')), 'C')
assert.equal(sichtbarerText(ohneKopf('<header class="kb-head"><h1>A</h1></header><p>C</p>')), 'C')
assert.equal(sichtbarerText(ohneKopf('<p>C</p>')), 'C')
})
test('altes app.js rendert in der VM: Bestellen, Wechsel, Speicher, Gutschrift', async () => {
const alt = ladeAlteApp()
assert.match(sichtbarerText(alt.bestellen(aboAntwort(), 'wartung')), /Zahlungspflichtigbestellen/)
const hosting = aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true }) })
assert.match(sichtbarerText(await alt.wechsel(hosting, VORSCHAU)), /Zahlungspflichtigbestellen/)
assert.match(sichtbarerText(await alt.speicher(aboAntwort({ subscription: abo() }))), /E-Mail-Speicher/)
assert.match(sichtbarerText(await alt.gutschrift(aboAntwort({ subscription: abo() }), { requests: [], limits: LIMITS })), /Antragabsenden/)
})
+125
View File
@@ -0,0 +1,125 @@
// Wortlaut-Orakel (P2): lädt das alte public/app.js in eine VM, rendert die vier Wortlaut-Seiten (Bestellen, Wechsel, E-Mail-Speicher,
// Gutschrift) mit Attrappen und liefert deren HTML. Die neuen Seiten müssen denselben sichtbaren Text haben (C14, Fassung 5).
// Nur lesend: app.js bleibt unverändert. P10 friert dieses Hilfsmittel vor dem Löschen von app.js ein (N40, N51).
import vm from 'node:vm'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { setImmediate as takt } from 'node:timers/promises'
const APP_JS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'app.js')
const ENTITAETEN = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' }
/** Sichtbarer Text eines HTML-Strings oder Elements: ohne Tags, Kommentare, <script>, <style> und ohne jeden Leerraum (auch U+00A0). */
export function sichtbarerText(quelle) {
const roh = typeof quelle === 'string' ? quelle : String(quelle?.textContent ?? '')
const ohneTags = typeof quelle === 'string'
? roh
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, '')
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, '')
.replace(/<[^>]*>/g, '')
: roh
return ohneTags.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (e) => ENTITAETEN[e]).replace(/\s+/g, '')
}
/** Streicht den Seitenkopf <header class="kb-head">…</header> (Titel zeichnet die Hülle, Untertitel entfallen); ohne Kopf bleibt alles. */
export function ohneKopf(html) {
return String(html).replace(/<header class="kb-head">[\s\S]*?<\/header>/, '')
}
function attrappeElement() {
return {
innerHTML: '',
textContent: '',
dataset: {},
style: {},
value: '',
checked: false,
disabled: false,
classList: { contains: () => false, add() {}, remove() {}, toggle() {} },
querySelector: () => null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
insertAdjacentHTML() {},
scrollIntoView() {},
focus() {},
}
}
/** app.js in einer VM mit Attrappen laden. Alle Top-Level-Berührungen sind addEventListener, matchMedia, querySelector, navigator und serviceWorker. */
export function ladeAlteApp() {
const antworten = new Map()
const fetchAttrappe = async (url) => {
const pfad = String(url).split('?')[0]
const body = antworten.has(pfad) ? antworten.get(pfad) : {}
return { ok: antworten.has(pfad), status: antworten.has(pfad) ? 200 : 404, json: async () => structuredClone(body) }
}
const medien = () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {} })
const elemente = new Map()
const document = {
body: attrappeElement(),
addEventListener() {},
querySelector: () => null,
querySelectorAll: () => [],
getElementById: (id) => elemente.get(id) ?? null,
createElement: () => attrappeElement(),
}
const window = {
addEventListener() {},
removeEventListener() {},
matchMedia: medien,
location: { href: '', hash: '', search: '', pathname: '/dashboard.html' },
history: { state: null, replaceState() {}, pushState() {}, back() {} },
isSecureContext: false,
scrollTo() {},
}
class CustomEventAttrappe {
constructor(typ, init = {}) { this.type = typ; this.detail = init.detail ?? null }
}
const sandbox = {
window, document, navigator: { userAgent: '' }, fetch: fetchAttrappe, CustomEvent: CustomEventAttrappe, Event: CustomEventAttrappe,
console, URLSearchParams, TextEncoder, structuredClone, setTimeout, clearTimeout, setInterval, clearInterval,
}
const ctx = vm.createContext(sandbox)
vm.runInContext(readFileSync(APP_JS, 'utf8'), ctx, { filename: 'app.js' })
const view = () => attrappeElement()
return {
/** Bestellseite (#bestellen-hosting / #bestellen-wartung): HTML der Ansicht. */
bestellen(daten, key) {
const v = view()
vm.runInContext('renderOrderView', ctx)(v, daten, key)
return v.innerHTML
},
/** Wechselseite (#wechsel) nach der Vorschau-Antwort. */
async wechsel(daten, vorschau) {
antworten.set('/api/abo/switch/preview', vorschau)
const v = view()
vm.runInContext('renderSwitchView', ctx)(v, daten)
await takt()
await takt()
return v.innerHTML
},
/** Speicher-Block der Verträge-Ansicht; die Vorschau kommt bei geplanter Reduzierung (app.js:1405). */
async speicher(daten, vorschau) {
if (vorschau) antworten.set('/api/abo/storage/preview', vorschau)
const c = view()
vm.runInContext('renderStoragePanel', ctx)(c, daten)
await takt()
await takt()
return c.innerHTML
},
/** Gutschrift-Karte (#gutschrift) mit der Antwort von GET /api/abo/takeover-credits. */
async gutschrift(daten, antwort) {
antworten.set('/api/abo/takeover-credits', antwort)
const karte = view()
elemente.set('takeover-card', karte)
vm.runInContext('takeoverState.customer = { email: "anna@example.com" }', ctx)
await vm.runInContext('loadTakeoverCard', ctx)({ abo: daten })
return karte.innerHTML
},
}
}