- 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>
2114 lines
60 KiB
Markdown
2114 lines
60 KiB
Markdown
# 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.
|