- 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>
1038 lines
33 KiB
Markdown
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.
|