Twitch-VOD-Manager/tasks/v5.0.0-plan-01-foundation.md
xRangerDE 956ffc30bc docs: v5.0.0 goal + roadmap + foundation plan
- tasks/v5.0.0-goal.md: 7-Pillar Vision, Breaking Changes, Release-Phasen
- tasks/v5.0.0-roadmap.md: Reality-Check vs Goal, 11-Plan Execution-Order
- tasks/v5.0.0-plan-01-foundation.md: Vitest + 5 Pure-Module-Extraktionen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:37:54 +02:00

33 KiB

Plan 01: Foundation — Vitest + Pure-Utility-Extraction

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Vitest-Test-Infrastruktur etablieren und 5 reine Utility-Module aus src/main.ts (7485 LoC) in src/main/infra/ und src/main/domain/ herausziehen, jeweils mit Unit-Tests. Build und E2E bleiben gruen. Setzt Pattern fuer Plan 02-04.

Architecture: src/main/ als neues Stammverzeichnis fuer die kuenftigen Domain-Module. Pure Helpers (keine Side-Effects ausser dokumentierter FS-Operationen) wandern als Erstes — sie sind risikoarm extrahierbar. main.ts importiert sie zurueck statt sie inline zu definieren. Vitest laeuft in Node-Env (kein jsdom), Tests sind colocated unter src/main/**/*.test.ts.

Tech Stack: Vitest (Node 22 compatible), TypeScript 5.3 strict, Electron 28.

Verifikation pro Task: npm run build muss gruen bleiben. Nach allen Extraktionen einmal npm run test:e2e:release.


File Structure

Neu:

  • vitest.config.ts
  • src/main/index.ts (Barrel, leer am Ende von Plan 01, wird in Plan 04 zum Entry-Point)
  • src/main/infra/fs-atomic.ts
  • src/main/infra/fs-atomic.test.ts
  • src/main/infra/duration.ts
  • src/main/infra/duration.test.ts
  • src/main/domain/i18n-backend.ts
  • src/main/domain/i18n-backend.test.ts
  • src/main/domain/config-normalize.ts
  • src/main/domain/config-normalize.test.ts
  • src/main/domain/update-version-utils.ts (Move von src/update-version-utils.ts)
  • src/main/domain/update-version-utils.test.ts

Modifiziert:

  • package.json (devDeps + scripts)
  • src/main.ts (entfernt extrahierte Funktionen, fuegt Imports hinzu)
  • scripts/smoke-test-update-version-logic.js (passt Importpfad an oder loescht — bleibt vorerst)
  • CLAUDE.md (neuer test:unit Befehl + neue Struktur erklaert)

Geloescht:

  • src/update-version-utils.ts (nach Move)

Tasks

Task 1: Vitest installieren

Files:

  • Modify: package.json

  • Step 1: Vitest als devDep hinzufuegen

Run:

npm install --save-dev vitest@latest

Expected: package.json enthaelt "vitest": "^X.Y.Z" in devDependencies. package-lock.json aktualisiert.

  • Step 2: Build verifizieren

Run: npm run build Expected: Exit 0, keine TS-Errors.

  • Step 3: Commit
git add package.json package-lock.json
git commit -m "build: add vitest devDep"

Task 2: vitest.config.ts erstellen

Files:

  • Create: vitest.config.ts

  • Step 1: Config schreiben

import { defineConfig } from 'vitest/config';

export default defineConfig({
    test: {
        environment: 'node',
        include: ['src/**/*.test.ts'],
        globals: false,
        reporters: ['default'],
        clearMocks: true,
    }
});
  • Step 2: Sanity-Check via Dummy-Test

Schreibe temporaer src/_smoke.test.ts:

import { test, expect } from 'vitest';
test('vitest runs', () => { expect(1 + 1).toBe(2); });

Run: npx vitest run Expected: 1 passed.

  • Step 3: Dummy entfernen

Run: rm src/_smoke.test.ts

  • Step 4: Commit
git add vitest.config.ts
git commit -m "build: vitest config (node env, src/**/*.test.ts)"

Task 3: test:unit Script

Files:

  • Modify: package.json (Scripts-Block)

  • Step 1: test:unit Script hinzufuegen

In package.json unter "scripts", nach "test:e2e:update-logic":

"test:unit": "vitest run",
"test:unit:watch": "vitest",

Und "test:e2e:release" erweitern: "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full".

  • Step 2: Script laeuft (ohne Tests noch leer)

Run: npm run test:unit Expected: "No test files found" oder "0 passed" — Exit 0 ist OK.

  • Step 3: Commit
git add package.json
git commit -m "build: add test:unit scripts + chain into test:e2e:release"

Task 4: src/main/ Verzeichnisstruktur

Files:

  • Create: src/main/index.ts (Stub)

  • Create: src/main/infra/.gitkeep (oder Stub)

  • Create: src/main/domain/.gitkeep

  • Step 1: Verzeichnisse anlegen

mkdir -p "src/main/infra" "src/main/domain"
  • Step 2: Stub index.ts

src/main/index.ts:

// Stammverzeichnis fuer das v5-Architektur-Refactoring.
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
export {};
  • Step 3: Build verifizieren

Run: npm run build Expected: Exit 0.

  • Step 4: Commit
git add src/main
git commit -m "scaffold: src/main directory tree for v5 split"

Task 5: update-version-utils.ts verlagern

Files:

  • Create: src/main/domain/update-version-utils.ts (Move)

  • Create: src/main/domain/update-version-utils.test.ts

  • Delete: src/update-version-utils.ts

  • Modify: src/main.ts (Importpfad)

  • Modify: scripts/smoke-test-update-version-logic.js (Importpfad falls noetig)

  • Step 1: Inhalt der alten Datei lesen

Run: cat src/update-version-utils.ts Notiere die Funktionssignaturen.

  • Step 2: Datei verlagern

Run:

git mv src/update-version-utils.ts src/main/domain/update-version-utils.ts
  • Step 3: Test-Datei schreiben (Fixtures aus dem bestehenden smoke-test)

src/main/domain/update-version-utils.test.ts:

import { test, expect, describe } from 'vitest';
// TODO: nach Step 1 die echten exportierten Namen einsetzen.
// import { ... } from './update-version-utils';

describe('update-version-utils', () => {
    test.skip('placeholder - re-enable after import paths fixed', () => {
        expect(true).toBe(true);
    });
});

(Echte Tests werden nach Step 4 nachgezogen, sobald die Importpfade in src/main.ts aktualisiert sind. Skip-Pattern verhindert false-positive.)

  • Step 4: Importpfad in main.ts aktualisieren

Run: grep -n "update-version-utils" src/main.ts Ersetze den Import-Pfad von './update-version-utils' auf './main/domain/update-version-utils'.

  • Step 5: smoke-test-update-version-logic.js anpassen falls noetig

Run: grep -n "update-version-utils" scripts/smoke-test-update-version-logic.js Falls Verweis: Pfad aktualisieren.

  • Step 6: Build + bestehender Smoke-Test

Run: npm run build && npm run test:e2e:update-logic Expected: Beide Exit 0, "passes": all true.

  • Step 7: Echte Vitest-Tests fuer update-version-utils

Ersetze src/main/domain/update-version-utils.test.ts mit konkreten Tests basierend auf den im Smoke-Test gepruefen Faellen. Beispiel-Skelett (echte Funktionsnamen aus Step 1 einsetzen):

import { test, expect, describe } from 'vitest';
import { /* echte Exports */ } from './update-version-utils';

describe('compareVersions', () => {
    test('1.0.0 < 1.0.1', () => {
        expect(/* fn */('1.0.0', '1.0.1')).toBeLessThan(0);
    });
    test('strips v-prefix', () => {
        expect(/* fn */('v1.0.0', '1.0.0')).toBe(0);
    });
    test('trims whitespace', () => {
        expect(/* fn */(' 1.0.0 ', '1.0.0')).toBe(0);
    });
});

Mindestens 6 Test-Cases (semver-Ordnung, v-Prefix, Trim, Equal, Major-Jump, Pre-Release falls unterstuetzt).

  • Step 8: Vitest laeuft

Run: npm run test:unit Expected: Tests passed (Zahl >= 6).

  • Step 9: Commit
git add src/main/domain/update-version-utils.ts src/main/domain/update-version-utils.test.ts src/main.ts scripts/smoke-test-update-version-logic.js
git rm src/update-version-utils.ts
git commit -m "refactor: move update-version-utils to src/main/domain/ + vitest coverage"

Task 6: fs-atomic Modul

Files:

  • Create: src/main/infra/fs-atomic.ts

  • Create: src/main/infra/fs-atomic.test.ts

  • Modify: src/main.ts (entferne writeFileAtomicSync, importiere stattdessen)

  • Step 1: Failing Test schreiben

src/main/infra/fs-atomic.test.ts:

import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { writeFileAtomicSync } from './fs-atomic';

let tmpDir: string;
beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
});
afterEach(() => {
    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('writeFileAtomicSync', () => {
    test('writes a string payload', () => {
        const target = path.join(tmpDir, 'a.txt');
        writeFileAtomicSync(target, 'hello');
        expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
    });

    test('writes a buffer payload', () => {
        const target = path.join(tmpDir, 'b.bin');
        writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
        expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
    });

    test('overwrites existing file', () => {
        const target = path.join(tmpDir, 'c.txt');
        fs.writeFileSync(target, 'old');
        writeFileAtomicSync(target, 'new');
        expect(fs.readFileSync(target, 'utf-8')).toBe('new');
    });

    test('cleans up tmp file after success', () => {
        const target = path.join(tmpDir, 'd.txt');
        writeFileAtomicSync(target, 'x');
        expect(fs.existsSync(target + '.tmp')).toBe(false);
    });
});
  • Step 2: Test soll fehlschlagen (Modul existiert noch nicht)

Run: npm run test:unit Expected: 4 failed (module not found).

  • Step 3: Modul implementieren (verbatim aus main.ts Z 542-565)

src/main/infra/fs-atomic.ts:

import * as fs from 'fs';

/**
 * Atomic write via tmp + rename. Survives crash mid-write — either old or
 * new content, never partial. Windows fallback: copy + unlink if rename
 * locks (target open by reader). fsync best-effort, ignored on FS that
 * doesnt support it.
 */
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
    const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
    const tmpPath = targetPath + '.tmp';

    let fd: number | null = null;
    try {
        fd = fs.openSync(tmpPath, 'w');
        fs.writeSync(fd, buffer, 0, buffer.length, 0);
        try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
    } finally {
        if (fd !== null) {
            try { fs.closeSync(fd); } catch { }
        }
    }

    try {
        fs.renameSync(tmpPath, targetPath);
    } catch {
        fs.copyFileSync(tmpPath, targetPath);
        try { fs.unlinkSync(tmpPath); } catch { }
    }
}
  • Step 4: Test passt

Run: npm run test:unit Expected: 4 passed.

  • Step 5: main.ts umverdrahten

In src/main.ts:

  • Loesche die function writeFileAtomicSync(...) Definition (Z 542-565).

  • Adde ganz oben (bei den anderen Imports) import { writeFileAtomicSync } from './main/infra/fs-atomic';.

  • Step 6: Build verifizieren

Run: npm run build && npm run test:unit Expected: Beide Exit 0.

  • Step 7: E2E-Smoke laeuft (saveConfig nutzt das intern, also implicit getestet)

Run: npm run test:e2e Expected: Exit 0.

  • Step 8: Commit
git add src/main/infra/fs-atomic.ts src/main/infra/fs-atomic.test.ts src/main.ts
git commit -m "refactor: extract writeFileAtomicSync to src/main/infra/fs-atomic + tests"

Task 7: duration Modul

Files:

  • Create: src/main/infra/duration.ts

  • Create: src/main/infra/duration.test.ts

  • Modify: src/main.ts

  • Step 1: Failing Tests schreiben

src/main/infra/duration.test.ts:

import { test, expect, describe } from 'vitest';
import { parseDuration, formatDuration, formatDurationDashed } from './duration';

describe('parseDuration', () => {
    test('1h2m3s = 3723', () => {
        expect(parseDuration('1h2m3s')).toBe(3723);
    });
    test('45m = 2700', () => {
        expect(parseDuration('45m')).toBe(2700);
    });
    test('10s = 10', () => {
        expect(parseDuration('10s')).toBe(10);
    });
    test('empty string = 0', () => {
        expect(parseDuration('')).toBe(0);
    });
    test('unknown format = 0', () => {
        expect(parseDuration('abcdef')).toBe(0);
    });
    test('partial 2h = 7200', () => {
        expect(parseDuration('2h')).toBe(7200);
    });
});

describe('formatDuration', () => {
    test('3723 = 01:02:03', () => {
        expect(formatDuration(3723)).toBe('01:02:03');
    });
    test('0 = 00:00:00', () => {
        expect(formatDuration(0)).toBe('00:00:00');
    });
    test('negative = 00:00:00', () => {
        expect(formatDuration(-1)).toBe('00:00:00');
    });
    test('Infinity = 00:00:00', () => {
        expect(formatDuration(Infinity)).toBe('00:00:00');
    });
    test('NaN = 00:00:00', () => {
        expect(formatDuration(NaN)).toBe('00:00:00');
    });
    test('3600 = 01:00:00', () => {
        expect(formatDuration(3600)).toBe('01:00:00');
    });
});

describe('formatDurationDashed', () => {
    test('3723 = 01-02-03', () => {
        expect(formatDurationDashed(3723)).toBe('01-02-03');
    });
    test('negative = 00-00-00', () => {
        expect(formatDurationDashed(-1)).toBe('00-00-00');
    });
});
  • Step 2: Tests fehlschlagen

Run: npm run test:unit Expected: 14 failed (module not found).

  • Step 3: Modul (verbatim aus main.ts Z 1102-1129)

src/main/infra/duration.ts:

export function parseDuration(duration: string): number {
    let seconds = 0;
    const hours = duration.match(/(\d+)h/);
    const minutes = duration.match(/(\d+)m/);
    const secs = duration.match(/(\d+)s/);

    if (hours) seconds += parseInt(hours[1]) * 3600;
    if (minutes) seconds += parseInt(minutes[1]) * 60;
    if (secs) seconds += parseInt(secs[1]);

    return seconds;
}

export function formatDuration(seconds: number): string {
    if (!isFinite(seconds) || seconds < 0) return '00:00:00';
    const h = Math.floor(seconds / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    const s = Math.floor(seconds % 60);
    return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}

export function formatDurationDashed(seconds: number): string {
    if (!isFinite(seconds) || seconds < 0) return '00-00-00';
    const h = Math.floor(seconds / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    const s = Math.floor(seconds % 60);
    return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
  • Step 4: Tests passen

Run: npm run test:unit Expected: 14 passed.

  • Step 5: main.ts umverdrahten

In src/main.ts:

  • Loesche die 3 function-Definitionen (Z 1102-1129).

  • Adde Import oben: import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';.

  • Step 6: Build + Tests

Run: npm run build && npm run test:unit && npm run test:e2e:update-logic Expected: alle Exit 0.

  • Step 7: Commit
git add src/main/infra/duration.ts src/main/infra/duration.test.ts src/main.ts
git commit -m "refactor: extract duration helpers to src/main/infra/duration + tests"

Task 8: i18n-backend Modul

Files:

  • Create: src/main/domain/i18n-backend.ts

  • Create: src/main/domain/i18n-backend.test.ts

  • Modify: src/main.ts

  • Step 1: Quelle einsehen

Run: sed -n '85,189p' src/main.ts Identifiziere BACKEND_MESSAGES, BackendMessageKey, tBackend. Aktueller Sprachselektor: prueft config.language — Achtung: tBackend depends on global config. Wir extrahieren tBackend als pure Funktion, die Sprache als Parameter erwartet, und behalten den config-Adapter in main.ts.

  • Step 2: Tests schreiben

src/main/domain/i18n-backend.test.ts:

import { test, expect, describe } from 'vitest';
import { tBackend, BACKEND_MESSAGES, type BackendMessageKey, type BackendLanguage } from './i18n-backend';

describe('tBackend', () => {
    test('returns DE message for known key', () => {
        // Pick a key that we know exists. Adjust if first key changes.
        const keys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
        expect(keys.length).toBeGreaterThan(0);
        const k = keys[0];
        expect(tBackend(k, undefined, 'de')).toBe(BACKEND_MESSAGES.de[k]);
    });

    test('returns EN fallback', () => {
        const keys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
        const k = keys[0];
        expect(tBackend(k, undefined, 'en')).toBe(BACKEND_MESSAGES.en[k]);
    });

    test('substitutes {param} placeholders', () => {
        // Find a key containing a {param}. Skip if no parameterized message exists.
        const enMessages = BACKEND_MESSAGES.en;
        const paramKey = (Object.keys(enMessages) as BackendMessageKey[])
            .find(k => /\{\w+\}/.test(enMessages[k]));
        if (!paramKey) return;
        const template = enMessages[paramKey];
        const m = template.match(/\{(\w+)\}/);
        if (!m) return;
        const result = tBackend(paramKey, { [m[1]]: 'XYZ' }, 'en');
        expect(result).toContain('XYZ');
        expect(result).not.toContain(`{${m[1]}}`);
    });

    test('unknown language falls back to de', () => {
        const keys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
        const k = keys[0];
        // @ts-expect-error testing fallback path
        expect(tBackend(k, undefined, 'fr')).toBe(BACKEND_MESSAGES.de[k]);
    });
});
  • Step 3: Failing Test

Run: npm run test:unit Expected: 4 failed (module not found).

  • Step 4: Modul schreiben

src/main/domain/i18n-backend.ts:

// Auszug aus src/main.ts Z 85-189. tBackend wird pure: Sprache als Parameter.
// Der bisherige Caller (main.ts) wickelt den config.language Zugriff aussen.

// === HIER: BACKEND_MESSAGES verbatim aus main.ts kopieren ===
export const BACKEND_MESSAGES = {
    de: { /* ... unveraendert ... */ },
    en: { /* ... unveraendert ... */ },
} as const;

export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
export type BackendLanguage = 'de' | 'en';

export function tBackend(
    key: BackendMessageKey,
    params?: Record<string, string | number>,
    language: BackendLanguage | string = 'de'
): string {
    const lang: BackendLanguage = (language === 'en') ? 'en' : 'de';
    let msg: string = BACKEND_MESSAGES[lang][key];
    if (params) {
        for (const [k, v] of Object.entries(params)) {
            msg = msg.replaceAll(`{${k}}`, String(v));
        }
    }
    return msg;
}

(Echtes BACKEND_MESSAGES inline aus main.ts Z 91-171 kopieren — sind ca. 80 Zeilen.)

  • Step 5: Tests passen

Run: npm run test:unit Expected: 4 passed (assuming key set non-empty).

  • Step 6: main.ts umverdrahten

In src/main.ts:

  • Loesche BACKEND_MESSAGES Objekt + BackendMessageKey Typ + tBackend Funktion (Z 85-189).
  • Adde Import: import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
  • Adde lokale Adapter-Funktion direkt darunter:
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
    return tBackendCore(key, params, config?.language || 'de');
}

So bleiben alle Call-Sites unveraendert (sie nutzen die 2-Arg-Form). Pure Modul ist 3-Arg.

  • Step 7: Build + Tests

Run: npm run build && npm run test:unit Expected: beide Exit 0.

  • Step 8: Commit
git add src/main/domain/i18n-backend.ts src/main/domain/i18n-backend.test.ts src/main.ts
git commit -m "refactor: extract BACKEND_MESSAGES + tBackend (pure) to src/main/domain/i18n-backend + tests"

Task 9: config-normalize Modul

Files:

  • Create: src/main/domain/config-normalize.ts

  • Create: src/main/domain/config-normalize.test.ts

  • Modify: src/main.ts

  • Step 1: Quelle einsehen

Run: sed -n '375,510p' src/main.ts Identifiziere die 9 Funktionen:

  • normalizeAutoRecordPollSeconds(value: unknown): number
  • normalizeAutoRecordList(value: unknown): string[] (depends on normalizeLogin — finden!)
  • normalizeStreamlinkQuality(value: unknown): string
  • normalizeFilenameTemplate(template, fallback): string
  • normalizeMetadataCacheMinutes(value: unknown): number
  • normalizePerformanceMode(mode: unknown): PerformanceMode
  • normalizeConfigTemplates(input: Config): Config (depends on Config type — wird mitwandern muessen oder als Generic)
  • isPlainObject(value: unknown): value is Record<string, unknown> (Z 521)

getStreamlinkStreamArg bleibt in main.ts (haengt von config ab — kein Pure). normalizeConfigTemplates haengt von Default-Templates ab. Wir muessen die Konstanten mitziehen oder als Optionen-Objekt uebergeben.

Entscheidung: normalizeConfigTemplates nimmt einen defaults: NormalizeDefaults Parameter. Die Default-Konstanten bleiben in main.ts und werden injected. Das macht das Modul pure.

  • Step 2: Tests schreiben

src/main/domain/config-normalize.test.ts:

import { test, expect, describe } from 'vitest';
import {
    normalizeAutoRecordPollSeconds,
    normalizeAutoRecordList,
    normalizeStreamlinkQuality,
    normalizeFilenameTemplate,
    normalizeMetadataCacheMinutes,
    normalizePerformanceMode,
    isPlainObject,
    VALID_STREAMLINK_QUALITIES,
} from './config-normalize';

describe('normalizeAutoRecordPollSeconds', () => {
    test('default for non-number', () => {
        expect(normalizeAutoRecordPollSeconds('x')).toBe(90);
        expect(normalizeAutoRecordPollSeconds(null)).toBe(90);
        expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90);
    });
    test('clamps low', () => {
        expect(normalizeAutoRecordPollSeconds(5)).toBe(30);
    });
    test('clamps high', () => {
        expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800);
    });
    test('passes valid', () => {
        expect(normalizeAutoRecordPollSeconds(120)).toBe(120);
    });
    test('floors fractional', () => {
        expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120);
    });
});

describe('normalizeAutoRecordList', () => {
    test('empty for non-array', () => {
        expect(normalizeAutoRecordList(null)).toEqual([]);
        expect(normalizeAutoRecordList('x')).toEqual([]);
    });
    test('lowercases + trims + dedupes', () => {
        expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']);
    });
    test('strips leading @ (twitch username paste-form)', () => {
        expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']);
    });
    test('drops non-string entries', () => {
        expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']);
    });
});

describe('normalizeStreamlinkQuality', () => {
    test('valid values pass', () => {
        for (const q of VALID_STREAMLINK_QUALITIES) {
            expect(normalizeStreamlinkQuality(q)).toBe(q);
        }
    });
    test('invalid falls back to best', () => {
        expect(normalizeStreamlinkQuality('foo')).toBe('best');
        expect(normalizeStreamlinkQuality(null)).toBe('best');
        expect(normalizeStreamlinkQuality(undefined)).toBe('best');
        expect(normalizeStreamlinkQuality(42)).toBe('best');
    });
});

describe('normalizeFilenameTemplate', () => {
    test('valid string used', () => {
        expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4');
    });
    test('trims', () => {
        expect(normalizeFilenameTemplate('  hi  ', 'FB')).toBe('hi');
    });
    test('empty falls back', () => {
        expect(normalizeFilenameTemplate('', 'FB')).toBe('FB');
        expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB');
        expect(normalizeFilenameTemplate('   ', 'FB')).toBe('FB');
    });
});

describe('normalizeMetadataCacheMinutes', () => {
    test('default for non-number', () => {
        expect(normalizeMetadataCacheMinutes('x')).toBe(10);
    });
    test('clamps low', () => {
        expect(normalizeMetadataCacheMinutes(0)).toBe(1);
    });
    test('clamps high', () => {
        expect(normalizeMetadataCacheMinutes(999)).toBe(120);
    });
    test('passes valid', () => {
        expect(normalizeMetadataCacheMinutes(15)).toBe(15);
    });
});

describe('normalizePerformanceMode', () => {
    test('valid pass', () => {
        expect(normalizePerformanceMode('stability')).toBe('stability');
        expect(normalizePerformanceMode('balanced')).toBe('balanced');
        expect(normalizePerformanceMode('speed')).toBe('speed');
    });
    test('invalid falls back to balanced', () => {
        expect(normalizePerformanceMode('foo')).toBe('balanced');
        expect(normalizePerformanceMode(null)).toBe('balanced');
    });
});

describe('isPlainObject', () => {
    test('true for object literal', () => {
        expect(isPlainObject({})).toBe(true);
        expect(isPlainObject({ a: 1 })).toBe(true);
    });
    test('false for array', () => {
        expect(isPlainObject([])).toBe(false);
    });
    test('false for null', () => {
        expect(isPlainObject(null)).toBe(false);
    });
    test('false for primitives', () => {
        expect(isPlainObject('x')).toBe(false);
        expect(isPlainObject(42)).toBe(false);
        expect(isPlainObject(true)).toBe(false);
    });
});
  • Step 3: Failing Tests

Run: npm run test:unit Expected: 20+ failed (module not found).

  • Step 4: Modul schreiben

src/main/domain/config-normalize.ts:

// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals.
// normalizeLogin wird hier inline definiert, da abhaengig.

export type PerformanceMode = 'stability' | 'balanced' | 'speed';

export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;

const AUTO_RECORD_POLL_MIN_SECONDS = 30;
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';

export function normalizeLogin(input: string): string {
    // Verbatim aus main.ts Z 1910 — strippt fuehrende @ + lowercase + trim.
    return input.trim().replace(/^@+/, '').toLowerCase();
}

export function normalizeAutoRecordPollSeconds(value: unknown): number {
    const parsed = Number(value);
    if (!Number.isFinite(parsed)) return 90;
    return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
}

export function normalizeAutoRecordList(value: unknown): string[] {
    if (!Array.isArray(value)) return [];
    const seen = new Set<string>();
    const out: string[] = [];
    for (const v of value) {
        if (typeof v !== 'string') continue;
        const cleaned = normalizeLogin(v);
        if (cleaned && !seen.has(cleaned)) {
            seen.add(cleaned);
            out.push(cleaned);
        }
    }
    return out;
}

export function normalizeStreamlinkQuality(value: unknown): string {
    if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
        return value;
    }
    return 'best';
}

export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
    const value = (template || '').trim();
    return value || fallback;
}

export function normalizeMetadataCacheMinutes(value: unknown): number {
    const parsed = Number(value);
    if (!Number.isFinite(parsed)) {
        return DEFAULT_METADATA_CACHE_MINUTES;
    }
    return Math.max(1, Math.min(120, Math.floor(parsed)));
}

export function normalizePerformanceMode(mode: unknown): PerformanceMode {
    if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
        return mode;
    }
    return DEFAULT_PERFORMANCE_MODE;
}

export function isPlainObject(value: unknown): value is Record<string, unknown> {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}

Hinweis (vor Plan verifiziert): normalizeLogin existiert in src/main.ts Z 1910 mit @-Stripping. Modul uebernimmt die identische Logik. In Task 9 Step 6 wird die main.ts-Definition geloescht und durch Import aus config-normalize ersetzt.

  • Step 5: Tests passen

Run: npm run test:unit Expected: alle vorigen plus 20+ neue passed.

  • Step 6: main.ts umverdrahten

  • Loesche die 8 Funktionen + Konstante VALID_STREAMLINK_QUALITIES (Z 402, 377-440, 521-524) aus main.ts.

  • Loesche function normalizeLogin Z 1910-1912 aus main.ts (kommt aus dem Modul).

  • Loesche type PerformanceMode = ... Zeile (Z 68) — kommt jetzt aus dem Modul.

  • Adde Import oben:

import {
    normalizeAutoRecordPollSeconds,
    normalizeAutoRecordList,
    normalizeStreamlinkQuality,
    normalizeFilenameTemplate,
    normalizeMetadataCacheMinutes,
    normalizePerformanceMode,
    isPlainObject,
    VALID_STREAMLINK_QUALITIES,
    type PerformanceMode,
} from './main/domain/config-normalize';
  • Falls normalizeLogin an anderer Stelle in main.ts existiert: entweder loeschen und aus dem Modul importieren oder bestehende behalten + im Modul auf re-export verzichten. Entscheidung dokumentieren.

normalizeConfigTemplates BLEIBT in main.ts (zu viel Config-Knowledge, nicht pure genug ohne grosse Refactorierung — kommt in Plan 02 dran).

recordDownloadedVodId bleibt auch in main.ts (mutiert globales config).

  • Step 7: Build + alle Tests

Run: npm run build && npm run test:unit && npm run test:e2e:update-logic Expected: alle Exit 0.

  • Step 8: Commit
git add src/main/domain/config-normalize.ts src/main/domain/config-normalize.test.ts src/main.ts
git commit -m "refactor: extract pure config normalizers to src/main/domain/config-normalize + tests"

Task 10: Full Verification Pass

Files: keine Aenderungen, nur Tests.

  • Step 1: Komplette Test-Pipeline

Run: npm run test:e2e:release Expected: alle Stages Exit 0 (build, unit, update-logic, e2e basic, e2e guide, e2e full).

  • Step 2: LoC-Reduktion messen

Run: wc -l src/main.ts Expected: < 7300 (vorher 7485; mindestens ~200 Zeilen weniger).

  • Step 3: Modul-Anzahl + Test-Anzahl

Run: find src/main -name '*.ts' -not -name '*.test.ts' | wc -l && find src/main -name '*.test.ts' | wc -l && npm run test:unit -- --reporter=verbose 2>&1 | tail -10 Expected: >= 5 Module, >= 5 Test-Dateien, alle gruen.

  • Step 4: Commit Verifikations-Snapshot (optional)

Falls erwuenscht, ein CHANGELOG_5.0.0_alpha.0.md mit Foundation-Notes anlegen — sonst skip.


Task 11: Version bump auf 5.0.0-alpha.0

Files:

  • Modify: package.json

  • Step 1: Version setzen

Run: npm version 5.0.0-alpha.0 --no-git-tag-version Expected: package.json "version" = "5.0.0-alpha.0".

  • Step 2: Build mit neuer Version

Run: npm run build Expected: Exit 0.

  • Step 3: Commit
git add package.json package-lock.json
git commit -m "release: 5.0.0-alpha.0 — foundation: vitest + 5 pure modules extracted"
  • Step 4: Tag (lokal, kein push)

Run: git tag v5.0.0-alpha.0


Task 12: CLAUDE.md aktualisieren

Files:

  • Modify: CLAUDE.md

  • Step 1: Build-Tabelle ergaenzen

In der Build-Commands-Tabelle nach test:e2e:update-logic Zeile hinzufuegen:

| `npm run test:unit` | Vitest Unit-Tests (`src/**/*.test.ts`) |
| `npm run test:unit:watch` | Vitest Watch-Mode |

Und test:e2e:release Beschreibung erweitern: "build + unit + update-logic + 3 Playwright-Stages".

  • Step 2: Architecture-Abschnitt um Hinweis erweitern

Unter "Process Model" eine Notiz adden:

**v5-Umzug (in Arbeit):** Plan 01 hat erste Module aus main.ts in `src/main/infra/` und `src/main/domain/` ausgelagert. Die Roadmap fuer den vollstaendigen Architektur-Split steht in `tasks/v5.0.0-roadmap.md`.
  • Step 3: Commit
git add CLAUDE.md
git commit -m "docs: CLAUDE.md notes new test:unit script + v5 split status"

Self-Review Checklist

  • Alle Task-Steps haben echten Code, keine Platzhalter
  • Jedes Modul hat mindestens 4 Test-Cases
  • parseDuration, formatDuration etc. werden in formatDurationDashed konsistent benannt (kein Drift)
  • Spec-Coverage: Pillar 4 (Architektur-Split) — Foundation-Schritt ist abgedeckt; vollstaendiger Split kommt in Plan 02-04. Pillar 4 selbst trifft hier nur den Anfang.
  • Vitest-Setup wirkt sich nicht auf dist:win aus (build ignoriert test files via tsc Resolution; vitest config kennt sie nur ueber test.include)

Done-Definition Plan 01

  1. Vitest laeuft (npm run test:unit Exit 0)
  2. 5 neue Module in src/main/{infra,domain}/ mit Tests (gesamt >= 40 Tests passed)
  3. main.ts ist um ~200+ LoC kuerzer
  4. npm run test:e2e:release Exit 0
  5. Version 5.0.0-alpha.0 committed
  6. CLAUDE.md aktualisiert

Execution Handoff

Dieser Plan wird via superpowers:executing-plans Inline-Execution abgearbeitet (kein Subagent-Driven — Tasks sind zu klein und stark sequenziell). Naechster Plan (tasks/v5.0.0-plan-02-domain-pt1.md) wird erst nach Done-Definition geschrieben.