# 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** ```typescript 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`: ```typescript 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"`: ```json "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** ```bash mkdir -p "src/main/infra" "src/main/domain" ``` - [ ] **Step 2: Stub index.ts** `src/main/index.ts`: ```typescript // 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`: ```typescript 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): ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript // 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, 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: ```typescript function tBackend(key: BackendMessageKey, params?: Record): 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` (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`: ```typescript 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`: ```typescript // 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(); 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 { 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: ```typescript 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.