feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm

WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 22:22:04 +02:00
co-authored by Claude Fable 5.1
parent e3ce8220f1
commit 6dd1b0bc7a
24 changed files with 558 additions and 47 deletions
@@ -0,0 +1,90 @@
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
// HandBrake 1.11 gemessen haben:
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert
const fs = require('node:fs')
const args = process.argv.slice(2)
const out = (zeile) => process.stdout.write(zeile + '\n')
const wert = (schalter) => {
const i = args.indexOf(schalter)
return i >= 0 ? args[i + 1] : undefined
}
if (args.includes('--help') || args.includes('-h')) {
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
out('')
out('Video Options -----------------------------------------------------------------')
out('')
out(' -e, --encoder <string> Select video encoder:')
out(' x264')
out(' x265')
out(' nvenc_h264')
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
out('')
process.exit(0)
}
if (args.includes('--preset-list')) {
out('General/')
out(' Very Fast 1080p30')
out(' Fast 1080p30')
out(' HQ 1080p30 Surround')
out('')
process.exit(0)
}
if (args.includes('--scan')) {
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 1, 100.00 %')
out('JSON Title Set: {')
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
out('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
out(']')
out('}')
out(']')
out('}')
out('HandBrake has exited.')
process.exit(0)
}
const eingabe = wert('--input') ?? wert('-i')
const ausgabe = wert('--output') ?? wert('-o')
if (eingabe === undefined || ausgabe === undefined) {
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
process.exit(1)
}
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0
const stufen = [5, 30, 55, 80, 100]
const tick = () => {
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 300)
return
}
fs.copyFileSync(eingabe, ausgabe)
out('[12:00:05] libhb: work result = 0')
out('')
out('Encode done!')
out('HandBrake has exited.')
process.exit(0)
}
setTimeout(tick, 200)
@@ -0,0 +1,79 @@
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
//
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
//
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
const fs = require('node:fs')
const path = require('node:path')
const args = process.argv.slice(2)
const befehl = args.find((a) => a === 'info' || a === 'mkv')
const out = (zeile) => process.stdout.write(zeile + '\n')
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
const TITEL = [
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
]
if (befehl === 'info') {
out(`TCOUNT:${TITEL.length}`)
for (const t of TITEL) {
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
out(`SINFO:${t.nr},1,3,0,"jpn"`)
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
out(`SINFO:${t.nr},2,3,0,"deu"`)
out(`SINFO:${t.nr},2,4,0,"German"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},3,3,0,"deu"`)
out(`SINFO:${t.nr},3,4,0,"German"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},4,3,0,"eng"`)
out(`SINFO:${t.nr},4,4,0,"English"`)
}
}
process.exit(0)
}
if (befehl === 'mkv') {
const i = args.indexOf('mkv')
const nr = Number(args[i + 2])
const ziel = args[i + 3]
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
out('MSG:5010,0,0,"Falscher Aufruf"')
process.exit(1)
}
let schritt = 0
const stufen = [10, 35, 60, 85, 100]
const tick = () => {
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 250)
return
}
fs.mkdirSync(ziel, { recursive: true })
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
process.exit(0)
}
setTimeout(tick, 200)
} else {
out('MSG:5010,0,0,"Unbekannter Befehl"')
process.exit(1)
}