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:
1 parent
91e5bca1c5
commit
b77c3291c5
2 files changed
+147
No files matched your search
@@ -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 <b>Welt</b> & 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/)
|
||||||
|
})
|
||||||
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' }
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user