Files
rippy/rippy-windows/test/pipeline.test.ts
T
HitonabiandClaude Opus 5 9d16e82b5f
Ampel / ampel (push) Successful in 1m14s
test(v5): F2-Test ohne /proc — Nodes rekursives mkdirSync pendelt dort endlos, die Ampel hing
WAS: Der Test „Ablage nicht erreichbar" nimmt jetzt einen Ordner UNTER
einer Datei als Ablage (scheitert auf jeder Plattform sofort mit
ENOTDIR/EEXIST) statt /proc/rippy-nicht-da unter Linux.
WARUM: Ampel-Lauf 279 (12.09.2026) blieb in test/pipeline.test.ts
hängen: auf procfs antwortet mkdir mit ENOENT, obwohl /proc da ist —
Nodes mkdirSync({recursive:true}) läuft dann zwischen Kind und Eltern-
ordner im Kreis. Lokal (Windows) war der Lauf grün, weil Q:\ dort nicht
existiert und Node am Wurzelpfad abbricht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:29:04 +02:00

338 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 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')
})
})