// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag // als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf // mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur // sagen, warum; Stopp und Jetzt. import { describe, expect, it } from 'vitest' import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten' import type { Protokoll } from '../src/gemeinsam/protokoll' import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik' import type { KernKontext } from '../src/kern/kontext' import type { Zuordnung } from '../src/kern/metadaten/zuordnung' function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] { return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } } } function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand { return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler } } function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo { return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' } } function zuordnung(confidence: number): Zuordnung { return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never } } describe('auftragAusVorschlag', () => { it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => { const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')])) expect(auftrag).toEqual([ { nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }, { nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 }, ]) }) }) describe('optionenAus', () => { it('ohne alles: nichts vorgeben', () => { expect(optionenAus(null, null)).toEqual({ staffel: null, ersteFolge: null }) }) it('die Staffel der Disc schlägt den Merker', () => { expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 }) }) it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => { expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null }) }) it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => { expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 }) expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 }) }) }) function aufbau(sekunden = 3) { const nachrichten: AutomatikStand[] = [] const hinweise: string[] = [] const kontext = { db: null, dbStatus: { ok: false, fehler: 'Test' }, anFenster: (n: KernNachricht) => { if (n.art === 'automatik') nachrichten.push(n.stand) }, anHauptUndFenster: () => {}, hinweis: (t: string) => hinweise.push(t), fehler: () => {}, protokoll: { schreiben: () => {} } as unknown as Protokoll, smoke: true, } as unknown as KernKontext const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = [] const benachrichtigt: string[] = [] let tick: (() => void) | null = null let gestoppt = 0 let an = true let ripLaeuft = false let titelStand: TitelStand = stand([titel(0, true), titel(1, false)]) let angaben: { staffel: number | null; discNr: number | null } | null = null let merker: { staffel: number; naechsteFolge: number } | null = null const abh: AutomatikAbhaengigkeiten = { an: () => an, sekunden: () => sekunden, ripLaeuft: () => ripLaeuft, titelLesen: async () => titelStand, ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }), discAngaben: () => angaben, serienMerker: () => merker, benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`), takt: (fn) => { tick = fn return 1 as unknown as NodeJS.Timeout }, taktStoppen: () => { gestoppt += 1 tick = null }, } const dienst = new AutomatikDienst(kontext, abh) return { dienst, nachrichten, hinweise, gestartet, benachrichtigt, tick: () => { if (tick === null) throw new Error('kein Takt') tick() }, gestopptZahl: () => gestoppt, setAn: (w: boolean) => (an = w), setRipLaeuft: (w: boolean) => (ripLaeuft = w), setTitel: (s: TitelStand) => (titelStand = s), setAngaben: (a: typeof angaben) => (angaben = a), setMerker: (m: typeof merker) => (merker = m), } } describe('AutomatikDienst', () => { it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => { const a = aufbau(3) await a.dienst.pruefen(geraet(), zuordnung(0.95), null) expect(a.nachrichten[0].text).toContain('liest die Titel') expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 }) a.tick() a.tick() expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 }) expect(a.gestartet).toHaveLength(0) a.tick() expect(a.gestartet).toHaveLength(1) expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }]) expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' }) expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet') expect(a.gestopptZahl()).toBe(1) }) it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => { const a = aufbau() await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null) expect(a.nachrichten).toHaveLength(1) expect(a.nachrichten[0]).toMatchObject({ aktiv: false }) expect(a.nachrichten[0].text).toContain('nicht sicher genug') expect(a.gestartet).toHaveLength(0) }) it('schon gerippt: wartet und nennt das Datum', async () => { const a = aufbau() await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z') expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)') expect(a.gestartet).toHaveLength(0) }) it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => { const a = aufbau() a.setAn(false) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) a.setAn(true) await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null) await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null) a.setRipLaeuft(true) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) expect(a.nachrichten).toHaveLength(0) }) it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => { const a = aufbau() a.setTitel(stand([titel(0, true), titel(1, false)], true)) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen') expect(a.gestartet).toHaveLength(0) }) it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => { const a = aufbau(30) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.') expect(a.gestopptZahl()).toBe(1) expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' }) expect(() => a.tick()).toThrow('kein Takt') // Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand. a.dienst.jetzt('G') expect(a.gestartet).toHaveLength(0) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)])) a.dienst.jetzt('G') expect(a.gestartet).toHaveLength(1) }) it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => { const a = aufbau(1) a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')])) a.setAngaben({ staffel: null, discNr: 2 }) a.setMerker({ staffel: 1, naechsteFolge: 3 }) a.dienst.zuordnungMerken('G', zuordnung(0.99)) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) a.tick() expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 }) }) it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => { const a = aufbau(5) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) a.dienst.leeren('G') expect(a.gestopptZahl()).toBe(1) expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' }) // Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts. const b = aufbau(5) b.dienst.leeren('G') expect(b.nachrichten).toHaveLength(0) }) it('Disc raus: Countdown weg, Stand weg', async () => { const a = aufbau(5) await a.dienst.pruefen(geraet(), zuordnung(0.99), null) expect(a.dienst.stand('G')).not.toBeNull() a.dienst.discEntnommen('G') expect(a.dienst.stand('G')).toBeNull() expect(a.gestopptZahl()).toBe(1) }) })