fix(v5): Der Kern verwarf 'titel-lesen' STILL — Auswahl-Dialog hing ewig
Ampel / ampel (push) Successful in 1m48s

Commander: "das funktioniert nicht, du kannst gerne am lebenen objekt
pruefen". Der Dialog blieb bei "Rippy liest die Titel der Disc ..."
stehen, ohne Fehler, ohne Ende.

Ursache, am lebenden Objekt gemessen: NICHT der Info-Lauf.

  makemkvcon -r --noscan info dev:G:  ->  0,8 s, TCOUNT:0
  MSG:5042 "konnte keine verwendbaren optischen Laufwerke finden"

Der Lauf antwortet also sofort. Der Fehler lag in MEINEM Code von 5.2.0:
istFensterNachricht() kannte 'titel-lesen' und 'rip-ueberspringen' nicht
— beide standen in der FensterNachricht-Union, aber nicht im Pruefer.
Der Kern verwarf sie mit `if (!istFensterNachricht(frage)) return`.
STILL. Genau die R4-Wunde, vor der die Hausordnung warnt.

Auf drei Ebenen behoben:

1. Der Pruefer kennt beide Nachrichten.
2. Eine verworfene Nachricht ist jetzt LAUT: console.error plus
   kern-fehler ins Fenster mit der Art, die verworfen wurde.
3. Neuer Waechter-Test (waechter.test.ts): Er liest das Schema und
   vergleicht JEDE `art: '...'` der Unions mit dem jeweiligen Pruefer;
   dazu prueft er, dass der Kern nicht mehr still verwirft.
   GEGENPROBE gemacht: Mit zurueckgenommener Reparatur schlaegt er fehl
   ("expected [ 'titel-lesen' ] to deeply equal []"), mit Reparatur ist
   er gruen. Kein Deko-Test.

Dazu: Der Info-Lauf reicht die URSACHE durch (DiscInfo.ursachen aus den
kritischen MSG-Nummern). Kam nichts heraus und MakeMKV hat einen Grund
genannt, steht im Dialog der Grund statt "keine Titel gefunden" — beim
5042-Zustand also die Abhilfe (Disc neu einlegen, Laufwerk ab- und
anstecken, notfalls neu starten) statt eines Fingerzeigs auf die Disc.

Neu test/messung.titelwahl.test.ts — faehrt am echten Laufwerk genau den
Weg des Kerns nach (Info-Lauf, Vorauswahl, Dialog-Fehlertext).

Zweimal in dieselbe Heredoc-Falle getappt (AGENTS.md warnt davor):
Escape-Sequenzen wurden halbiert, ein rohes CR landete in makemkv.ts.
Beides korrigiert; der Hygiene-Waechter haette das CR auch gefangen.

260 Tests gruen (vorher 257), Typpruefung sauber. Version 5.3.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 20:12:44 +02:00
co-authored by Claude Opus 5
parent 306b090ccf
commit 9622f4e6ef
8 changed files with 564 additions and 411 deletions
+17 -19
View File
@@ -1,24 +1,22 @@
Version 5.3.0 Version 5.3.1
Serien landen jetzt dort, wo Jellyfin sie sucht. Die Titel-Auswahl funktioniert jetzt wirklich.
Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme In 5.2.0 und 5.3.0 blieb der Dialog ewig bei "Rippy liest die Titel der
statt einer Staffel. Jetzt legt Rippy sie unter Serien/<Titel>/Season 01 Disc …" stehen. Der Grund war ein Fehler in Rippy selbst: Die Anfrage
ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten "lies die Titel" war dem Kern unbekannt und wurde weggeworfen — still.
eindeutig zur Staffel passen; sonst behalten die Dateien ihre Namen und Behoben, und zwar auf drei Ebenen: Die Anfrage ist jetzt bekannt, eine
Rippy sagt das. Lieber gar nicht als falsch. verworfene Nachricht sagt künftig laut warum, und ein neuer Prüf-Test
vergleicht bei jedem Bau die Nachrichten-Liste mit dem Prüfer. Derselbe
Fehler kann so nicht wiederkommen.
Bei Filmen wählst du jetzt auch, was mit soll. Rippy sagt jetzt, WARUM keine Titel kamen.
Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst Findet MakeMKV das Laufwerk nicht (das passiert nach abgebrochenen
du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen Rips), stand dort bisher nur "keine Titel gefunden" — als läge es an der
im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi Disc. Jetzt steht die echte Ursache da, samt Abhilfe: Disc auswerfen und
sie als Zugaben und nicht als zweiten Film. neu einlegen, Laufwerk ab- und anstecken, notfalls neu starten.
Neuer Knopf in der Auswahl: "Hauptinhalt + Extras". Aus 5.3.0: Serien landen unter Serien/<Titel>/Season NN, Filme bekommen
Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und ihre Extras im Unterordner "extras".
Alterskennzeichen. Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
Aus 5.2.0: Titel-Auswahl vor dem Rip und der Defekt-Waechter.
Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung ueberarbeitet.
Aus 5.1.4: Das Update installiert sich wirklich.
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft. Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rippy", "name": "rippy",
"version": "5.3.0", "version": "5.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rippy", "name": "rippy",
"version": "5.3.0", "version": "5.3.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rippy", "name": "rippy",
"productName": "Rippy", "productName": "Rippy",
"version": "5.3.0", "version": "5.3.1",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js", "main": "out/haupt/index.js",
"author": "KrBrZ", "author": "KrBrZ",
+7 -1
View File
@@ -376,7 +376,13 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
export function istFensterNachricht(wert: unknown): wert is FensterNachricht { export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number' if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { if (
wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' ||
wert.art === 'rip-start' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen'
) {
return typeof wert.id === 'string' && wert.id.length > 0 return typeof wert.id === 'string' && wert.id.length > 0
} }
if (wert.art === 'einstellung-setzen') { if (wert.art === 'einstellung-setzen') {
+27 -2
View File
@@ -313,9 +313,22 @@ async function titelLesen(id: string): Promise<void> {
istSerie: merkmale?.istSerie ?? false, istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0, folgen: merkmale?.folgen ?? 0,
}) })
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
anFenster({ anFenster({
art: 'disc-titel', art: 'disc-titel',
stand: { id, laeuft: false, titel: wahl.titel, einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: '' }, stand: {
id,
laeuft: false,
titel: wahl.titel,
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
},
}) })
} catch (fehler) { } catch (fehler) {
// R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als // R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als
@@ -640,7 +653,19 @@ process.parentPort.on('message', (ereignis) => {
if (fensterPort === null) return if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => { fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data const frage: unknown = portEreignis.data
if (!istFensterNachricht(frage)) return if (!istFensterNachricht(frage)) {
// R4: Eine verworfene Nachricht verschwindet NICHT still. Genau
// das kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im
// Prüfer, der Kern warf sie weg, und im Fenster stand ewig
// „Rippy liest die Titel …", ohne dass irgendwo etwas stand.
const art =
typeof frage === 'object' && frage !== null
? String((frage as Record<string, unknown>).art)
: typeof frage
console.error(`[kern] Fenster-Nachricht verworfen (unbekannt): ${art}`)
anFenster({ art: 'kern-fehler', text: `Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.` })
return
}
if (frage.art === 'ping') { if (frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit }) anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') { } else if (frage.art === 'auswerfen') {
+286 -268
View File
@@ -1,268 +1,286 @@
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2). // makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar); // Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen. // hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
// //
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen): // Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan // - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
// hing bzw. stürzte — Befund 23.07.) // hing bzw. stürzte — Befund 23.07.)
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt // - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
// Kodierungen; „Das Öffnen" war die Folge von text=True. // Kodierungen; „Das Öffnen" war die Folge von text=True.
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und // - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2). // sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer // - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor. // nur „Failed to open disc", die Ursache steht Zeilen davor.
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { mkdirSync, readdirSync } from 'node:fs' import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import {
KRITISCHE_CODES, KRITISCHE_CODES,
KRITISCHE_TEXTE, KRITISCHE_TEXTE,
LESEFEHLER_MARKE, LESEFEHLER_MARKE,
offsetAusMeldung, offsetAusMeldung,
parseMsg, parseMsg,
parseStreamInfo, parseStreamInfo,
parseTitelInfo, parseTitelInfo,
progressAusPrgv, progressAusPrgv,
quelle, quelle,
sprachenZusammenfassen, sprachenZusammenfassen,
textVon, textVon,
type SprachEintrag, type SprachEintrag,
type TitelInfo, type TitelInfo,
type TitelStreams, type TitelStreams,
} from './parser' } from './parser'
export function buildInfoArgs(geraetepfad: string): string[] { export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)] return ['-r', '--noscan', 'info', quelle(geraetepfad)]
} }
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] { export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner] return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
} }
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend. /** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler), * Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer * zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
* von einem echten Defekt zu unterscheiden. */ * von einem echten Defekt zu unterscheiden. */
export const HAENGT_AB = 12 export const HAENGT_AB = 12
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */ /** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung { export class RipAuswertung {
prozent = -1 prozent = -1
letzteMeldung = '' letzteMeldung = ''
lesefehler = false lesefehler = false
kritischeMeldungen: string[] = [] kritischeMeldungen: string[] = []
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */ /** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
haengtBei = '' haengtBei = ''
/** Wie oft dieselbe Stelle schon gemeldet wurde. */ /** Wie oft dieselbe Stelle schon gemeldet wurde. */
haengtWiederholungen = 0 haengtWiederholungen = 0
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf /** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */ * der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
haengt(): boolean { haengt(): boolean {
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
} }
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */ /** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } { verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
const p = progressAusPrgv(zeile) const p = progressAusPrgv(zeile)
if (p >= 0) { if (p >= 0) {
if (p === this.prozent) return {} if (p === this.prozent) return {}
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr. // Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
this.haengtBei = '' this.haengtBei = ''
this.haengtWiederholungen = 0 this.haengtWiederholungen = 0
this.prozent = p this.prozent = p
return { prozent: p } return { prozent: p }
} }
const meldung = parseMsg(zeile) const meldung = parseMsg(zeile)
if (meldung === null) return {} if (meldung === null) return {}
this.letzteMeldung = meldung.text this.letzteMeldung = meldung.text
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt. // Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
const stelle = offsetAusMeldung(meldung.text) const stelle = offsetAusMeldung(meldung.text)
if (stelle.length > 0) { if (stelle.length > 0) {
if (stelle === this.haengtBei) { if (stelle === this.haengtBei) {
this.haengtWiederholungen += 1 this.haengtWiederholungen += 1
} else { } else {
this.haengtBei = stelle this.haengtBei = stelle
this.haengtWiederholungen = 1 this.haengtWiederholungen = 1
} }
} }
if (meldung.code in KRITISCHE_CODES) { if (meldung.code in KRITISCHE_CODES) {
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`) this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) { } else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
this.kritischeMeldungen.push(meldung.text) this.kritischeMeldungen.push(meldung.text)
} }
return { meldung } return { meldung }
} }
fehlerText(returncode: number | null, dateienDa: boolean): string { fehlerText(returncode: number | null, dateienDa: boolean): string {
return ( return (
`makemkvcon endete mit Code ${returncode ?? '?'}` + `makemkvcon endete mit Code ${returncode ?? '?'}` +
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') + (this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') + (this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
(dateienDa ? '' : ' — keine MKV-Datei entstanden') (dateienDa ? '' : ' — keine MKV-Datei entstanden')
) )
} }
} }
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt /** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */ * liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
export class ZeilenLeser { export class ZeilenLeser {
private rest: Buffer = Buffer.alloc(0) private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] { hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck]) this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = [] const zeilen: string[] = []
for (;;) { for (;;) {
const umbruch = this.rest.indexOf(0x0a) const umbruch = this.rest.indexOf(0x0a)
if (umbruch < 0) break if (umbruch < 0) break
let ende = umbruch let ende = umbruch
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1 if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
zeilen.push(textVon(this.rest.subarray(0, ende))) zeilen.push(textVon(this.rest.subarray(0, ende)))
this.rest = this.rest.subarray(umbruch + 1) this.rest = this.rest.subarray(umbruch + 1)
} }
return zeilen return zeilen
} }
schluss(): string[] { schluss(): string[] {
if (this.rest.length === 0) return [] if (this.rest.length === 0) return []
const letzte = textVon(this.rest) const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0) this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : [] return letzte.trim().length > 0 ? [letzte] : []
} }
} }
export interface DiscInfo { export interface DiscInfo {
titel: TitelInfo[] /** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
streams: Map<number, TitelStreams> * kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } *
} * Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
* Programm konnte keine verwendbaren optischen Laufwerke finden")
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20120 s, * antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */ * stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
export function titelInfoLesen( * dass das Laufwerk gemeint ist und nicht die Disc. */
programm: string, ursachen: string[]
geraetepfad: string, titel: TitelInfo[]
timeoutMs = 300_000, streams: Map<number, TitelStreams>
): Promise<DiscInfo> { sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
return new Promise((fertig, scheitert) => { }
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true, /** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20120 s,
stdio: ['ignore', 'pipe', 'pipe'], * zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
}) export function titelInfoLesen(
const stuecke: Buffer[] = [] programm: string,
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck)) geraetepfad: string,
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck)) timeoutMs = 300_000,
const zeiger = setTimeout(() => { ): Promise<DiscInfo> {
kind.kill() return new Promise((fertig, scheitert) => {
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) const kind = spawn(programm, buildInfoArgs(geraetepfad), {
}, timeoutMs) windowsHide: true,
kind.on('error', (fehler) => { stdio: ['ignore', 'pipe', 'pipe'],
clearTimeout(zeiger) })
scheitert(fehler) const stuecke: Buffer[] = []
}) kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
kind.on('close', () => { kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
clearTimeout(zeiger) const zeiger = setTimeout(() => {
const ausgabe = textVon(Buffer.concat(stuecke)) kind.kill()
const titel = parseTitelInfo(ausgabe) scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
const streams = parseStreamInfo(ausgabe) }, timeoutMs)
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) }) kind.on('error', (fehler) => {
}) clearTimeout(zeiger)
}) scheitert(fehler)
} })
kind.on('close', () => {
export type RipErgebnis = clearTimeout(zeiger)
| { status: 'success'; dateien: string[]; lesefehler: boolean } const ausgabe = textVon(Buffer.concat(stuecke))
| { status: 'cancelled' } const titel = parseTitelInfo(ausgabe)
| { status: 'error'; fehler: string; lesefehler: boolean } const streams = parseStreamInfo(ausgabe)
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
export interface RipLaufOptionen { const ursachen: string[] = []
programm: string for (const zeile of ausgabe.split('\n')) {
geraetepfad: string const meldung = parseMsg(zeile)
zielOrdner: string if (meldung === null) continue
/** 'all' oder eine Titel-Nummer als Text. */ if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
titel: string ursachen.push(KRITISCHE_CODES[meldung.code])
aufFortschritt?: (prozent: number) => void }
aufMeldung?: (code: number, text: string) => void }
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) — fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */ })
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void })
abbruch?: AbortSignal }
}
export type RipErgebnis =
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt | { status: 'success'; dateien: string[]; lesefehler: boolean }
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */ | { status: 'cancelled' }
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> { | { status: 'error'; fehler: string; lesefehler: boolean }
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true }) export interface RipLaufOptionen {
programm: string
const kind = spawn( geraetepfad: string
optionen.programm, zielOrdner: string
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel), /** 'all' oder eine Titel-Nummer als Text. */
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, titel: string
) aufFortschritt?: (prozent: number) => void
aufMeldung?: (code: number, text: string) => void
const auswertung = new RipAuswertung() /** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
const leser = new ZeilenLeser() * das ist ein Disc-Defekt, kein Fortschrittsproblem. */
let abgebrochen = false aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
abbruch?: AbortSignal
const abbrechen = (): void => { }
abgebrochen = true
kind.kill() /** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
} * den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder mkdirSync(optionen.zielOrdner, { recursive: true })
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
let klemmteVorher = false const kind = spawn(
const zeile = (z: string): void => { optionen.programm,
const ergebnis = auswertung.verarbeite(z) buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
if (ergebnis.meldung !== undefined) { )
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
} const auswertung = new RipAuswertung()
const klemmt = auswertung.haengt() const leser = new ZeilenLeser()
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) { let abgebrochen = false
klemmteVorher = klemmt
optionen.aufHaengt?.( const abbrechen = (): void => {
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null, abgebrochen = true
) kind.kill()
} }
} optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) // Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
kind.on('error', (fehler) => { let klemmteVorher = false
optionen.abbruch?.removeEventListener('abort', abbrechen) const zeile = (z: string): void => {
fertig({ status: 'error', fehler: String(fehler), lesefehler: false }) const ergebnis = auswertung.verarbeite(z)
}) if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.meldung !== undefined) {
kind.on('close', (code) => { optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
optionen.abbruch?.removeEventListener('abort', abbrechen) }
leser.schluss().forEach(zeile) const klemmt = auswertung.haengt()
if (abgebrochen) { if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
fertig({ status: 'cancelled' }) klemmteVorher = klemmt
return optionen.aufHaengt?.(
} klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
let dateien: string[] = [] )
try { }
dateien = readdirSync(optionen.zielOrdner) }
.filter((name) => name.endsWith('.mkv')) kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
.sort() kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) { kind.on('error', (fehler) => {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler }) optionen.abbruch?.removeEventListener('abort', abbrechen)
return fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
} })
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler }) kind.on('close', (code) => {
return optionen.abbruch?.removeEventListener('abort', abbrechen)
} leser.schluss().forEach(zeile)
fertig({ if (abgebrochen) {
status: 'error', fertig({ status: 'cancelled' })
fehler: auswertung.fehlerText(code, dateien.length > 0), return
lesefehler: auswertung.lesefehler, }
}) let dateien: string[] = []
}) try {
}) dateien = readdirSync(optionen.zielOrdner)
} .filter((name) => name.endsWith('.mkv'))
.sort()
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
return
}
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
return
}
fertig({
status: 'error',
fehler: auswertung.fehlerText(code, dateien.length > 0),
lesefehler: auswertung.lesefehler,
})
})
})
}
@@ -0,0 +1,66 @@
// MESSUNG am ECHTEN Laufwerk: Was bekäme der Auswahl-Dialog zu sehen?
//
// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.titelwahl.test.ts --reporter=verbose
//
// Der Anlass (01.09.2026): Der Dialog blieb ewig bei „Rippy liest die
// Titel der Disc …". Ursache war NICHT der Info-Lauf, sondern der
// Nachrichten-Prüfer: `istFensterNachricht` kannte 'titel-lesen' nicht,
// der Kern verwarf die Anfrage — still. Gemessen wurde danach:
//
// makemkvcon -r --noscan info dev:G: -> 0,7 s, TCOUNT:0
// MSG:5042 „konnte keine verwendbaren optischen Laufwerke finden"
// MSG:2024 „Unknown device - 'G:'" · MSG:5010 „Öffnen der Disk schlug fehl"
//
// Der Info-Lauf HING also nie. Diese Messung fährt genau den Weg des
// Kerns nach — Info-Lauf, Vorauswahl, und was als Fehlertext im Dialog
// stünde. Sie läuft nur auf Verlangen und nur unter Windows.
import { describe, expect, it } from 'vitest'
const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase()
const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32'
describe.skipIf(!messen)('Titelwahl am echten Laufwerk', () => {
it('liest die Titel und baut die Vorauswahl', { timeout: 320_000 }, async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
const { titelVorauswahl } = await import('../src/kern/rip/titelwahl')
const { discTitel } = await import('../src/kern/metadaten/discmerkmale')
const programm = finden('makemkv', '')
expect(programm.length, 'makemkvcon nicht gefunden').toBeGreaterThan(0)
const geraetepfad = `\\\\.\\${buchstabe}:`
const merkmale = discTitel(geraetepfad)
console.log(`MESSUNG Disc sagt: "${merkmale.titel}" · Folgen ${merkmale.folgen} · Serie ${merkmale.istSerie}`)
const start = Date.now()
const info = await titelInfoLesen(programm, geraetepfad)
console.log(`MESSUNG Info-Lauf: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel`)
if (info.ursachen.length > 0) console.log(`MESSUNG Ursachen: ${info.ursachen.join(' · ')}`)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale.istSerie,
folgen: merkmale.folgen,
})
console.log(`MESSUNG Einordnung: ${wahl.einordnung} (unsicher: ${wahl.unsicher})`)
for (const t of wahl.titel) {
const min = Math.floor(t.dauerS / 60)
console.log(
`MESSUNG ${t.gewaehlt ? '[x]' : '[ ]'} Titel ${t.nr} | ${min}:${String(t.dauerS % 60).padStart(2, '0')} min` +
` | ${(t.groesseBytes / 1e9).toFixed(2)} GB | ${t.kapitel} Kap. | ${t.rolle} | ${t.grund}`,
)
}
// Was im Dialog als Fehler stünde — genau die Regel aus kern/index.ts.
const dialogFehler = info.titel.length === 0 && info.ursachen.length > 0 ? info.ursachen.join(' · ') : ''
console.log(`MESSUNG Dialog-Fehlertext: ${dialogFehler.length > 0 ? dialogFehler : '(keiner)'}`)
// Der Lauf muss ANTWORTEN — leer sein darf er, hängen nicht.
expect(Date.now() - start).toBeLessThan(300_000)
})
})
describe.skipIf(messen)('Titelwahl-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_DISC=<Laufwerksbuchstabe> unter Windows', () => {
expect(true).toBe(true)
})
})
+158 -118
View File
@@ -1,118 +1,158 @@
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3. // Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem // Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei. // bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
import { readdirSync, readFileSync, statSync } from 'node:fs' import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path' import { join, relative, sep } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const WURZEL = join(__dirname, '..') const WURZEL = join(__dirname, '..')
const SRC = join(WURZEL, 'src') const SRC = join(WURZEL, 'src')
function quelldateien(ordner: string): string[] { function quelldateien(ordner: string): string[] {
const ergebnis: string[] = [] const ergebnis: string[] = []
for (const name of readdirSync(ordner)) { for (const name of readdirSync(ordner)) {
const pfad = join(ordner, name) const pfad = join(ordner, name)
if (statSync(pfad).isDirectory()) { if (statSync(pfad).isDirectory()) {
ergebnis.push(...quelldateien(pfad)) ergebnis.push(...quelldateien(pfad))
} else if (/\.(ts|tsx)$/.test(name)) { } else if (/\.(ts|tsx)$/.test(name)) {
ergebnis.push(pfad) ergebnis.push(pfad)
} }
} }
return ergebnis return ergebnis
} }
function relativPosix(pfad: string): string { function relativPosix(pfad: string): string {
return relative(WURZEL, pfad).split(sep).join('/') return relative(WURZEL, pfad).split(sep).join('/')
} }
const dateien = quelldateien(SRC).map((pfad) => ({ const dateien = quelldateien(SRC).map((pfad) => ({
pfad: relativPosix(pfad), pfad: relativPosix(pfad),
inhalt: readFileSync(pfad, 'utf8'), inhalt: readFileSync(pfad, 'utf8'),
})) }))
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => { describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die // § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts: // Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt. // Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
// Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute // Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute
// braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die // braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die
// Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt // Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt
// jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht. // jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht.
const erlaubt = new Set([ const erlaubt = new Set([
'src/haupt/leine.ts', 'src/haupt/leine.ts',
'src/haupt/fensterrand.ts', 'src/haupt/fensterrand.ts',
'src/kern/laufwerk/win32.ts', 'src/kern/laufwerk/win32.ts',
]) ])
it('koffi wird nirgendwo sonst angefasst', () => { it('koffi wird nirgendwo sonst angefasst', () => {
const verstoesse = dateien const verstoesse = dateien
.filter((d) => !erlaubt.has(d.pfad)) .filter((d) => !erlaubt.has(d.pfad))
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt)) .filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
.map((d) => d.pfad) .map((d) => d.pfad)
expect(verstoesse).toEqual([]) expect(verstoesse).toEqual([])
}) })
}) })
describe('R2/R4 — kein Fehlschlag verschwindet still', () => { describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
it('kein leerer catch-Block', () => { it('kein leerer catch-Block', () => {
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde // R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum. // gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
const verstoesse = dateien const verstoesse = dateien
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt)) .filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
.map((d) => d.pfad) .map((d) => d.pfad)
expect(verstoesse).toEqual([]) expect(verstoesse).toEqual([])
}) })
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => { it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß // R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt. // „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/ const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/ const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
const verstoesse = dateien const verstoesse = dateien
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt)) .filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
.map((d) => d.pfad) .map((d) => d.pfad)
expect(verstoesse).toEqual([]) expect(verstoesse).toEqual([])
}) })
}) })
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => { describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
it('niemand öffnet einen Server oder lauscht auf einem Port', () => { it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene // Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von // Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den // electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
// reinen Variablennamen ist erlaubt. // reinen Variablennamen ist erlaubt.
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/ const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad) const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
expect(verstoesse).toEqual([]) expect(verstoesse).toEqual([])
}) })
}) })
describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => { describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => { it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => {
// Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten // Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten
// als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs // als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs
// leiden, und ein Test sah dadurch anders aus, als sein Quelltext las. // leiden, und ein Test sah dadurch anders aus, als sein Quelltext las.
const testDateien = readdirSync(join(WURZEL, 'test')) const testDateien = readdirSync(join(WURZEL, 'test'))
.filter((name) => /\.(ts|tsx)$/.test(name)) .filter((name) => /\.(ts|tsx)$/.test(name))
.map((name) => ({ .map((name) => ({
pfad: `test/${name}`, pfad: `test/${name}`,
inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'), inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'),
})) }))
const verstoesse = [...dateien, ...testDateien] const verstoesse = [...dateien, ...testDateien]
.filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt)) .filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt))
.map((d) => d.pfad) .map((d) => d.pfad)
expect(verstoesse).toEqual([]) expect(verstoesse).toEqual([])
}) })
}) })
describe('§ 6.7 — EINE Quelle für Einstellungen', () => { describe('Nachrichten-Schema — der Prüfer kennt JEDE Nachrichtenart', () => {
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => { // Der teure Fund vom 01.09.2026: 'titel-lesen' und 'rip-ueberspringen'
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las // standen in der FensterNachricht-Union, aber NICHT in
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul. // istFensterNachricht(). Der Kern verwarf sie — still. Im Fenster stand
const verstoesse = dateien // ewig „Rippy liest die Titel …", und nirgends stand ein Grund.
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts') //
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt)) // Dieser Test liest den Quelltext: Jede `art: '…'` der Union muss im
.map((d) => d.pfad) // Prüfer wörtlich vorkommen. Eine neue Nachricht ohne Prüfung fällt
expect(verstoesse).toEqual([]) // damit sofort auf, statt erst am lebenden Objekt.
}) const schema = readFileSync(join(WURZEL, 'src', 'gemeinsam', 'nachrichten.ts'), 'utf8')
})
function artenAus(unionName: string): string[] {
const start = schema.indexOf(`export type ${unionName} =`)
expect(start, `${unionName} nicht gefunden`).toBeGreaterThan(-1)
// Der Union-Block reicht bis zur nächsten Deklaration auf oberster Ebene.
const rest = schema.slice(start + `export type ${unionName} =`.length)
const ende = rest.indexOf('\nexport ')
const block = ende > 0 ? rest.slice(0, ende) : rest
return [...new Set([...block.matchAll(/art:\s*'([a-z0-9-]+)'/g)].map((m) => m[1]))]
}
it('istFensterNachricht kennt jede FensterNachricht', () => {
const pruefer = schema.slice(schema.indexOf('export function istFensterNachricht'))
const fehlend = artenAus('FensterNachricht').filter((art) => !pruefer.includes(`'${art}'`))
expect(fehlend).toEqual([])
})
it('istKernNachricht kennt jede KernNachricht', () => {
const pruefer = schema.slice(schema.indexOf('export function istKernNachricht'))
const fehlend = artenAus('KernNachricht').filter((art) => !pruefer.includes(`'${art}'`))
expect(fehlend).toEqual([])
})
it('der Kern verwirft nichts still', () => {
// Wer eine Nachricht wegwirft, sagt WARUM (R4).
const kern = readFileSync(join(WURZEL, 'src', 'kern', 'index.ts'), 'utf8')
expect(kern).not.toMatch(/if\s*\(!istFensterNachricht\([^)]*\)\)\s*return/)
})
})
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
const verstoesse = dateien
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})