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

1038 lines
33 KiB
Markdown

# 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<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:
```typescript
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`:
```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<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:
```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.