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 <noreply@anthropic.com>
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
// 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<PipelineUmgebung> = {}): {
|
|
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 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')
|
|
})
|
|
})
|