Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:
* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
<html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.
Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
211 lines
9.5 KiB
TypeScript
211 lines
9.5 KiB
TypeScript
// 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<RipAuftragTitel[]>([
|
|
{ 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<RipAuftragOptionen>({ 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)
|
|
})
|
|
})
|