dd-timer/docs/plans/2026-03-27-ddd-architecture-plan.md
POL Mickaël 2893013093 docs: README, CLAUDE.md, changelog, plans de conception
- README : fonctionnalités, installation, build, tests (302 + 20 E2E),
  couverture 94%, workflow mise à jour latest.yml, changelog v1.1.6
- CLAUDE.md : règles de collaboration, architecture, conventions
- Plans de conception : DDD, electron-updater, accouplement, toast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 05:43:38 +02:00

2114 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DDD Hexagonal Architecture — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refactorer le monolithe `src/index.html` (3130 lignes) en architecture DDD hexagonale avec CQRS, TypeScript strict, Vite, et tests complets (unit, functional, regression).
**Architecture:** Domain-Driven Design avec couches strictes (Domain → Application → Infrastructure → Presentation). Le domaine est pur TypeScript sans dépendance. Les ports (interfaces) sont définis dans le domaine, implémentés dans l'infrastructure. CQRS sépare les commands (écriture) des queries (lecture). Injection de dépendances par constructeur.
**Tech Stack:** Vite + vite-plugin-electron, TypeScript strict, Vitest, Electron 32
---
## Phase 1 — Setup tooling
### Task 1: Initialiser Vite + TypeScript + Vitest
**Files:**
- Create: `vite.config.ts`
- Create: `vitest.config.ts`
- Create: `tsconfig.json`
- Modify: `package.json`
- Create: `src/renderer/index.ts` (point d'entrée renderer vide)
- Create: `src/index.html` (shell HTML minimal — remplace l'ancien après migration complète)
**Step 1: Installer les dépendances**
```bash
npm install --save-dev vite vite-plugin-electron vite-plugin-electron-renderer typescript vitest @types/node
```
**Step 2: Créer tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist-ts",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@domain/*": ["src/domain/*"],
"@application/*": ["src/application/*"],
"@infrastructure/*": ["src/infrastructure/*"],
"@presentation/*": ["src/presentation/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-ts", "tests"]
}
```
**Step 3: Créer vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import { resolve } from 'path';
export default defineConfig({
plugins: [
electron([
{
entry: 'src/infrastructure/electron/main.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: { external: ['electron'] },
},
},
},
{
entry: 'src/infrastructure/electron/preload.ts',
onstart(args) { args.reload(); },
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: { external: ['electron'] },
},
},
},
]),
renderer(),
],
resolve: {
alias: {
'@domain': resolve(__dirname, 'src/domain'),
'@application': resolve(__dirname, 'src/application'),
'@infrastructure': resolve(__dirname, 'src/infrastructure'),
'@presentation': resolve(__dirname, 'src/presentation'),
},
},
root: '.',
build: {
outDir: 'dist-vite',
},
});
```
**Step 4: Créer vitest.config.ts**
```typescript
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@domain': resolve(__dirname, 'src/domain'),
'@application': resolve(__dirname, 'src/application'),
'@infrastructure': resolve(__dirname, 'src/infrastructure'),
'@presentation': resolve(__dirname, 'src/presentation'),
},
},
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/domain/**', 'src/application/**'],
thresholds: { branches: 80, functions: 80, lines: 80 },
},
},
});
```
**Step 5: Mettre à jour package.json scripts**
Ajouter dans `"scripts"`:
```json
{
"dev": "vite",
"build:vite": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
```
**Step 6: Créer les dossiers de structure**
```bash
mkdir -p src/domain/{entities,value-objects,services,ports,events}
mkdir -p src/application/{commands,queries,handlers,event-handlers}
mkdir -p src/infrastructure/{persistence,notifications,alarm,electron}
mkdir -p src/presentation/{components,styles,state}
mkdir -p tests/{unit/domain,unit/application,functional,regression}
```
**Step 7: Vérifier que vitest fonctionne**
Créer `tests/unit/domain/smoke.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
describe('smoke test', () => {
it('should run', () => {
expect(1 + 1).toBe(2);
});
});
```
Run: `npx vitest run`
Expected: 1 test PASS
**Step 8: Commit**
```
feat: setup Vite + TypeScript + Vitest tooling
```
---
## Phase 2 — Domain Value Objects
### Task 2: Value Object — GaugeType
**Files:**
- Create: `src/domain/value-objects/GaugeType.ts`
- Test: `tests/unit/domain/GaugeType.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { GaugeType, GAUGE_DEFS, StatType } from '@domain/value-objects/GaugeType';
describe('GaugeType', () => {
it('should have 6 gauge types', () => {
const types: GaugeType[] = ['baffeur', 'caresseur', 'foudroyeur', 'abreuvoir', 'dragofesse', 'mangeoire'];
expect(types).toHaveLength(6);
types.forEach(t => expect(GAUGE_DEFS[t]).toBeDefined());
});
it('baffeur decreases serenite', () => {
expect(GAUGE_DEFS.baffeur.stat).toBe('serenite');
expect(GAUGE_DEFS.baffeur.dir).toBe(-1);
});
it('caresseur increases serenite', () => {
expect(GAUGE_DEFS.caresseur.stat).toBe('serenite');
expect(GAUGE_DEFS.caresseur.dir).toBe(1);
});
it('mangeoire is XP type', () => {
expect(GAUGE_DEFS.mangeoire.isXp).toBe(true);
expect(GAUGE_DEFS.mangeoire.stat).toBe('xp');
});
});
```
**Step 2: Run test — expected FAIL**
Run: `npx vitest run tests/unit/domain/GaugeType.test.ts`
**Step 3: Implement**
```typescript
export type GaugeType = 'baffeur' | 'caresseur' | 'foudroyeur' | 'abreuvoir' | 'dragofesse' | 'mangeoire';
export type StatType = 'serenite' | 'endurance' | 'maturite' | 'amour' | 'xp';
export interface GaugeDef {
readonly label: string;
readonly icon: string;
readonly cssVar: string;
readonly stat: StatType;
readonly dir: -1 | 1;
readonly isXp?: true;
}
export const GAUGE_DEFS: Readonly<Record<GaugeType, GaugeDef>> = {
baffeur: { label: 'Baffeur', icon: '', cssVar: '--ser', stat: 'serenite', dir: -1 },
caresseur: { label: 'Caresseur', icon: '', cssVar: '--ser', stat: 'serenite', dir: 1 },
foudroyeur: { label: 'Foudroyeur', icon: '⚡', cssVar: '--end', stat: 'endurance', dir: 1 },
abreuvoir: { label: 'Abreuvoir', icon: '💧', cssVar: '--mat', stat: 'maturite', dir: 1 },
dragofesse: { label: 'Dragofesse', icon: '❤', cssVar: '--amour', stat: 'amour', dir: 1 },
mangeoire: { label: 'Mangeoire', icon: '🍖', cssVar: '--xp', stat: 'xp', dir: 1, isXp: true },
};
export interface StatDef {
readonly label: string;
readonly min: number;
readonly max: number;
readonly cssVar: string;
readonly isLevel?: true;
}
export const STAT_DEFS: Readonly<Record<StatType, StatDef>> = {
serenite: { label: 'Sérénité', min: -5000, max: 5000, cssVar: '--ser' },
endurance: { label: 'Endurance', min: 0, max: 20000, cssVar: '--end' },
maturite: { label: 'Maturité', min: 0, max: 20000, cssVar: '--mat' },
amour: { label: 'Amour', min: 0, max: 20000, cssVar: '--amour' },
xp: { label: 'Niveau', min: 1, max: 200, cssVar: '--xp', isLevel: true },
};
export const DEFAULT_TARGETS: Readonly<Record<GaugeType, number>> = {
baffeur: -5000, caresseur: 40, foudroyeur: 20000, abreuvoir: 20000, dragofesse: 20000, mangeoire: 100,
};
export function targetRange(gid: GaugeType): { min: number; max: number } {
const def = GAUGE_DEFS[gid];
if (def.isXp) return { min: 1, max: 200 };
const sd = STAT_DEFS[def.stat];
if (def.dir < 0) return { min: sd.min, max: 0 };
if (def.dir > 0 && sd.min < 0) return { min: 0, max: sd.max };
return { min: sd.min, max: sd.max };
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add GaugeType value object`
---
### Task 3: Value Object — Tier
**Files:**
- Create: `src/domain/value-objects/Tier.ts`
- Test: `tests/unit/domain/Tier.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { tierRate, tierNum, TIER_THRESHOLDS } from '@domain/value-objects/Tier';
describe('Tier', () => {
it('tier 1: level 0-40000, rate 10', () => {
expect(tierRate(0)).toBe(10);
expect(tierRate(39999)).toBe(10);
expect(tierRate(40000)).toBe(10);
expect(tierNum(0)).toBe(1);
});
it('tier 2: level 40001-70000, rate 20', () => {
expect(tierRate(40001)).toBe(20);
expect(tierRate(70000)).toBe(20);
expect(tierNum(50000)).toBe(2);
});
it('tier 3: level 70001-90000, rate 30', () => {
expect(tierRate(70001)).toBe(30);
expect(tierRate(90000)).toBe(30);
expect(tierNum(80000)).toBe(3);
});
it('tier 4: level 90001+, rate 40', () => {
expect(tierRate(90001)).toBe(40);
expect(tierRate(100000)).toBe(40);
expect(tierNum(95000)).toBe(4);
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
```typescript
export const TIER_THRESHOLDS = [
{ lo: 90000, rate: 40, num: 4 },
{ lo: 70000, rate: 30, num: 3 },
{ lo: 40000, rate: 20, num: 2 },
{ lo: 0, rate: 10, num: 1 },
] as const;
export function tierRate(level: number): number {
return level > 90000 ? 40 : level > 70000 ? 30 : level > 40000 ? 20 : 10;
}
export function tierNum(level: number): number {
return level > 90000 ? 4 : level > 70000 ? 3 : level > 40000 ? 2 : 1;
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add Tier value object`
---
### Task 4: Value Object — Race + Gender
**Files:**
- Create: `src/domain/value-objects/Race.ts`
- Create: `src/domain/value-objects/Gender.ts`
- Test: `tests/unit/domain/Race.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { RACE_GEN, GEN_COLORS, isBaseRace, generationOf } from '@domain/value-objects/Race';
import { Gender } from '@domain/value-objects/Gender';
describe('Race', () => {
it('base races are gen 1', () => {
expect(generationOf('Rousse')).toBe(1);
expect(generationOf('Amande')).toBe(1);
expect(generationOf('Dorée')).toBe(1);
expect(isBaseRace('Rousse')).toBe(true);
});
it('gen 2 races are correctly mapped', () => {
expect(generationOf('Dorée et Rousse')).toBe(2);
expect(isBaseRace('Dorée et Rousse')).toBe(false);
});
it('GEN_COLORS has 10 entries', () => {
expect(Object.keys(GEN_COLORS)).toHaveLength(10);
});
});
describe('Gender', () => {
it('has male, female, neutral', () => {
const values: Gender[] = ['m', 'f', 'n'];
expect(values).toHaveLength(3);
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement Gender.ts**
```typescript
export type Gender = 'm' | 'f' | 'n';
```
**Step 3b: Implement Race.ts**
```typescript
// Copier RACES_DATA, BREEDING_RECIPES, RACE_GEN, GEN_COLORS, RACE_BASE_COLORS depuis index.html
// Transformer en exports TypeScript typés
export const GEN_COLORS: Readonly<Record<number, string>> = {
1:'#c8622a',2:'#e8b820',3:'#6040b0',4:'#2a8acc',5:'#c03050',
6:'#d040a0',7:'#c8c0a0',8:'#20a8b0',9:'#28a058',10:'#8050a0',
};
export const RACE_BASE_COLORS: Readonly<Record<string, string>> = {
'Rousse':'#c8622a','Amande':'#d4b48a','Dorée':'#e8b820',
'Ebène':'#2a2a2a','Indigo':'#6040b0','Pourpre':'#c03050',
'Orchidée':'#d040a0','Ivoire':'#c8c0a0','Turquoise':'#20a8b0',
'Emeraude':'#28a058','Prune':'#8050a0',
};
export interface RaceData {
readonly name: string;
readonly stats: readonly string[];
readonly parents: readonly [string, string] | null;
readonly icon: string;
}
// RACES_DATA: copié intégralement depuis index.html lignes 510-592
export const RACES_DATA: Readonly<Record<number, readonly RaceData[]>> = {
// ... (copier l'intégralité depuis le monolithe)
};
// Lookup table: baby → [parentA, parentB]
export const BREEDING_RECIPES: Readonly<Record<string, readonly [string, string]>> = {
// ... (copier l'intégralité depuis index.html lignes 597-661)
};
// Derived lookups — computed at module load
export const RACE_GEN: Record<string, number> = {};
// Initialiser RACE_GEN
['Rousse','Amande','Dorée'].forEach(n => { RACE_GEN[n] = 1; });
for (const [genStr, races] of Object.entries(RACES_DATA)) {
const gen = Number(genStr);
for (const r of races) { RACE_GEN[r.name] = gen; }
}
export function generationOf(raceName: string): number {
return RACE_GEN[raceName] ?? 0;
}
export function isBaseRace(raceName: string): boolean {
return generationOf(raceName) === 1;
}
export function raceColor(name: string): string {
const order = ['Emeraude','Prune','Ivoire','Turquoise','Orchidée','Pourpre','Indigo','Ebène','Dorée','Amande','Rousse'];
for (const k of order) { if (name.includes(k)) return RACE_BASE_COLORS[k] ?? '#888'; }
return '#888';
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add Race and Gender value objects`
---
### Task 5: Value Object — XpTable
**Files:**
- Create: `src/domain/value-objects/XpTable.ts`
- Test: `tests/unit/domain/XpTable.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
describe('XpTable', () => {
it('level 1 requires 0 XP', () => {
expect(xpForLevel(1)).toBe(0);
});
it('level 200 requires 867582 XP', () => {
expect(xpForLevel(200)).toBe(867582);
});
it('clamps below 1', () => {
expect(xpForLevel(0)).toBe(0);
expect(xpForLevel(-5)).toBe(0);
});
it('clamps above 200', () => {
expect(xpForLevel(250)).toBe(867582);
});
it('levelFromXp returns correct level', () => {
expect(levelFromXp(0)).toBe(1);
expect(levelFromXp(18)).toBe(1);
expect(levelFromXp(19)).toBe(2);
expect(levelFromXp(867582)).toBe(200);
expect(levelFromXp(999999)).toBe(200);
});
it('roundtrip: xpForLevel → levelFromXp', () => {
for (let lvl = 1; lvl <= 200; lvl++) {
expect(levelFromXp(xpForLevel(lvl))).toBe(lvl);
}
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement** — copier XP_RAW + xpForLevel + levelFromXp depuis index.html lignes 357-365
```typescript
export const XP_RAW: Readonly<Record<number, number>> = {
1:0,2:19,3:49,4:96,5:161,6:246,7:353,8:481,9:633,10:809,
// ... (copier intégralement les 200 entrées)
200:867582
};
export function xpForLevel(lvl: number): number {
return XP_RAW[Math.min(200, Math.max(1, Math.round(lvl)))] ?? 0;
}
export function levelFromXp(xp: number): number {
if (xp >= XP_RAW[200]!) return 200;
for (let i = 199; i >= 1; i--) {
if (XP_RAW[i]! <= xp) return i;
}
return 1;
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add XpTable value object`
---
## Phase 3 — Domain Services (pure math)
### Task 6: GaugeCalculator
**Files:**
- Create: `src/domain/services/GaugeCalculator.ts`
- Test: `tests/unit/domain/GaugeCalculator.test.ts`
**Step 1: Write failing tests**
```typescript
import { describe, it, expect } from 'vitest';
import { gainedIn, timeToGain, gaugeAfter, elapsed } from '@domain/services/GaugeCalculator';
describe('GaugeCalculator', () => {
describe('gainedIn', () => {
it('tier 1 only: 100 pts in 100 sec (10 ticks × 10pts)', () => {
expect(gainedIn(100, 100)).toBe(100);
});
it('zero seconds = zero gain', () => {
expect(gainedIn(50000, 0)).toBe(0);
});
it('crosses tier boundary 2→1', () => {
// Start at 40010, 10 sec = 1 tick. Tier 2 rate = 20. Gain = 20 but only 10 in tier 2
expect(gainedIn(40010, 10)).toBe(10);
});
it('large gauge drains through multiple tiers', () => {
// Start at 100000 (tier 4), 5000 sec = 500 ticks
// Tier 4: 100000→90000 = 10000/40 = 250 ticks → gain 10000
// Tier 3: 90000→70000 = 20000/30 = 666.67 → 250 remaining ticks → 250*30 = 7500
// Total = 17500
expect(gainedIn(100000, 5000)).toBe(17500);
});
});
describe('timeToGain', () => {
it('zero points = zero time', () => {
expect(timeToGain(50000, 0)).toBe(0);
});
it('negative points = zero time', () => {
expect(timeToGain(50000, -10)).toBe(0);
});
it('tier 1: 100 pts = 100 sec', () => {
expect(timeToGain(100, 100)).toBe(100);
});
it('returns Infinity if level too low', () => {
expect(timeToGain(0, 100)).toBe(Infinity);
});
});
describe('gaugeAfter', () => {
it('tier 1: level 100 after 100 sec = 0', () => {
expect(gaugeAfter(100, 100)).toBe(0);
});
it('does not go below 0', () => {
expect(gaugeAfter(50, 1000)).toBe(0);
});
it('no time = same level', () => {
expect(gaugeAfter(50000, 0)).toBe(50000);
});
});
describe('elapsed', () => {
it('no start time = 0', () => {
expect(elapsed({ startTime: null, running: false, pausedAt: null, pausedMs: 0 })).toBe(0);
});
it('running timer', () => {
const now = Date.now();
const timer = { startTime: now - 10000, running: true, pausedAt: null, pausedMs: 0 };
const el = elapsed(timer);
expect(el).toBeGreaterThanOrEqual(9.9);
expect(el).toBeLessThanOrEqual(10.5);
});
it('paused timer', () => {
const now = Date.now();
const timer = { startTime: now - 10000, running: false, pausedAt: now - 5000, pausedMs: 0 };
expect(elapsed(timer)).toBeCloseTo(5, 0);
});
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
```typescript
export interface TimerState {
readonly startTime: number | null;
readonly running: boolean;
readonly pausedAt: number | null;
readonly pausedMs: number;
}
export function gainedIn(lvl: number, sec: number): number {
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let g = Math.min(Math.max(lvl, 0), 100000);
let tl = Math.floor(sec / 10);
let out = 0;
for (const { lo, r } of T) {
if (g <= lo || tl <= 0) continue;
const a = g - lo;
const m = Math.floor(a / r);
const u = Math.min(m, tl);
out += u * r;
tl -= u;
g = lo;
}
return out;
}
export function timeToGain(lvl: number, pts: number): number {
if (pts <= 0) return 0;
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let s = 0, rem = pts;
let g = Math.min(Math.max(lvl, 0), 100000);
for (const { lo, r } of T) {
if (g <= lo || rem <= 0) continue;
const a = g - lo;
const d = Math.min(rem, a);
s += Math.ceil(d / r) * 10;
rem -= d;
g = lo;
}
return rem > 0 ? Infinity : s;
}
export function gaugeAfter(lvl: number, sec: number): number {
const T = [
{ lo: 90000, r: 40 },
{ lo: 70000, r: 30 },
{ lo: 40000, r: 20 },
{ lo: 0, r: 10 },
];
let g = Math.min(Math.max(lvl, 0), 100000);
let tl = Math.floor(sec / 10);
for (const { lo, r } of T) {
if (g <= lo || tl <= 0) continue;
const a = g - lo;
const m = Math.floor(a / r);
const u = Math.min(m, tl);
g -= u * r;
tl -= u;
}
return g;
}
export function elapsed(timer: TimerState): number {
if (!timer.startTime) return 0;
if (timer.running) return (Date.now() - timer.startTime - timer.pausedMs) / 1000;
if (timer.pausedAt) return (timer.pausedAt - timer.startTime - timer.pausedMs) / 1000;
return 0;
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add GaugeCalculator service`
---
### Task 7: BreedingService
**Files:**
- Create: `src/domain/services/BreedingService.ts`
- Test: `tests/unit/domain/BreedingService.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';
describe('BreedingService', () => {
const svc = new BreedingService();
it('deduces baby from two parents', () => {
expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
expect(svc.deduceBaby('Dorée', 'Rousse')).toBe('Dorée et Rousse');
});
it('returns null for incompatible parents', () => {
expect(svc.deduceBaby('Rousse', 'Rousse')).toBeNull();
});
it('lists compatible partners for a race', () => {
const partners = svc.getCompatiblePartners('Rousse');
expect(partners.length).toBeGreaterThan(0);
expect(partners.some(p => p.partner === 'Dorée')).toBe(true);
});
it('gets parents for a baby race', () => {
const parents = svc.getParents('Dorée et Rousse');
expect(parents).toEqual(['Rousse', 'Dorée']);
});
it('returns null parents for base race', () => {
expect(svc.getParents('Rousse')).toBeNull();
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
```typescript
import { BREEDING_RECIPES } from '@domain/value-objects/Race';
interface PartnerInfo {
readonly partner: string;
readonly baby: string;
readonly gen: number;
}
export class BreedingService {
private readonly byParents: Map<string, string>;
private readonly partners: Map<string, PartnerInfo[]>;
constructor() {
this.byParents = new Map();
this.partners = new Map();
for (const [baby, [a, b]] of Object.entries(BREEDING_RECIPES)) {
const gen = (RACE_GEN[baby] ?? 0);
// Bidirectional key
this.byParents.set(`${a}|${b}`, baby);
this.byParents.set(`${b}|${a}`, baby);
// Partners lookup
if (!this.partners.has(a)) this.partners.set(a, []);
this.partners.get(a)!.push({ partner: b, baby, gen });
if (a !== b) {
if (!this.partners.has(b)) this.partners.set(b, []);
this.partners.get(b)!.push({ partner: a, baby, gen });
}
}
}
deduceBaby(parent1: string, parent2: string): string | null {
return this.byParents.get(`${parent1}|${parent2}`) ?? null;
}
getCompatiblePartners(race: string): readonly PartnerInfo[] {
return this.partners.get(race) ?? [];
}
getParents(babyRace: string): readonly [string, string] | null {
const recipe = BREEDING_RECIPES[babyRace];
return recipe ? [recipe[0], recipe[1]] : null;
}
}
```
Note: importer `RACE_GEN` depuis `@domain/value-objects/Race`.
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add BreedingService`
---
### Task 8: SerenityCalculator + XpCalculator
**Files:**
- Create: `src/domain/services/SerenityCalculator.ts`
- Create: `src/domain/services/XpCalculator.ts`
- Test: `tests/unit/domain/SerenityCalculator.test.ts`
- Test: `tests/unit/domain/XpCalculator.test.ts`
**Step 1: Write failing tests**
SerenityCalculator.test.ts:
```typescript
import { describe, it, expect } from 'vitest';
import { SerenityCalculator } from '@domain/services/SerenityCalculator';
describe('SerenityCalculator', () => {
const calc = new SerenityCalculator();
it('returns done when target reached', () => {
const eta = calc.computeEta({
currentSerenite: -5000,
target: -5000,
activeGauges: ['baffeur'],
gaugeLevels: { baffeur: 50000 },
});
expect(eta.done).toBe(true);
});
it('needs baffeur to decrease serenite', () => {
const eta = calc.computeEta({
currentSerenite: 0,
target: -5000,
activeGauges: [],
gaugeLevels: {},
});
expect(eta.needsGauge).toBe('baffeur');
});
it('computes time to target', () => {
const eta = calc.computeEta({
currentSerenite: 0,
target: -5000,
activeGauges: ['baffeur'],
gaugeLevels: { baffeur: 50000 },
});
expect(eta.done).toBe(false);
expect(eta.seconds).toBeGreaterThan(0);
expect(eta.seconds).toBeLessThan(Infinity);
});
});
```
XpCalculator.test.ts:
```typescript
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';
describe('XpCalculator', () => {
const calc = new XpCalculator();
it('returns done when level reached', () => {
const eta = calc.computeEta({ currentLevel: 100, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(eta.done).toBe(true);
});
it('needs mangeoire gauge', () => {
const eta = calc.computeEta({ currentLevel: 1, target: 100, gaugeLevels: {}, activeGauges: [] });
expect(eta.needsGauge).toBe('mangeoire');
});
it('computes seconds to level up', () => {
const eta = calc.computeEta({ currentLevel: 1, target: 10, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(eta.done).toBe(false);
expect(eta.seconds).toBeGreaterThan(0);
});
});
```
**Step 2: Run tests — expected FAIL**
**Step 3: Implement both**
SerenityCalculator.ts:
```typescript
import { timeToGain } from './GaugeCalculator';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export interface SerenityEtaInput {
currentSerenite: number;
target: number | null;
activeGauges: readonly string[];
gaugeLevels: Readonly<Record<string, number>>;
}
export interface EtaResult {
done: boolean;
seconds: number;
needsGauge?: GaugeType;
}
export class SerenityCalculator {
computeEta(input: SerenityEtaInput): EtaResult {
const { currentSerenite, target, activeGauges, gaugeLevels } = input;
if (target === null || target === undefined) return { done: false, seconds: 0 };
const diff = target - currentSerenite;
if (diff === 0) return { done: true, seconds: 0 };
const needUp = diff > 0;
const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';
if (!activeGauges.includes(gid)) return { done: false, seconds: Infinity, needsGauge: gid };
const gl = gaugeLevels[gid] ?? 0;
const pts = Math.abs(diff);
const sec = timeToGain(gl, pts);
return { done: false, seconds: sec };
}
}
```
XpCalculator.ts:
```typescript
import { tierRate } from '@domain/value-objects/Tier';
import { xpForLevel } from '@domain/value-objects/XpTable';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export interface XpEtaInput {
currentLevel: number;
target: number | null;
gaugeLevels: Readonly<Record<string, number>>;
activeGauges: readonly string[];
}
export interface EtaResult {
done: boolean;
seconds: number;
needsGauge?: GaugeType;
}
export class XpCalculator {
computeEta(input: XpEtaInput): EtaResult {
const { currentLevel, target, gaugeLevels, activeGauges } = input;
if (target === null || target === undefined) return { done: false, seconds: 0 };
if (currentLevel >= target) return { done: true, seconds: 0 };
if (!activeGauges.includes('mangeoire')) return { done: false, seconds: Infinity, needsGauge: 'mangeoire' };
const gl = gaugeLevels.mangeoire ?? 0;
const xpNeeded = Math.max(0, xpForLevel(target) - xpForLevel(currentLevel));
if (xpNeeded <= 0) return { done: true, seconds: 0 };
const rate = Math.max(10, tierRate(gl));
const sec = Math.ceil(xpNeeded / rate) * 10;
return { done: false, seconds: sec };
}
}
```
**Step 4: Run tests — expected PASS**
**Step 5: Commit** `feat(domain): add SerenityCalculator and XpCalculator`
---
### Task 9: ReapproCalculator
**Files:**
- Create: `src/domain/services/ReapproCalculator.ts`
- Test: `tests/unit/domain/ReapproCalculator.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { ReapproCalculator } from '@domain/services/ReapproCalculator';
describe('ReapproCalculator', () => {
const calc = new ReapproCalculator();
it('computes gen1 needs for a gen2 race', () => {
const result = calc.compute({
target: 'Dorée et Rousse',
qty: 4,
repro: {},
inverted: {},
});
expect(result.steps.length).toBe(1);
expect(result.steps[0]!.parentA).toBe('Rousse');
expect(result.steps[0]!.parentB).toBe('Dorée');
expect(result.gen1Needs.length).toBe(2);
const totalGen1 = result.gen1Needs.reduce((s, n) => s + n.total, 0);
expect(totalGen1).toBe(8); // 4 couples = 4 Rousse + 4 Dorée
});
it('handles gender inversion', () => {
const result = calc.compute({
target: 'Dorée et Rousse',
qty: 4,
repro: {},
inverted: { 'Dorée et Rousse': true },
});
// Inverted: parentA=Dorée, parentB=Rousse
expect(result.steps[0]!.parentA).toBe('Dorée');
expect(result.steps[0]!.parentB).toBe('Rousse');
});
it('subtracts breeders from couple count', () => {
const result = calc.compute({
target: 'Dorée et Rousse',
qty: 4,
repro: { 'Dorée et Rousse': 2 },
inverted: {},
});
expect(result.steps[0]!.couples).toBe(2);
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
```typescript
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
export interface ReapproInput {
target: string;
qty: number;
repro: Readonly<Record<string, number>>;
inverted: Readonly<Record<string, boolean>>;
}
export interface ReapproStep {
race: string;
gen: number;
qty: number;
couples: number;
parentA: string;
parentB: string;
repro: number;
}
export interface Gen1Need {
name: string;
total: number;
m: number;
f: number;
}
export interface ReapproResult {
steps: readonly ReapproStep[];
gen1Needs: readonly Gen1Need[];
totalGen1: number;
}
export class ReapproCalculator {
compute(input: ReapproInput): ReapproResult {
const { target, qty, repro, inverted } = input;
if (!target || !BREEDING_RECIPES[target]) return { steps: [], gen1Needs: [], totalGen1: 0 };
const needs: Record<string, { total: number; m: number; f: number }> = {};
needs[target] = { total: qty, m: 0, f: 0 };
const steps: ReapproStep[] = [];
const processed = new Set<string>();
const targetGen = RACE_GEN[target] ?? 2;
for (let gen = targetGen; gen >= 2; gen--) {
const racesAtGen = Object.keys(needs).filter(
r => !processed.has(r) && (RACE_GEN[r] ?? 0) === gen && BREEDING_RECIPES[r]
);
for (const race of racesAtGen) {
const Q = needs[race]!.total;
if (Q <= 0) { processed.add(race); continue; }
const R = repro[race] ?? 0;
const couplesReal = (2 * R >= Q) ? Math.ceil(Q / 2) : (R > 0 ? Q - R : Q);
const isInv = inverted[race] ?? false;
const [rawA, rawB] = BREEDING_RECIPES[race]!;
const a = isInv ? rawB : rawA;
const b = isInv ? rawA : rawB;
if (!needs[a]) needs[a] = { total: 0, m: 0, f: 0 };
if (!needs[b]) needs[b] = { total: 0, m: 0, f: 0 };
needs[a]!.total += couplesReal;
needs[a]!.m += couplesReal;
needs[b]!.total += couplesReal;
needs[b]!.f += couplesReal;
steps.push({ race, gen, qty: Q, couples: couplesReal, parentA: a, parentB: b, repro: R });
processed.add(race);
}
}
const gen1Needs: Gen1Need[] = Object.entries(needs)
.filter(([n]) => !BREEDING_RECIPES[n] && needs[n]!.total > 0)
.sort((a, b) => b[1].total - a[1].total)
.map(([name, d]) => ({ name, total: d.total, m: d.m, f: d.f }));
const totalGen1 = gen1Needs.reduce((s, n) => s + n.total, 0);
return { steps, gen1Needs, totalGen1 };
}
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add ReapproCalculator`
---
### Task 10: InventaireCalculator
**Files:**
- Create: `src/domain/services/InventaireCalculator.ts`
- Test: `tests/unit/domain/InventaireCalculator.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { InventaireCalculator } from '@domain/services/InventaireCalculator';
describe('InventaireCalculator', () => {
const calc = new InventaireCalculator();
it('empty inventory produces nothing', () => {
const result = calc.compute({});
expect(result.generations).toHaveLength(0);
});
it('2 base races with ♂+♀ produce a gen2 baby', () => {
const result = calc.compute({
'Rousse': { m: 1, f: 0 },
'Dorée': { m: 0, f: 1 },
});
expect(result.generations.length).toBeGreaterThanOrEqual(1);
const gen2 = result.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
expect(gen2!.crossings.some(c => c.name === 'Dorée et Rousse')).toBe(true);
});
it('requires ♂+♀ constraint (no same-gender breeding)', () => {
const result = calc.compute({
'Rousse': { m: 2, f: 0 },
'Dorée': { m: 2, f: 0 },
});
// Both male — no breeding possible
expect(result.generations).toHaveLength(0);
});
it('cascades babies into next generation', () => {
const result = calc.compute({
'Rousse': { m: 2, f: 2 },
'Amande': { m: 2, f: 2 },
'Dorée': { m: 2, f: 2 },
});
// Should produce gen 2 babies, which cascade to gen 3
const gen2 = result.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
```typescript
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
export interface Stock { m: number; f: number; n: number; }
export interface CrossingDetail { aSex: string; bSex: string; }
export interface CrossingResult { name: string; qty: number; parents: readonly [string, string]; gen: number; details: CrossingDetail[]; }
export interface GenerationResult { gen: number; crossings: CrossingResult[]; }
export interface InventaireResult { generations: GenerationResult[]; remaining: Record<string, Stock>; }
export class InventaireCalculator {
compute(inventaire: Readonly<Record<string, { m: number; f: number }>>): InventaireResult {
const avail: Record<string, Stock> = {};
for (const [k, v] of Object.entries(inventaire)) {
if ((v.m + v.f) > 0) avail[k] = { m: v.m, f: v.f, n: 0 };
}
if (Object.keys(avail).length === 0) return { generations: [], remaining: {} };
const hasMale = (s: Stock) => s.m > 0 || s.n > 0;
const hasFemale = (s: Stock) => s.f > 0 || s.n > 0;
const takeMale = (s: Stock) => { if (s.m > 0) s.m--; else s.n--; };
const takeFemale = (s: Stock) => { if (s.f > 0) s.f--; else s.n--; };
const totalOf = (s: Stock) => s.m + s.f + s.n;
const generations: GenerationResult[] = [];
for (let gen = 2; gen <= 10; gen++) {
const crossingsAtGen = Object.entries(BREEDING_RECIPES)
.filter(([name]) => (RACE_GEN[name] ?? 0) === gen)
.map(([name, parents]) => ({ name, parents }));
const genResults: CrossingResult[] = [];
let more = true;
while (more) {
more = false;
for (const cr of crossingsAtGen) {
const [a, b] = cr.parents;
const sa = avail[a] ?? { m: 0, f: 0, n: 0 };
const sb = avail[b] ?? { m: 0, f: 0, n: 0 };
let ok = false, aSex = '', bSex = '';
if (a === b) {
if (totalOf(sa) >= 2 && hasMale(sa) && hasFemale(sa)) {
takeMale(sa); takeFemale(sa); aSex = '♂'; bSex = '♀'; ok = true;
}
} else if (hasMale(sa) && hasFemale(sb)) {
takeMale(sa); takeFemale(sb); aSex = '♂'; bSex = '♀'; ok = true;
} else if (hasFemale(sa) && hasMale(sb)) {
takeFemale(sa); takeMale(sb); aSex = '♀'; bSex = '♂'; ok = true;
}
if (ok) {
if (!avail[a]) avail[a] = sa;
if (!avail[b]) avail[b] = sb;
if (!avail[cr.name]) avail[cr.name] = { m: 0, f: 0, n: 0 };
avail[cr.name]!.n++;
let entry = genResults.find(r => r.name === cr.name);
if (!entry) { entry = { name: cr.name, qty: 0, parents: cr.parents as [string, string], gen, details: [] }; genResults.push(entry); }
entry.qty++;
entry.details.push({ aSex, bSex });
more = true;
}
}
}
if (genResults.length > 0) generations.push({ gen, crossings: genResults });
}
return { generations, remaining: avail };
}
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add InventaireCalculator`
---
## Phase 4 — Domain Entities + Ports
### Task 11: Entités Dragodinde + Enclos
**Files:**
- Create: `src/domain/entities/Dragodinde.ts`
- Create: `src/domain/entities/Enclos.ts`
- Create: `src/domain/entities/Accouplement.ts`
- Test: `tests/unit/domain/Enclos.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect } from 'vitest';
import { createEnclos, addDragodinde, removeDragodinde, MAX_DD } from '@domain/entities/Enclos';
import { createDragodinde } from '@domain/entities/Dragodinde';
describe('Enclos', () => {
it('creates with defaults', () => {
const enc = createEnclos(1);
expect(enc.id).toBe(1);
expect(enc.name).toBe('Enclos 1');
expect(enc.dragodindes).toHaveLength(0);
expect(enc.activeGauges).toHaveLength(0);
expect(enc.timer.running).toBe(false);
});
it('adds a dragodinde', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
expect(enc.dragodindes).toHaveLength(1);
expect(enc.dragodindes[0]!.name).toBe('DD 1');
});
it('limits to MAX_DD', () => {
let enc = createEnclos(1);
for (let i = 0; i < MAX_DD + 2; i++) enc = addDragodinde(enc);
expect(enc.dragodindes).toHaveLength(MAX_DD);
});
it('removes a dragodinde', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
const ddId = enc.dragodindes[0]!.id;
enc = removeDragodinde(enc, ddId);
expect(enc.dragodindes).toHaveLength(0);
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
Dragodinde.ts:
```typescript
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType';
import type { Gender } from '@domain/value-objects/Gender';
export interface DragodindeStats {
serenite: number;
endurance: number;
maturite: number;
amour: number;
xp: number;
}
export interface Dragodinde {
readonly id: number;
name: string;
race: string;
gender: Gender;
stats: DragodindeStats;
targets: Record<GaugeType, number>;
sereniteTarget: number | null;
levelTarget: number | null;
reproducteur: number;
}
export function createDragodinde(id: number): Dragodinde {
return {
id,
name: `DD ${id}`,
race: '',
gender: 'n',
stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 },
targets: { ...DEFAULT_TARGETS },
sereniteTarget: null,
levelTarget: null,
reproducteur: 0,
};
}
```
Enclos.ts:
```typescript
import type { Dragodinde } from './Dragodinde';
import { createDragodinde } from './Dragodinde';
import type { GaugeType } from '@domain/value-objects/GaugeType';
export const MAX_DD = 10;
export const MAX_ENCLOS = 6;
export const MAX_GAUGES = 2;
export interface TimerData {
running: boolean;
startTime: number | null;
pausedAt: number | null;
pausedMs: number;
snapGauges: Record<string, number>;
snapStats: Record<string, Record<string, number>>;
}
export interface Enclos {
readonly id: number;
name: string;
activeGauges: GaugeType[];
gaugeLevels: Record<GaugeType, number>;
dragodindes: Dragodinde[];
nextDdId: number;
timer: TimerData;
alerted: Record<string, boolean>;
}
export function createEnclos(id: number): Enclos {
return {
id,
name: `Enclos ${id}`,
activeGauges: [],
gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
dragodindes: [],
nextDdId: 1,
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
alerted: {},
};
}
export function addDragodinde(enc: Enclos): Enclos {
if (enc.dragodindes.length >= MAX_DD) return enc;
const dd = createDragodinde(enc.nextDdId);
return { ...enc, dragodindes: [...enc.dragodindes, dd], nextDdId: enc.nextDdId + 1 };
}
export function removeDragodinde(enc: Enclos, ddId: number): Enclos {
return { ...enc, dragodindes: enc.dragodindes.filter(d => d.id !== ddId) };
}
```
Accouplement.ts:
```typescript
export interface Accouplement {
readonly parentA: string;
readonly parentB: string;
readonly baby: string;
readonly gen: number;
readonly couples: number;
readonly babiesObtained: number;
readonly date: string;
}
export function createAccouplement(
parentA: string, parentB: string, baby: string, gen: number,
couples: number, babiesObtained: number
): Accouplement {
return { parentA, parentB, baby, gen, couples, babiesObtained, date: new Date().toISOString() };
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add Enclos, Dragodinde, Accouplement entities`
---
### Task 12: Ports (interfaces)
**Files:**
- Create: `src/domain/ports/StateRepository.ts`
- Create: `src/domain/ports/NotificationPort.ts`
- Create: `src/domain/ports/AlarmPort.ts`
- Create: `src/domain/ports/UpdatePort.ts`
**Step 1: Implement** (no tests needed — interfaces only)
StateRepository.ts:
```typescript
import type { Enclos } from '@domain/entities/Enclos';
import type { Accouplement } from '@domain/entities/Accouplement';
export interface AppState {
enclos: Enclos[];
activeId: number | string | null;
nextEnclosId: number;
alarmSound: string;
notifsEnabled: boolean;
ntfyTopic: string;
archivedStats: unknown[];
inventaire: Record<string, { m: number; f: number }>;
workflows: unknown[];
accouplements: Accouplement[];
}
export interface StateRepository {
load(): Promise<AppState | null>;
save(state: AppState): void;
}
```
NotificationPort.ts:
```typescript
export interface NotificationPort {
showNotification(title: string, body: string): void;
sendMobileNotification(url: string, title: string, message: string): void;
}
```
AlarmPort.ts:
```typescript
export interface AlarmPort {
play(soundName: string): void;
stop(): void;
}
```
UpdatePort.ts:
```typescript
export interface UpdateInfo {
version: string;
downloadUrl: string;
assetName: string;
releaseNotes: string;
}
export interface UpdatePort {
checkForUpdates(): Promise<UpdateInfo | null>;
downloadAndInstall(info: UpdateInfo): void;
}
```
**Step 2: Commit** `feat(domain): add port interfaces`
---
### Task 13: Domain Events
**Files:**
- Create: `src/domain/events/DomainEvent.ts`
- Create: `src/domain/events/EventBus.ts`
- Test: `tests/unit/domain/EventBus.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect, vi } from 'vitest';
import { EventBus } from '@domain/events/EventBus';
describe('EventBus', () => {
it('dispatches events to subscribers', () => {
const bus = new EventBus();
const handler = vi.fn();
bus.on('timer-completed', handler);
bus.emit({ type: 'timer-completed', enclosId: 1 });
expect(handler).toHaveBeenCalledWith({ type: 'timer-completed', enclosId: 1 });
});
it('ignores unsubscribed events', () => {
const bus = new EventBus();
const handler = vi.fn();
bus.on('timer-completed', handler);
bus.emit({ type: 'accouplement-registered', data: {} });
expect(handler).not.toHaveBeenCalled();
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
DomainEvent.ts:
```typescript
export type DomainEventType = 'timer-completed' | 'gauge-threshold-reached' | 'accouplement-registered' | 'enclos-deleted';
export interface DomainEvent {
readonly type: DomainEventType;
readonly [key: string]: unknown;
}
```
EventBus.ts:
```typescript
import type { DomainEvent, DomainEventType } from './DomainEvent';
type Handler = (event: DomainEvent) => void;
export class EventBus {
private handlers = new Map<DomainEventType, Handler[]>();
on(type: DomainEventType, handler: Handler): void {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type)!.push(handler);
}
emit(event: DomainEvent): void {
const handlers = this.handlers.get(event.type) ?? [];
for (const h of handlers) h(event);
}
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(domain): add EventBus`
---
## Phase 5 — Application Layer (CQRS)
### Task 14: CommandBus + QueryBus
**Files:**
- Create: `src/application/handlers/CommandBus.ts`
- Create: `src/application/handlers/QueryBus.ts`
- Test: `tests/unit/application/CommandBus.test.ts`
**Step 1: Write failing test**
```typescript
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';
describe('CommandBus', () => {
it('dispatches to registered handler', () => {
const bus = new CommandBus();
const handler = vi.fn();
bus.register('start-timer', handler);
bus.execute({ type: 'start-timer', enclosId: 1 });
expect(handler).toHaveBeenCalledWith({ type: 'start-timer', enclosId: 1 });
});
it('throws on unknown command', () => {
const bus = new CommandBus();
expect(() => bus.execute({ type: 'unknown' as any })).toThrow();
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement**
CommandBus.ts:
```typescript
export interface Command {
readonly type: string;
readonly [key: string]: unknown;
}
type CommandHandler<T extends Command = Command> = (cmd: T) => void;
export class CommandBus {
private handlers = new Map<string, CommandHandler>();
register<T extends Command>(type: string, handler: CommandHandler<T>): void {
this.handlers.set(type, handler as CommandHandler);
}
execute(cmd: Command): void {
const handler = this.handlers.get(cmd.type);
if (!handler) throw new Error(`No handler for command: ${cmd.type}`);
handler(cmd);
}
}
```
QueryBus.ts:
```typescript
export interface Query {
readonly type: string;
readonly [key: string]: unknown;
}
type QueryHandler<T extends Query = Query, R = unknown> = (query: T) => R;
export class QueryBus {
private handlers = new Map<string, QueryHandler>();
register<T extends Query, R>(type: string, handler: QueryHandler<T, R>): void {
this.handlers.set(type, handler as QueryHandler);
}
execute<R>(query: Query): R {
const handler = this.handlers.get(query.type);
if (!handler) throw new Error(`No handler for query: ${query.type}`);
return handler(query) as R;
}
}
```
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(application): add CommandBus and QueryBus`
---
### Task 15: Commands — Timer, Enclos, Accouplement
**Files:**
- Create: `src/application/commands/StartTimer.ts`
- Create: `src/application/commands/StopTimer.ts`
- Create: `src/application/commands/CreateEnclos.ts`
- Create: `src/application/commands/DeleteEnclos.ts`
- Create: `src/application/commands/AddDragodinde.ts`
- Create: `src/application/commands/RemoveDragodinde.ts`
- Create: `src/application/commands/UpdateGauge.ts`
- Create: `src/application/commands/RegisterAccouplement.ts`
- Create: `src/application/commands/UpdateSettings.ts`
- Create: `src/application/commands/ResetStats.ts`
- Test: `tests/unit/application/commands.test.ts`
- Test: `tests/functional/timer-workflow.test.ts`
Ce sont les command handlers qui orchestrent domaine + ports. Chaque handler :
1. Lit l'état depuis `StateRepository`
2. Applique la logique domaine
3. Sauvegarde via `StateRepository`
4. Émet des événements domaine si nécessaire
**Step 1: Write failing test (exemple StartTimer)**
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createStartTimerHandler } from '@application/commands/StartTimer';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { EventBus } from '@domain/events/EventBus';
function makeRepo(state: AppState): StateRepository {
return {
load: vi.fn(async () => state),
save: vi.fn(),
};
}
describe('StartTimer', () => {
it('starts timer on enclos', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
enc.activeGauges = ['baffeur'];
enc.gaugeLevels.baffeur = 50000;
const state: AppState = {
enclos: [enc], activeId: 1, nextEnclosId: 2,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
const repo = makeRepo(state);
const events = new EventBus();
const handler = createStartTimerHandler(state, repo, events);
handler({ type: 'start-timer', enclosId: 1 });
expect(state.enclos[0]!.timer.running).toBe(true);
expect(state.enclos[0]!.timer.startTime).not.toBeNull();
expect(repo.save).toHaveBeenCalled();
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement StartTimer.ts (pattern pour tous les handlers)**
```typescript
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { EventBus } from '@domain/events/EventBus';
export interface StartTimerCommand {
type: 'start-timer';
enclosId: number;
}
export function createStartTimerHandler(state: AppState, repo: StateRepository, events: EventBus) {
return (cmd: StartTimerCommand): void => {
const enc = state.enclos.find(e => e.id === cmd.enclosId);
if (!enc || enc.timer.running) return;
if (!enc.activeGauges.length || !enc.dragodindes.length) return;
const now = Date.now();
enc.timer.running = true;
enc.timer.startTime = now;
enc.timer.pausedAt = null;
enc.timer.pausedMs = 0;
// Snapshot gauges
enc.timer.snapGauges = { ...enc.gaugeLevels };
// Snapshot stats
const snapStats: Record<string, Record<string, number>> = {};
for (const dd of enc.dragodindes) {
snapStats[dd.id] = { ...dd.stats };
}
enc.timer.snapStats = snapStats;
enc.alerted = {};
repo.save(state);
};
}
```
Les autres command handlers suivent le même pattern. Implémenter chacun selon la logique existante dans index.html.
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(application): add command handlers`
---
### Task 16: Queries — Dashboard, EnclosDetail, TimerState
**Files:**
- Create: `src/application/queries/GetDashboard.ts`
- Create: `src/application/queries/GetEnclosDetail.ts`
- Create: `src/application/queries/GetTimerState.ts`
- Create: `src/application/queries/GetBreedingOptions.ts`
- Create: `src/application/queries/GetReapproTree.ts`
- Create: `src/application/queries/GetInventaire.ts`
- Test: `tests/unit/application/queries.test.ts`
Pattern similaire aux commands mais retourne des données sans modifier l'état.
**Step 1: Write failing test (exemple GetDashboard)**
```typescript
import { describe, it, expect } from 'vitest';
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import type { AppState } from '@domain/ports/StateRepository';
describe('GetDashboard', () => {
it('aggregates stats from enclos + accouplements', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
const state: AppState = {
enclos: [enc], activeId: 'dashboard', nextEnclosId: 2,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [],
accouplements: [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '2026-01-01' },
],
};
const handler = createGetDashboardHandler(state);
const result = handler({ type: 'get-dashboard' });
expect(result.totalCouples).toBe(5);
expect(result.totalBabies).toBe(3);
expect(result.enclosSummaries).toHaveLength(1);
});
});
```
**Step 2: Run test — expected FAIL**
**Step 3: Implement** (extraire la logique depuis `renderDashboardStats` dans index.html)
**Step 4: Run test — expected PASS**
**Step 5: Commit** `feat(application): add query handlers`
---
## Phase 6 — Infrastructure
### Task 17: LocalStorageRepository
**Files:**
- Create: `src/infrastructure/persistence/LocalStorageRepository.ts`
- Test: `tests/unit/infrastructure/LocalStorageRepository.test.ts` (avec mock localStorage)
Implémenter `StateRepository` en utilisant `window.electronAPI.saveData`/`loadData` avec fallback sur `localStorage`. Inclure la logique de migration depuis index.html (`load()` lignes 1321-1356).
**Commit** `feat(infrastructure): add LocalStorageRepository`
---
### Task 18: Electron adapters (Notification, Alarm, Update)
**Files:**
- Create: `src/infrastructure/notifications/ElectronNotification.ts`
- Create: `src/infrastructure/notifications/NtfyNotification.ts`
- Create: `src/infrastructure/alarm/WebAudioAlarm.ts`
- Create: `src/infrastructure/update/GiteaUpdateAdapter.ts`
Chaque fichier implémente un port. Extraire la logique depuis index.html et main.js.
**Commit** `feat(infrastructure): add notification, alarm, update adapters`
---
### Task 19: Migrer main.ts + preload.ts
**Files:**
- Create: `src/infrastructure/electron/main.ts` (copier + convertir main.js en TypeScript)
- Create: `src/infrastructure/electron/preload.ts` (copier + convertir preload.js en TypeScript)
Le `main.ts` reste quasiment identique à `main.js` actuel — c'est déjà de l'infrastructure. Conversion TypeScript + cleanup.
**Commit** `feat(infrastructure): migrate Electron main and preload to TypeScript`
---
## Phase 7 — Presentation
### Task 20: UIState + App shell
**Files:**
- Create: `src/presentation/state/UIState.ts`
- Create: `src/presentation/components/App.ts`
- Create: `src/presentation/index.ts` (bootstrap / DI wiring)
- Modify: `src/index.html` (shell HTML minimal)
UIState gère l'état UI pur (onglet actif, sidebar ouverte, etc.) avec un pattern observable simple.
Le bootstrap (`index.ts`) connecte tout : crée les services domain, instancie les adapters infrastructure, configure les command/query bus, monte les composants.
**Commit** `feat(presentation): add UIState and App shell with DI wiring`
---
### Task 21: Composants UI — extraction progressive
**Files:**
- Create: `src/presentation/components/Sidebar.ts`
- Create: `src/presentation/components/Dashboard.ts`
- Create: `src/presentation/components/EnclosView.ts`
- Create: `src/presentation/components/DragodindeCard.ts`
- Create: `src/presentation/components/GaugePill.ts`
- Create: `src/presentation/components/AccouplementView.ts`
- Create: `src/presentation/components/ReapproView.ts`
- Create: `src/presentation/components/InventaireView.ts`
- Create: `src/presentation/components/ParametresView.ts`
- Create: `src/presentation/components/UpdateBanner.ts`
Chaque composant est une classe :
```typescript
export class DashboardComponent {
private el: HTMLElement;
constructor(private queryBus: QueryBus, private commandBus: CommandBus, private uiState: UIState) {}
render(): HTMLElement { /* ... */ }
update(): void { /* ... */ }
destroy(): void { /* ... */ }
}
```
Extraire le HTML/CSS/JS de rendu depuis index.html pour chaque composant. Le CSS est extrait dans `src/presentation/styles/`.
**Commit** `feat(presentation): extract UI components`
---
### Task 22: Styles — extraction CSS
**Files:**
- Create: `src/presentation/styles/variables.css` (lignes 9-14 du monolithe)
- Create: `src/presentation/styles/base.css` (reset, body, wrap)
- Create: `src/presentation/styles/components.css` (tous les styles composants)
- Create: `src/presentation/styles/index.css` (imports)
**Commit** `feat(presentation): extract CSS into modular files`
---
## Phase 8 — Tests de régression
### Task 23: Tests de régression
**Files:**
- Create: `tests/regression/gauge-tier-calculation.test.ts`
- Create: `tests/regression/xp-timer-display.test.ts`
- Create: `tests/regression/stats-persistence.test.ts`
- Create: `tests/regression/breeding-deduction.test.ts`
**Tests de régression basés sur les bugs corrigés dans les versions précédentes :**
```typescript
// xp-timer-display.test.ts — Bug v1.1.4: affichait ~23h au lieu du temps réel
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';
describe('Regression: XP timer display', () => {
it('should NOT show ~23h for a short XP gain', () => {
const calc = new XpCalculator();
const eta = calc.computeEta({
currentLevel: 1,
target: 5,
gaugeLevels: { mangeoire: 50000 },
activeGauges: ['mangeoire'],
});
// 161 XP needed (level 5), rate 20 at tier 2 = ~81 ticks = 810 sec ≈ 13.5 min
expect(eta.seconds).toBeLessThan(3600); // Must be under 1h, NOT 23h
});
});
// breeding-deduction.test.ts
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';
describe('Regression: Breeding deduction', () => {
it('bidirectional parent order produces same baby', () => {
const svc = new BreedingService();
expect(svc.deduceBaby('Rousse', 'Dorée')).toBe(svc.deduceBaby('Dorée', 'Rousse'));
});
it('all gen 2-10 recipes are deducible', () => {
const svc = new BreedingService();
// Every BREEDING_RECIPES entry should be deducible from its parents
// (tested implicitly via BreedingService constructor)
expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
expect(svc.deduceBaby('Amande', 'Dorée')).toBe('Amande et Dorée');
});
});
// stats-persistence.test.ts
import { describe, it, expect } from 'vitest';
describe('Regression: Stats persistence', () => {
it('deleting enclos should archive stats', () => {
// Verify via DeleteEnclos command that archivedStats grows
// when an enclos with history is deleted
});
});
```
**Commit** `test: add regression tests for known bugs`
---
### Task 24: Tests fonctionnels
**Files:**
- Create: `tests/functional/timer-workflow.test.ts`
- Create: `tests/functional/breeding-workflow.test.ts`
- Create: `tests/functional/enclos-management.test.ts`
**timer-workflow.test.ts:**
```typescript
import { describe, it, expect } from 'vitest';
// Test le scénario complet: créer enclos → ajouter DD → configurer jauges → démarrer timer → vérifier progression → arrêter
describe('Timer Workflow', () => {
it('full lifecycle: create → configure → start → progress → stop', () => {
// 1. Create enclos via command
// 2. Add dragodinde via command
// 3. Toggle gauge via command
// 4. Start timer via command
// 5. Query timer state — verify running
// 6. Stop timer via command
// 7. Query timer state — verify stopped
});
});
```
**breeding-workflow.test.ts:**
```typescript
describe('Breeding Workflow', () => {
it('register accouplement → updates global stats', () => {
// 1. Register accouplement via command
// 2. Query dashboard — verify stats include new accouplement
});
});
```
**Commit** `test: add functional workflow tests`
---
## Phase 9 — Nettoyage + intégration finale
### Task 25: Supprimer le monolithe
Une fois que tous les composants sont migrés et que tous les tests passent :
1. Renommer `src/index.html` (ancien) → `src/index.html.bak`
2. Renommer `main.js``main.js.bak`
3. Renommer `preload.js``preload.js.bak`
4. Mettre à jour `package.json` : adapter les scripts `build` pour utiliser Vite
5. Vérifier que `npm run dev` fonctionne
6. Vérifier que `npm run build` produit un installeur fonctionnel
7. Supprimer les `.bak`
**Commit** `chore: remove legacy monolith, Vite is now the sole build`
---
### Task 26: Mettre à jour package.json et electron-builder config
Adapter `package.json`:
```json
{
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "vite build && electron-builder --win --x64",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"build": {
"files": [
"dist-vite/**/*",
"dist-electron/**/*",
"icon.png"
]
}
}
```
**Commit** `chore: update build config for Vite output`
---
## Résumé des phases
| Phase | Tasks | Description |
|-------|-------|-------------|
| 1 | 1 | Setup Vite + TS + Vitest |
| 2 | 2-5 | Domain Value Objects |
| 3 | 6-10 | Domain Services (logique pure) |
| 4 | 11-13 | Domain Entities + Ports + Events |
| 5 | 14-16 | Application Layer (CQRS) |
| 6 | 17-19 | Infrastructure (adapters) |
| 7 | 20-22 | Presentation (composants UI) |
| 8 | 23-24 | Tests régression + fonctionnels |
| 9 | 25-26 | Nettoyage + intégration finale |
**Total: 26 tasks, ~9 phases**
Tests attendus : ~50+ tests unitaires, ~10 fonctionnels, ~5 régression.