// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt // danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk // ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft // test/ablegen.test.ts. // // makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner — // nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler // wirklich verschwinden. import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten' import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag' interface Schritt { status: 'success' | 'error' | 'cancelled' /** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */ datei?: string bytes?: number lesefehler?: boolean fehler?: string /** Fortschritts-Ticks, die der Lauf meldet. */ ticks?: number[] } const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] })) vi.mock('../src/kern/rip/makemkv', async () => { const fs = await import('node:fs') const pfad = await import('node:path') return { rippen: vi.fn( async (o: { zielOrdner: string aufFortschritt?: (p: number) => void }) => { const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' } for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p) if (schritt.datei !== undefined) { fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10)) } if (schritt.status === 'cancelled') return { status: 'cancelled' } if (schritt.status === 'error') { return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false } } const dateien = fs .readdirSync(o.zielOrdner) .filter((n: string) => n.endsWith('.mkv')) .sort() .map((n: string) => pfad.join(o.zielOrdner, n)) return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false } }, ), } }) import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline' let wegwerf: string[] = [] afterEach(() => { plan.schritte = [] for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true }) }) const GERAET = { id: 'G', name: 'Laufwerk G:', typ: 'bluray' as const, pfad: '\\\\.\\G:', status: 'ready' as const, modell: '', seriennummer: '', groesseBytes: 33e9, grund: '', } function umgebung(überschreiben: Partial = {}): { pipeline: Pipeline meldungen: RipStatus[] ablage: string eingereiht: KompressionsAuftrag[] endet: Array<{ id: string; phase: string; text: string }> ausgeworfen: string[] } { const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-')) wegwerf.push(ablage) const meldungen: RipStatus[] = [] const eingereiht: KompressionsAuftrag[] = [] const endet: Array<{ id: string; phase: string; text: string }> = [] const ausgeworfen: string[] = [] const pipeline = new Pipeline({ makemkvcon: () => 'C:\\mk\\makemkvcon64.exe', handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', flac: () => '', geraet: () => GERAET, ablage: () => ablage, einstellungen: () => ({}), melden: (s) => meldungen.push(s), jetzt: () => new Date('2026-08-30T12:00:00Z'), vorgangEinreihen: (auftrag) => { eingereiht.push(auftrag) return eingereiht.length }, vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }), nachRipAuswerfen: (id) => ausgeworfen.push(id), ...überschreiben, }) return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen } } const AUFTRAG: RipAuftragTitel[] = [ { nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 }, ] describe('Pipeline — die Rip-Phase (5.4.0)', () => { it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => { plan.schritte = [ { status: 'success', datei: 'title_t00.mkv', bytes: 300 }, { status: 'success', datei: 'title_t01.mkv', bytes: 100 }, ] const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung() const beginn: string[] = [] ;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner) await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 }) expect(meldungen[0].phase).toBe('rippt') const letzte = meldungen.at(-1)! expect(letzte.phase).toBe('fertig') expect(letzte.text).toContain('eingereiht') expect(letzte.text).toContain('Laufwerk ist frei') expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00') expect(pipeline.laeuft('G')).toBe(false) expect(ausgeworfen).toEqual(['G']) expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')]) expect(eingereiht).toHaveLength(1) const auftrag = eingereiht[0] expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00') expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge']) expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn']) expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu']) expect(auftrag.dateien[1].audioSprachen).toEqual([]) expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100]) expect(auftrag.staffel).toBe(2) expect(auftrag.ersteFolge).toBe(3) expect(auftrag.preset).toBe('') }) it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => { plan.schritte = [ { status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] }, { status: 'success', datei: 'title_t01.mkv', ticks: [50] }, ] const { pipeline, meldungen } = umgebung() await pipeline.starten('G', AUFTRAG) const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent) // Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %, // erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0. expect(prozente).toContain(37) expect(prozente).toContain(75) expect(prozente).toContain(87) for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1]) }) it('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => { plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }] const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung() await pipeline.starten('G', AUFTRAG) const letzte = meldungen.at(-1)! expect(letzte.phase).toBe('abgebrochen') expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv') expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false) expect(eingereiht).toHaveLength(0) expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }]) }) it('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => { plan.schritte = [ { status: 'success', datei: 'title_t00.mkv' }, { status: 'cancelled', datei: 'title_t01.mkv' }, ] const { pipeline, meldungen, ablage } = umgebung() await pipeline.starten('G', AUFTRAG) expect(meldungen.at(-1)!.phase).toBe('abgebrochen') const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00') expect(readdirSync(roh)).toEqual(['title_t00.mkv']) expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben') }) it('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => { plan.schritte = [ { status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' }, { status: 'success', datei: 'title_t01.mkv' }, ] const { pipeline, meldungen, eingereiht } = umgebung() await pipeline.starten('G', AUFTRAG) const letzte = meldungen.at(-1)! expect(letzte.phase).toBe('fertig') expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv']) expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert') expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.mkv') }) it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => { plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }] const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung() await pipeline.starten('G', AUFTRAG) expect(meldungen.at(-1)!.phase).toBe('fehler') expect(meldungen.at(-1)!.text).toContain('kaputt') expect(eingereiht).toHaveLength(0) expect(endet[0].phase).toBe('fehler') expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false) }) it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => { plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }] const { pipeline, meldungen, eingereiht } = umgebung() await pipeline.starten('G', [AUFTRAG[0]]) expect(meldungen.at(-1)!.lesefehler).toBe(true) expect(meldungen.at(-1)!.text).toContain('nicht lesen') expect(eingereiht[0].lesefehler).toBe(true) }) it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => { plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) }) await pipeline.starten('G') expect(eingereiht[0].dateien).toHaveLength(1) expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm') expect(eingereiht[0].staffel).toBe(3) expect(eingereiht[0].discNr).toBe(2) }) it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => { plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined }) await pipeline.starten('G') expect(meldungen.at(-1)!.text).toContain('nicht eingereiht') }) it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => { const { pipeline, meldungen } = umgebung({ geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }), }) await pipeline.starten('G') expect(meldungen[0].phase).toBe('fehler') expect(meldungen[0].text).toContain('Kein Datenträger') }) it('halbeDateienEntfernen: nur, was NEU dazukam', () => { const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-')) wegwerf.push(ordner) writeFileSync(join(ordner, 'alt.mkv'), 'a') const vorher = new Set(readdirSync(ordner)) writeFileSync(join(ordner, 'neu.mkv'), 'b') expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv']) 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 Ordner UNTER einer Datei — anlegen scheitert sofort, auf jeder // Plattform (ENOTDIR bzw. EEXIST). Nicht /proc: dort antwortet mkdir mit // ENOENT, obwohl der Elternordner da ist, und Nodes rekursives mkdirSync // pendelt dann endlos — die Ampel hing daran (Lauf 279, 12.09.2026). const sperre = mkdtempSync(join(tmpdir(), 'rippy-sperre-')) wegwerf.push(sperre) const datei = join(sperre, 'keine-ablage.txt') writeFileSync(datei, 'x') const weg = join(datei, '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') }) })