From 8c4456d0052b39c73e2f00245349f17b1ede250e Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 12 Sep 2026 21:27:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(v5):=20kein=20stilles=20Scheitern=20mehr=20?= =?UTF-8?q?=E2=80=94=20Rip-Start=20meldet=20unerreichbare=20Ablage,=20Kern?= =?UTF-8?q?-Neustart=20verdrahtet=20das=20Fenster=20neu,=20eine=20Wache-Ke?= =?UTF-8?q?tte=20je=20Laufwerk,=20Rettungs-Abbild=20=C3=BCberlebt,=20Titel?= =?UTF-8?q?-Auftrag=20schl=C3=A4gt=20=E2=80=9Eunbekannt"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: - pipeline.starten fängt jede Ausnahme und meldet sie als Fehler des Laufwerks; vor dem Start eine Schreibprobe am Zielordner mit Klartext („nicht erreichbar … NAS verbunden?"). Vorher warf mkdirSync bei toter Ablage aus starten() heraus, der Aufrufer schluckte es: kein Fehler im Fenster, keine Protokollzeile, der Dialog ging zu (Sonde 10). - kern/index.ts: unbehandelte Promise-Ablehnungen landen im Protokoll und im Fenster (gemessen: ein Electron-utilityProcess stirbt daran nicht, er schweigt); echte Ausnahmen werden protokolliert, der Kern endet kontrolliert, der Haupt startet ihn neu. Die Einlege-Kette hat ein Fangnetz. - kernstart.ts spannt nach einem Kern-Neustart den Fenster-Port neu auf; das Fenster schließt den toten Port und meldet den Neustart als Zeile. Vorher blieb es taub, bis Rippy komplett neu gestartet wurde. - wache.runde() löscht den geplanten Timer, bevor es einen neuen setzt — jeder Auswurf legte sonst eine weitere 3-s-Kette an (Sonde 3). - beenden() räumt den leeren Roh-Ordner, verschont aber rettung.iso und den Bericht (RETTUNG_DATEIEN). - Typ „unbekannt" heißt nur ohne Titel-Auftrag ISO; mit Auftrag rippt makemkvcon, der Typ folgt der Größe (Blu-ray/DVD) für Preset und Ordner. WARUM: Durchsicht 12.09.2026, Funde F2, F4, F6, F8, F10 — jeder mit Test. Co-Authored-By: Claude Opus 5 --- rippy-windows/src/fenster/kernverbindung.ts | 14 +++ rippy-windows/src/haupt/kernstart.ts | 11 ++ rippy-windows/src/kern/ablauf/pipeline.ts | 105 +++++++++++++++++--- rippy-windows/src/kern/index.ts | 26 ++++- rippy-windows/src/kern/laufwerk/wache.ts | 8 ++ rippy-windows/test/pipeline.test.ts | 70 +++++++++++++ rippy-windows/test/wache.test.ts | 29 ++++++ 7 files changed, 247 insertions(+), 16 deletions(-) diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index 2d0ae3e..5c40b1a 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -131,6 +131,11 @@ function meldung(text: string): void { function kernNachricht(nachricht: KernNachricht): void { switch (nachricht.art) { case 'kern-bereit': + // 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das + // steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren. + if (stand.kern !== null && stand.kern.pid !== nachricht.pid) { + meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`) + } stand.kern = nachricht break case 'pong': @@ -266,6 +271,15 @@ function kernNachricht(nachricht: KernNachricht): void { } function portAnnehmen(neuerPort: MessagePort): void { + // Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte + // ist tot und wird geschlossen, damit nichts mehr ins Leere geht. + if (port !== null && port !== neuerPort) { + try { + port.close() + } catch { + // Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg. + } + } port = neuerPort stand.verbunden = true neuerPort.onmessage = (ereignis: MessageEvent) => { diff --git a/rippy-windows/src/haupt/kernstart.ts b/rippy-windows/src/haupt/kernstart.ts index 7bca27f..a00214d 100644 --- a/rippy-windows/src/haupt/kernstart.ts +++ b/rippy-windows/src/haupt/kernstart.ts @@ -21,6 +21,11 @@ export class KernVerwaltung { private beendenGewollt = false private neustarts = 0 private neustartZeiten: number[] = [] + /** Das Fenster, das zuletzt einen Port bekam — ein NEU gestarteter Kern + * bekommt es wieder (5.7.0, Durchsicht F4: vorher hielt das Fenster nach + * einem Kern-Neustart den toten Port und war taub, bis Rippy komplett neu + * gestartet wurde). */ + private letztesFenster: WebContents | null = null constructor( private readonly datenbankPfad: string, @@ -54,6 +59,11 @@ export class KernVerwaltung { kern.on('spawn', () => { this.beiZustand(this.zustand()) + // Ein Neustart: Das Fenster hängt noch am toten Port des alten Kerns — + // frischen Port aufspannen, sonst tut kein Knopf mehr etwas. + if (this.neustarts > 0 && this.letztesFenster !== null && !this.letztesFenster.isDestroyed()) { + this.fensterVerbinden(this.letztesFenster) + } }) kern.on('exit', (code) => { @@ -80,6 +90,7 @@ export class KernVerwaltung { * ins Fenster (§ 4.1). */ fensterVerbinden(webContents: WebContents): void { + this.letztesFenster = webContents if (this.kern === null) return const { port1, port2 } = new MessageChannelMain() const nachricht: HauptNachricht = { art: 'fenster-port' } diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 9fd30ed..1e3dd74 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -16,11 +16,12 @@ // 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy // Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip // aus dem Abbild (iso:), das nicht mehr hängen kann. -import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. import { basename, join } from 'node:path' import type { BibliothekEintrag, + DiscTyp, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, @@ -39,6 +40,7 @@ import { mkdtempSync, createWriteStream } from 'node:fs' import { tmpdir } from 'node:os' import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag' import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme' +import { BLURAY_MIN_BYTES } from '../laufwerk/disc' interface LaufenderVorgang { /** Beendet den GANZEN Auftrag. */ @@ -114,6 +116,11 @@ export interface PipelineUmgebung { rettungsAbbild?: typeof rettungsAbbildDatei } +/** Das Rettungs-Abbild und sein Bericht — die zwei Dateien im Roh-Ordner, + * die KEIN Aufräumen anfasst (5.7.0, Durchsicht F8). */ +export const RETTUNG_ISO = 'rettung.iso' +export const RETTUNG_DATEIEN: readonly string[] = [RETTUNG_ISO, `${RETTUNG_ISO}.rettung.txt`] + /** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */ interface LetzterAuftrag { auftrag: RipAuftragTitel[] @@ -180,12 +187,52 @@ export class Pipeline { await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true) } - /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ + /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). + * + * 5.7.0 (Durchsicht 12.09.2026, F2): Löst NIE mit einer Ausnahme auf. + * Vorher warf `mkdirSync` bei nicht erreichbarer Ablage aus dieser + * Funktion heraus, der Aufrufer (`void … .then(() => starten())`) + * verschluckte das — kein Fehler im Fenster, keine Zeile im Protokoll, + * der Dialog ging zu, nichts passierte (Sonde 10). Jetzt wird jede + * Ausnahme zur Fehler-Meldung des Laufwerks (R4). */ async starten( id: string, auftrag: readonly RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}, rettung = false, + ): Promise { + try { + await this.lauf(id, auftrag, optionen, rettung) + } catch (fehler) { + const vorgangId = this.laufend.get(id)?.status.vorgangId + this.laufend.delete(id) + const text = `Rip auf Laufwerk ${id} abgebrochen — unerwarteter Fehler: ${String(fehler).slice(0, 300)}` + if (vorgangId !== undefined) this.umgebung.vorgangEndet?.(vorgangId, 'fehler', text) + this.umgebung.melden({ ...this.fehler(id, text), ...(vorgangId !== undefined ? { vorgangId } : {}) }) + } + } + + /** Ist dieser Ordner da und beschreibbar? Eine Ablage auf einer NAS oder + * einem Netzlaufwerk ist beim Start nicht selbstverständlich erreichbar — + * ausprobieren statt annehmen (Regel aus AGENTS: Wo eine Eigenschaft + * ausprobierbar ist, probiere sie aus). '' wenn alles gut, sonst der Grund. */ + static schreibprobe(ordner: string): string { + try { + mkdirSync(ordner, { recursive: true }) + const probe = join(ordner, `.rippy-schreibprobe-${process.pid}`) + writeFileSync(probe, 'rippy') + unlinkSync(probe) + return '' + } catch (fehler) { + return String(fehler instanceof Error ? fehler.message : fehler) + } + } + + private async lauf( + id: string, + auftrag: readonly RipAuftragTitel[], + optionen: RipAuftragOptionen, + rettung: boolean, ): Promise { if (this.laufend.has(id)) { this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) @@ -226,6 +273,21 @@ export class Pipeline { const startZeit = this.umgebung.jetzt?.() ?? new Date() const vorgangsName = vorgangsNameFuer(geraet, startZeit) + // 5.7.0 (F2): Erst nachsehen, ob das Ziel überhaupt beschreibbar ist — + // eine NAS, die gerade weg ist, soll einen Satz in der Kachel + // hinterlassen, nicht ein stilles Nichts. + const zielWurzel = geraet.typ === 'cd' || geraet.typ === 'unknown' ? this.umgebung.ablage() : (this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')) + const probe = Pipeline.schreibprobe(zielWurzel) + if (probe.length > 0) { + this.umgebung.melden( + this.fehler( + id, + `Rip nicht gestartet: Der Ordner ${zielWurzel} ist nicht erreichbar oder nicht beschreibbar (${probe}). Ist das Netzlaufwerk bzw. die NAS verbunden? Einstellungen → Ablage zeigt die Pfade.`, + ), + ) + return + } + // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). if (geraet.typ === 'cd') { this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) @@ -233,13 +295,22 @@ export class Pipeline { await this.audioCdRippen(id, geraet, startZeit, abbruch) return } - if (geraet.typ === 'unknown') { + // 5.7.0 (F10): „unbekannt" heißt nur dann ISO, wenn NIEMAND Titel gewählt + // hat. Liegt ein Titel-Auftrag vor, hat makemkvcon die Disc gerade + // gelesen — dann ist es eine Video-Disc, egal was der Windows-Steuercode + // zum Typ sagte (bei manchen USB-Brücken antwortet er gar nicht). + if (geraet.typ === 'unknown' && auftrag.length === 0) { this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) this.melden(this.laufend.get(id)!.status) await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) return } + // Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe + // ist es eine Blu-ray oder DVD — der Typ steuert nur noch Preset und + // Ordnername. + const discTyp: DiscTyp = geraet.typ === 'unknown' ? (geraet.groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd') : geraet.typ + this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } }) // 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang // behält seinen Zeitstempel-Namen als Kennung. @@ -260,7 +331,7 @@ export class Pipeline { this.umgebung.vorgangBeginnt?.({ id: vorgangsName, laufwerk: id, - typ: geraet.typ, + typ: discTyp, titel: anzeigeTitel, jahr: zuordnung?.jahr ?? null, rohOrdner, @@ -279,10 +350,20 @@ export class Pipeline { const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => { this.laufend.delete(id) // Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0, - // Punkt „Aufräumen nach Abbruch"). + // Punkt „Aufräumen nach Abbruch"). 5.7.0 (Durchsicht F8): AUSSER dem + // Rettungs-Abbild — dafür wurde die Disc stundenlang Sektor für Sektor + // gelesen; es bleibt liegen, auch wenn der Rip daraus leer ausging. if (alleDateien.size === 0) { try { - rmSync(rohOrdner, { recursive: true, force: true }) + const rettungDa = existsSync(join(rohOrdner, RETTUNG_ISO)) + if (rettungDa) { + for (const name of dateienIn(rohOrdner)) { + if (!RETTUNG_DATEIEN.includes(name)) rmSync(join(rohOrdner, name), { recursive: true, force: true }) + } + text += ` Das Rettungs-Abbild bleibt unter ${rohOrdner}.` + } else { + rmSync(rohOrdner, { recursive: true, force: true }) + } } catch (fehler) { text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})` } @@ -302,7 +383,7 @@ export class Pipeline { // ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ──────── let quellePfad = geraet.pfad if (rettung) { - const isoPfad = join(rohOrdner, 'rettung.iso') + const isoPfad = join(rohOrdner, RETTUNG_ISO) this.melden({ id, phase: 'rettet', @@ -430,7 +511,7 @@ export class Pipeline { abbruch.signal.removeEventListener('abort', weiterreichen) if (rip.status === 'cancelled') { - aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : [])) + aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : [])) // Nur dieser Titel — oder der ganze Auftrag? if (this.laufend.get(id)?.uebersprungen !== true) { beenden( @@ -452,7 +533,7 @@ export class Pipeline { // vielleicht heil. Der Grund wird gesammelt und am Ende genannt. // Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig. if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`) - aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : [])) + aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : [])) gescheitert.push(`${beschriftung}: ${rip.fehler}`) continue } @@ -527,7 +608,7 @@ export class Pipeline { this.umgebung.vorgangBeginnt?.({ id: gruppenId, laufwerk: id, - typ: geraet.typ, + typ: discTyp, titel: gruppenZuordnung.titel, jahr: gruppenZuordnung.jahr, rohOrdner: gruppenOrdner, @@ -538,7 +619,7 @@ export class Pipeline { auftrag: { vorgangId: gruppenId, laufwerk: id, - discTyp: geraet.typ, + discTyp, rohOrdner: gruppenOrdner, dateien: gruppe.dateien, zuordnung: gruppenZuordnung, @@ -559,7 +640,7 @@ export class Pipeline { const kompression: KompressionsAuftrag = { vorgangId: vorgangsName, laufwerk: id, - discTyp: geraet.typ, + discTyp, rohOrdner, dateien: hauptDateien, zuordnung: hauptZuordnung, diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 5d4fe66..b7a9d8c 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -87,6 +87,21 @@ const kontext: KernKontext = { }, } +// 5.7.0 (Durchsicht 12.09.2026, F2): Ein Fehler, den niemand fängt, darf +// nicht still verschwinden. Gemessen in Electron 44: Ein utilityProcess +// STIRBT NICHT an einer unbehandelten Promise-Ablehnung — er schweigt nur, +// und die Konsole eines Tray-Programms sieht niemand. Deshalb landet jede +// solche Ablehnung im Protokoll und im Fenster (R4). Eine echte Ausnahme +// wird protokolliert und beendet den Kern kontrolliert — der Haupt startet +// ihn neu und verdrahtet das Fenster wieder (kernstart.ts). +process.on('unhandledRejection', (grund: unknown) => { + kontext.fehler(`Unbehandelte Ablehnung im Kern: ${String(grund instanceof Error ? (grund.stack ?? grund.message) : grund).slice(0, 600)}`) +}) +process.on('uncaughtException', (fehler: Error) => { + kontext.fehler(`Absturz im Kern: ${String(fehler.stack ?? fehler.message).slice(0, 600)} — der Kern beendet sich, der Haupt startet ihn neu.`) + setTimeout(() => process.exit(1), 200) +}) + // ── Dienste verdrahten ────────────────────────────────────────────────── const einstellungen = new EinstellungsDienst(kontext) const werkzeuge = new WerkzeugDienst(kontext, einstellungen) @@ -101,10 +116,13 @@ const laufwerke = new LaufwerkDienst( kontext, { discEingelegt: (geraet) => { - void erkennung.discErmitteln(geraet).then((zuordnung) => { - automatik.zuordnungMerken(geraet.id, zuordnung) - return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id)) - }) + void erkennung + .discErmitteln(geraet) + .then((zuordnung) => { + automatik.zuordnungMerken(geraet.id, zuordnung) + return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id)) + }) + .catch((fehler: unknown) => kontext.fehler(`Nach dem Einlegen in ${geraet.id}: ${String(fehler)}`)) }, discEntnommen: (geraet) => { erkennung.discVergessen(geraet.id) diff --git a/rippy-windows/src/kern/laufwerk/wache.ts b/rippy-windows/src/kern/laufwerk/wache.ts index d31e945..68536b0 100644 --- a/rippy-windows/src/kern/laufwerk/wache.ts +++ b/rippy-windows/src/kern/laufwerk/wache.ts @@ -82,6 +82,14 @@ export class DiscWache { * Öffentlich, damit Tests sie ohne Uhr treiben können. */ runde(): void { if (!this.laeuft) return + // 5.7.0 (Durchsicht F6): Eine von außen angestoßene Runde (nach dem + // Auswurf) ersetzt die geplante — vorher legte JEDER Aufruf eine zweite + // Takt-Kette an, und nach zwanzig Discs fragte Rippy die Laufwerke alle + // 150 ms statt alle 3 s (Sonde 3: neun statt fünf Runden). + if (this.zeiger !== null) { + clearTimeout(this.zeiger) + this.zeiger = null + } try { const liste = this.lesen() const jetzt = new Map(liste.map((g) => [g.id, g])) diff --git a/rippy-windows/test/pipeline.test.ts b/rippy-windows/test/pipeline.test.ts index 7dc71f0..1b49873 100644 --- a/rippy-windows/test/pipeline.test.ts +++ b/rippy-windows/test/pipeline.test.ts @@ -259,3 +259,73 @@ describe('Pipeline — die Rip-Phase (5.4.0)', () => { expect(readdirSync(ordner)).toEqual(['alt.mkv']) }) }) + +// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ───────────────────────── +describe('Pipeline — Durchsicht 5.7.0', () => { + // F2: Vorher warf mkdirSync aus starten() heraus, der Aufrufer schluckte + // die Ablehnung — kein Fehler im Fenster, keine Zeile im Protokoll + // (Sonde 10). Jetzt: eine Fehler-Meldung, keine Ausnahme. + it('F2: Ablage nicht erreichbar → Fehler-Meldung statt stiller Ausnahme', async () => { + // Ein Laufwerksbuchstabe, den es nicht gibt — wie eine NAS, die gerade + // weg ist. (Unter Linux ein Ort, an dem niemand anlegen darf.) + const weg = process.platform === 'win32' ? 'Q:\\rippy-nicht-da' : '/proc/rippy-nicht-da' + const { pipeline, meldungen, endet } = umgebung({ ablage: () => weg }) + await pipeline.starten('G', AUFTRAG) + expect(meldungen).toHaveLength(1) + expect(meldungen[0].phase).toBe('fehler') + expect(meldungen[0].text).toContain('nicht erreichbar') + expect(meldungen[0].text).toContain(weg) + expect(endet).toHaveLength(0) // kein Vorgang angelegt, also keiner beendet + expect(pipeline.laeuft('G')).toBe(false) + }) + + it('F2: eine Ausnahme mitten im Lauf wird zur Fehler-Meldung des Laufwerks', async () => { + plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] + const { pipeline, meldungen } = umgebung({ + vorgangBeginnt: () => { + throw new Error('Datenbank gesperrt') + }, + }) + await expect(pipeline.starten('G', AUFTRAG)).resolves.toBeUndefined() + expect(meldungen.at(-1)!.phase).toBe('fehler') + expect(meldungen.at(-1)!.text).toContain('Datenbank gesperrt') + expect(pipeline.laeuft('G')).toBe(false) + }) + + // F10: Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc ist ein + // Video-Rip, kein ISO — makemkvcon hat die Titel ja gerade geliefert. + it('F10: Typ unbekannt MIT Titel-Auftrag rippt mit MakeMKV (Typ nach Größe)', async () => { + plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] + const { pipeline, meldungen, eingereiht } = umgebung({ geraet: () => ({ ...GERAET, typ: 'unknown' }) }) + await pipeline.starten('G', [AUFTRAG[0]]) + expect(meldungen.some((m) => m.text.includes('1:1-Abbild'))).toBe(false) + expect(eingereiht).toHaveLength(1) + expect(eingereiht[0].discTyp).toBe('bluray') + }) + + // F8: Der Roh-Ordner ohne brauchbare Datei wird geräumt — das Rettungs- + // Abbild aber nie: dafür wurde die Disc stundenlang gelesen. + it('F8: rettung.iso überlebt einen leer ausgegangenen Rip aus dem Abbild', async () => { + plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }, { status: 'error', fehler: 'MakeMKV endete mit Code 1' }] + const { pipeline, ablage, meldungen } = umgebung({ + rettungsAbbild: async (_geraet, ziel) => { + writeFileSync(ziel, 'ABBILD') + writeFileSync(`${ziel}.rettung.txt`, 'Bericht') + return { status: 'success', bytes: 6, unlesbareSektoren: 1, stellen: [{ offset: 0, bytes: 2048 }] } + }, + }) + // Erst ein normaler Rip, der Lesefehler sieht — nur dann gibt es die Rettung. + plan.schritte = [{ status: 'error', fehler: 'Lesefehler', lesefehler: true }] + await pipeline.starten('G', [AUFTRAG[0]]) + expect(pipeline.rettungGrund('G')).not.toBe('') + plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }] + await pipeline.rettungStarten('G') + const roh = join(ablage, 'roh') + const ordner = readdirSync(roh) + expect(ordner.length).toBeGreaterThan(0) + const inhalt = ordner.flatMap((o) => readdirSync(join(roh, o))) + expect(inhalt).toContain('rettung.iso') + expect(inhalt).toContain('rettung.iso.rettung.txt') + expect(meldungen.at(-1)!.text).toContain('Rettungs-Abbild bleibt') + }) +}) diff --git a/rippy-windows/test/wache.test.ts b/rippy-windows/test/wache.test.ts index 73b7aa2..469978d 100644 --- a/rippy-windows/test/wache.test.ts +++ b/rippy-windows/test/wache.test.ts @@ -88,3 +88,32 @@ describe('DiscWache', () => { wache.stoppen() }) }) + +// 5.7.0 (Durchsicht F6): runde() von außen — nach dem Auswurf — ersetzt die +// geplante Runde, statt eine zweite Takt-Kette anzulegen. Vorher: nach +// zwanzig Auswürfen zwanzig Ketten (Sonde 3, neun statt fünf Runden). +describe('DiscWache — eine Takt-Kette, egal wie oft runde() von außen kommt', () => { + it('drei Aufrufe von außen, danach nur EINE Kette', async () => { + let gelesen = 0 + const wache = new DiscWache( + () => { + gelesen += 1 + return [] + }, + () => {}, + () => {}, + 20, + ) + wache.starten() + wache.runde() + wache.runde() + const vorher = gelesen + await new Promise((f) => setTimeout(f, 130)) + wache.stoppen() + const runden = gelesen - vorher + // Eine Kette im 20-ms-Takt schafft in 130 ms höchstens ~7 Runden; drei + // Ketten schafften vorher ~19. + expect(runden).toBeLessThanOrEqual(8) + expect(runden).toBeGreaterThanOrEqual(3) + }) +})