Files
rippy/rippy-windows/test/rip-parser.test.ts
HitonabiandClaude Fable 5 844bca37ff
Ampel / ampel (push) Failing after 1m22s
feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
rip/parser.ts: die reine Textverarbeitung mit allen bezahlten Fallen aus
dem Docker-Zweig — quelle() (dev:G: statt dev:\.\G:, der 404-Verwandte),
textVon() (UTF-8 streng, sonst cp1252 — nie wieder 'Öffnen'), parseMsg
(Regex statt split — Meldungen mit Komma), PRGV (-1 vs. echtes 0 %),
TINFO/SINFO (Sprache aus Attr 3/4, NICHT 28/29), laengsterTitel mit
Play-All-Schutz, episodenTitel, KRITISCHE_CODES (sprachfeste NUMMERN).

rip/makemkv.ts: Kommandobau + Lauf — ZeilenLeser (binaer, halbe Zeilen,
je Zeile dekodiert), RipAuswertung (pur), rippen() mit AbortSignal,
Lesefehler-Flag AUCH bei Rueckgabewert 0. werkzeuge/katalog.ts: die
Suchreihenfolge samt %VAR%-Gross/Klein-Falle und Registry-Rueckfall.
ablauf/rippen.ts: ein Rip je Laufwerk, Status-Push, 'erst nachsehen,
dann rippen' (unbekannt heisst trotzdem rippen). Fenster: Rippen-Knopf,
Fortschrittsbalken, Abbrechen; Auswurf waehrend des Rips abgelehnt.

Gemessen am echten System (30.08.2026): Katalog findet
makemkvcon64 (Program Files x86), HandBrakeCLI und flac (beide im
rc11-Werkzeugordner LOCALAPPDATA\Rippy\tools). NEUER BEFUND: Nach dem
abgebrochenen rc11-Rip vom Mittag sieht MakeMKV GAR KEIN Laufwerk mehr
(MSG 5042, leere DRV-Liste, mit und ohne --noscan), waehrend die
Storage-IOCTLs sauber antworten — MakeMKV spricht SCSI direkt. 5042 ist
jetzt ein KRITISCHER_CODE mit Klartext-Abhilfe. Der volle Rip-Beweis
(messung.rip.test.ts liegt bereit) braucht deshalb erst eine Hand am
Geraet: Disc neu einlegen oder USB ab-/anstecken.

96 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:12:44 +02:00

187 lines
6.4 KiB
TypeScript

// Tests des makemkvcon-Parsers — die Testfälle wandern sinngemäß aus dem
// Docker-Zweig mit (test_makemkv_aufruf.py, ripping.py-Doku): Ein neu
// geschriebener Parser, der dieselben Fälle besteht, kennt dieselben
// Fallen (KONZEPT § 10).
import { describe, expect, it } from 'vitest'
import {
episodenTitel,
KRITISCHE_CODES,
laengsterTitel,
parseMsg,
parseStreamInfo,
parseTitelInfo,
progressAusPrgv,
quelle,
sprachenZusammenfassen,
textVon,
} from '../src/kern/rip/parser'
describe('quelle — wie MakeMKV das Laufwerk nennt', () => {
it.each([
['\\\\.\\G:', 'dev:G:'],
['G:', 'dev:G:'],
['G:\\', 'dev:G:'],
['\\\\?\\E:', 'dev:E:'],
['/dev/sr0', 'dev:/dev/sr0'],
])('%s → %s', (eingabe, erwartet) => {
expect(quelle(eingabe)).toBe(erwartet)
})
it('ohne Gerät kommt nichts', () => {
expect(quelle('')).toBe('')
expect(quelle(' ')).toBe('')
})
it('ein langer Windows-Pfad wird nicht verstümmelt', () => {
expect(quelle('\\\\.\\D:\\Ordner')).toBe('dev:D:\\Ordner')
})
})
describe('textVon — Kodierung bestimmen statt behaupten', () => {
it('liest UTF-8', () => {
expect(textVon(Buffer.from('Das Öffnen', 'utf8'))).toBe('Das Öffnen')
})
it('liest AUCH cp1252', () => {
expect(textVon(Buffer.from('Das Öffnen', 'latin1'))).toBe('Das Öffnen')
})
it('der Fehler des Commanders kommt NICHT zurück (kein Ã)', () => {
for (const kodierung of ['utf8', 'latin1'] as const) {
const gelesen = textVon(Buffer.from('Das Öffnen der Disk schlug fehl', kodierung))
expect(gelesen).not.toContain('Ã')
expect(gelesen).toBe('Das Öffnen der Disk schlug fehl')
}
})
it('Text bleibt Text, leer bleibt leer, kaputte Bytes werfen nicht', () => {
expect(textVon('schon Text')).toBe('schon Text')
expect(textVon(Buffer.alloc(0))).toBe('')
expect(textVon(Buffer.from([0xff, 0xfe, 0x20]))).toBeTypeOf('string')
})
})
describe('parseMsg', () => {
it('liest Code und Klartext', () => {
expect(parseMsg('MSG:5010,0,1,"Das Öffnen der Disk schlug fehl","%1","x"')).toEqual({
code: 5010,
text: 'Das Öffnen der Disk schlug fehl',
})
})
it('Meldungen MIT Komma werden nicht abgeschnitten (25.07.-Falle)', () => {
const m = parseMsg('MSG:3025,0,1,"Title #1 has length of 12 seconds, which is less than minimum","f"')
expect(m?.text).toBe('Title #1 has length of 12 seconds, which is less than minimum')
})
it('maskierte Anführungszeichen werden entpackt', () => {
expect(parseMsg('MSG:1,0,0,"sagt \\"hallo\\""')?.text).toBe('sagt "hallo"')
})
it('Nicht-MSG-Zeilen sind null', () => {
expect(parseMsg('PRGV:0,1,65536')).toBeNull()
expect(parseMsg('irgendwas')).toBeNull()
})
})
describe('progressAusPrgv', () => {
it('total/max ist der Gesamtfortschritt', () => {
expect(progressAusPrgv('PRGV:100,32768,65536')).toBe(50)
expect(progressAusPrgv('PRGV:0,65536,65536')).toBe(100)
})
it('ein echtes 0 % ist eine Angabe, keine Zeile ist -1', () => {
expect(progressAusPrgv('PRGV:0,0,65536')).toBe(0)
expect(progressAusPrgv('MSG:1,0,0,"x"')).toBe(-1)
expect(progressAusPrgv('PRGV:1,1,0')).toBe(-1)
})
})
// Fixture nach dem dokumentierten Robot-Format; die SINFO-Zeilen sind das
// am 26.07.2026 an der Akira-Blu-ray abgenommene Muster.
const INFO_AUSGABE = [
'TINFO:0,8,0,"12"',
'TINFO:0,9,0,"1:57:07"',
'TINFO:0,11,0,"31474929664"',
'TINFO:1,9,0,"0:02:01"',
'TINFO:1,8,0,"2"',
'SINFO:0,0,1,6201,"Video"',
'SINFO:0,1,1,6202,"Audio"',
'SINFO:0,1,3,0,"deu"',
'SINFO:0,1,4,0,"German"',
'SINFO:0,1,6,0,"DTS-HD MA"',
'SINFO:0,1,14,0,"2"',
'SINFO:0,1,30,0,"DTS-HD MA Stereo German"',
'SINFO:0,2,1,6202,"Audio"',
'SINFO:0,2,3,0,"jpn"',
'SINFO:0,2,4,0,"Japanese"',
'SINFO:0,3,1,6203,"Subtitles"',
'SINFO:0,3,3,0,"deu"',
'SINFO:0,3,4,0,"German"',
].join('\n')
describe('parseTitelInfo', () => {
it('liest Dauer, Größe und Kapitel je Titel', () => {
expect(parseTitelInfo(INFO_AUSGABE)).toEqual([
{ nr: 0, dauerS: 7027, groesseBytes: 31_474_929_664, kapitel: 12 },
{ nr: 1, dauerS: 121, groesseBytes: 0, kapitel: 2 },
])
})
it('leere Ausgabe ist eine leere Liste', () => {
expect(parseTitelInfo('')).toEqual([])
})
})
describe('parseStreamInfo + sprachenZusammenfassen', () => {
it('trennt Audio und Untertitel, Sprache aus Attribut 3/4', () => {
const streams = parseStreamInfo(INFO_AUSGABE)
const titel0 = streams.get(0)!
expect(titel0.audio.map((s) => s.lang)).toEqual(['deu', 'jpn'])
expect(titel0.audio[0]).toMatchObject({ codec: 'DTS-HD MA', kanaele: '2', sprache: 'German' })
expect(titel0.untertitel.map((s) => s.lang)).toEqual(['deu'])
})
it('fasst die Sprachen der ganzen Disc zusammen', () => {
const sprachen = sprachenZusammenfassen(parseStreamInfo(INFO_AUSGABE))
expect(sprachen.audio).toEqual([
{ lang: 'deu', sprache: 'German', spuren: 1 },
{ lang: 'jpn', sprache: 'Japanese', spuren: 1 },
])
expect(sprachen.untertitel).toEqual([{ lang: 'deu', sprache: 'German', spuren: 1 }])
})
})
describe('laengsterTitel', () => {
const dauern = new Map([
[0, 7027], // Hauptfilm ~117 min
[1, 7300], // „Play All" wäre länger
[2, 121],
])
it('ohne Soll-Laufzeit gewinnt der längste', () => {
expect(laengsterTitel(dauern)).toBe(1)
})
it('mit Soll-Laufzeit gewinnt der nächstliegende (Play-All-Falle)', () => {
expect(laengsterTitel(dauern, 117)).toBe(0)
})
it('Soll-Laufzeit weit weg von allem → wieder der längste', () => {
expect(laengsterTitel(new Map([[0, 100]]), 117)).toBe(0)
expect(laengsterTitel(new Map(), 117)).toBeNull()
})
})
describe('episodenTitel', () => {
it('findet Titel nahe der mittleren Episoden-Laufzeit (±10 min)', () => {
const dauern = new Map([
[1, 42 * 60],
[2, 44 * 60],
[3, 43 * 60],
[9, 170 * 60], // Play All
[10, 2 * 60], // Trailer
])
expect(episodenTitel(dauern, [43, 43, 42])).toEqual([1, 2, 3])
})
it('ohne Angaben keine Treffer', () => {
expect(episodenTitel(new Map(), [43])).toEqual([])
expect(episodenTitel(new Map([[1, 60]]), [])).toEqual([])
})
})
describe('KRITISCHE_CODES — die gemessenen Ursachen', () => {
it('kennt die vier am Commander-Laufwerk gesehenen Nummern + 5010', () => {
for (const code of [2024, 5010, 5020, 5021, 5051]) {
expect(KRITISCHE_CODES[code], `Code ${code}`).toBeDefined()
expect(KRITISCHE_CODES[code].length).toBeGreaterThan(20)
}
})
})