# 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> = { 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> = { 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> = { 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> = { 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> = { '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> = { // ... (copier l'intégralité depuis le monolithe) }; // Lookup table: baby → [parentA, parentB] export const BREEDING_RECIPES: Readonly> = { // ... (copier l'intégralité depuis index.html lignes 597-661) }; // Derived lookups — computed at module load export const RACE_GEN: Record = {}; // 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> = { 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; private readonly partners: Map; 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>; } 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>; 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>; inverted: Readonly>; } 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 = {}; needs[target] = { total: qty, m: 0, f: 0 }; const steps: ReapproStep[] = []; const processed = new Set(); 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; } export class InventaireCalculator { compute(inventaire: Readonly>): InventaireResult { const avail: Record = {}; 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; 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; snapStats: Record>; } export interface Enclos { readonly id: number; name: string; activeGauges: GaugeType[]; gaugeLevels: Record; dragodindes: Dragodinde[]; nextDdId: number; timer: TimerData; alerted: Record; } 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; workflows: unknown[]; accouplements: Accouplement[]; } export interface StateRepository { load(): Promise; 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; 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(); 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 = (cmd: T) => void; export class CommandBus { private handlers = new Map(); register(type: string, handler: CommandHandler): 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 = (query: T) => R; export class QueryBus { private handlers = new Map(); register(type: string, handler: QueryHandler): void { this.handlers.set(type, handler as QueryHandler); } execute(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> = {}; 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.