test: 302 tests unitaires + 20 E2E Playwright (couverture 94%)

- Unit : domain (GaugeCalculator, Enclos, Dragodinde, XpTable, Race, Tier...)
- Unit : application (commands, queries, CommandBus)
- Fonctionnel : breeding-workflow, enclos-management, timer-workflow
- Régression : gauge-tier, gauge-recharge, xp-timer, level-target, breeding
- E2E Playwright + Electron : navigation, timer, recharge jauge,
  accouplement, persistance des données

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
POL Mickaël 2026-04-06 05:43:29 +02:00
parent 62ae4c54eb
commit 203c423f19
36 changed files with 3965 additions and 0 deletions

11
playwright.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
retries: 0,
workers: 1,
use: {
trace: 'on-first-retry',
},
});

106
tests/e2e/breeding.spec.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* Tests E2E Workflow d'accouplement
*
* Navigue vers la vue Accouplement, selectionne deux parents,
* verifie la deduction du bebe, et enregistre l'accouplement.
*/
import { test, expect } from './electron-app';
test.describe('Workflow d\'accouplement', () => {
test.beforeEach(async ({ page }) => {
// Naviguer vers Accouplement via la sidebar
await page.click('.sb-item[data-view="accouplement"]');
await expect(page.locator('.accoup-view')).toBeVisible({ timeout: 5000 });
});
test('Selectionner Parent 1 en cliquant sur une race', async ({ page }) => {
// Cliquer sur la premiere race disponible dans la grille
const firstRaceCard = page.locator('.accoup-race-card').first();
const raceName = await firstRaceCard.locator('.accoup-race-card-name').textContent();
await firstRaceCard.click();
// Verifier que Parent 1 affiche la race selectionnee
const selectedParent = page.locator('.accoup-selected-parent-name').first();
await expect(selectedParent).toHaveText(raceName!.trim(), { timeout: 3000 });
});
test('Selectionner les deux parents et voir le resultat', async ({ page }) => {
// Selectionner "Rousse" comme Parent 1
const rousseCard = page.locator('.accoup-race-card[data-race="Rousse"]');
await rousseCard.click();
await expect(page.locator('.accoup-selected-parent-name').first()).toHaveText('Rousse', { timeout: 3000 });
// Apres P1 selectionne, la grille filtre les partenaires compatibles
// Selectionner le premier partenaire disponible comme Parent 2
const partnerCard = page.locator('.accoup-race-card').first();
await partnerCard.click();
// Verifier qu'un resultat (bebe) est affiche
// Le resultat apparait dans la zone centrale avec le texte "Resultat"
await page.waitForTimeout(500);
const parentNames = page.locator('.accoup-selected-parent-name');
await expect(parentNames).toHaveCount(2, { timeout: 3000 });
});
test('Definir nombre de couples et bebes puis enregistrer', async ({ page }) => {
// Selectionner P1 : Rousse
await page.click('.accoup-race-card[data-race="Rousse"]');
await expect(page.locator('.accoup-selected-parent-name').first()).toHaveText('Rousse', { timeout: 3000 });
// Selectionner P2 : premier partenaire compatible
const partnerCard = page.locator('.accoup-race-card').first();
await partnerCard.click();
await page.waitForTimeout(500);
// Remplir le nombre de couples
const couplesInput = page.locator('#accoup-couples');
await couplesInput.click();
await couplesInput.fill('3');
// Remplir les bebes obtenus
const babiesInput = page.locator('#accoup-babies');
await babiesInput.click();
await babiesInput.fill('2');
// Le bouton Enregistrer devrait etre actif
const registerBtn = page.locator('#accoup-register');
await expect(registerBtn).toBeEnabled({ timeout: 3000 });
// Cliquer sur Enregistrer
await registerBtn.click();
// Verifier que le toast success apparait
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 3000 });
await expect(page.locator('.toast-msg')).toContainText('Accouplement enregistré');
// Apres l'enregistrement, les parents sont reinitialises
// On devrait voir les placeholders "Cliquer ou glisser" a nouveau
await expect(page.locator('.accoup-placeholder')).toHaveCount(2, { timeout: 3000 });
});
test('Filtrer les races par generation', async ({ page }) => {
// Cliquer sur le chip "Gen 1"
await page.click('.accoup-gen-chip[data-gen="1"]');
await page.waitForTimeout(300);
// Verifier que le chip est actif
await expect(page.locator('.accoup-gen-chip[data-gen="1"].active')).toBeVisible();
// Toutes les races affichees devraient etre de Gen 1
const genBadges = page.locator('.accoup-race-card-gen');
const count = await genBadges.count();
for (let i = 0; i < count; i++) {
await expect(genBadges.nth(i)).toContainText('GEN 1');
}
});
test('Rechercher une race par nom', async ({ page }) => {
const searchInput = page.locator('#accoup-search-input');
await searchInput.fill('Rousse');
await page.waitForTimeout(300);
// La grille devrait contenir "Rousse"
await expect(page.locator('.accoup-race-card[data-race="Rousse"]')).toBeVisible();
});
});

60
tests/e2e/electron-app.ts Normal file
View File

@ -0,0 +1,60 @@
/**
* Helper Playwright pour lancer l'application Electron.
*
* Lance l'app depuis dist-electron/main.js (il faut avoir buildé avec `vite build` avant).
* Exporte une fixture `test` qui fournit `electronApp` et `page`.
*
* Chaque test démarre avec un userData vierge (nettoyé automatiquement).
*/
import { test as base, type ElectronApplication, type Page, _electron as electron } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const USER_DATA_DIR = path.resolve(__dirname, '../../.e2e-userdata');
type ElectronFixtures = {
electronApp: ElectronApplication;
page: Page;
};
export const test = base.extend<ElectronFixtures>({
// eslint-disable-next-line no-empty-pattern
electronApp: async ({}, use) => {
// Nettoyer le userData pour partir d'un état vierge à chaque test
if (fs.existsSync(USER_DATA_DIR)) {
fs.rmSync(USER_DATA_DIR, { recursive: true, force: true });
}
fs.mkdirSync(USER_DATA_DIR, { recursive: true });
const mainPath = path.resolve(__dirname, '../../dist-electron/main.js');
const app = await electron.launch({
args: [mainPath],
env: {
...process.env,
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
},
});
await use(app);
await app.close();
},
page: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow();
await window.waitForLoadState('domcontentloaded');
await window.waitForSelector('.app-shell', { timeout: 15000 });
// L'app démarre sans enclos — en créer un pour les tests qui en ont besoin
const addBtn = window.locator('#add-enclos-btn');
if (await addBtn.isVisible({ timeout: 5000 })) {
await addBtn.click();
// Attendre que l'enclos apparaisse dans la sidebar
await window.waitForSelector('.sb-item .sb-dot', { timeout: 5000 });
}
await use(window);
},
});
export { expect } from '@playwright/test';

View File

@ -0,0 +1,75 @@
/**
* Tests E2E Recharge de jauge en cours de session
*
* Setup: enclos avec baffeur a 100, DD ajoutee, timer demarre.
* Recharge la jauge a 50000 et verifie les mises a jour.
*/
import { test, expect } from './electron-app';
test.describe('Recharge de jauge pendant une session', () => {
test.beforeEach(async ({ page }) => {
// Naviguer vers le premier enclos
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Activer baffeur avec niveau bas (100)
await page.click('.gauge-btn[data-gid="baffeur"]');
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('100');
await gaugeInput.press('Enter');
// L'enclos contient deja 1 DD (creee avec l'enclos)
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
// Demarrer le timer
await page.click('.enc-start-btn');
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
});
test('Recharger la jauge met a jour "Alarme dans"', async ({ page }) => {
// Avec une jauge a 100, elle se vide rapidement
// Attendre un peu pour que la jauge commence a se drainer
await page.waitForTimeout(1000);
// Recharger la jauge a 50000
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('50000');
await gaugeInput.press('Enter');
// Attendre la mise a jour du DOM
await page.waitForTimeout(500);
// "Alarme dans" devrait montrer un vrai temps (pas infini)
const alarmText = await page.locator('.enc-alarm').textContent();
expect(alarmText).not.toBe('\u221e');
expect(alarmText).not.toBe('--:--:--');
});
test('La barre de jauge se met a jour apres la recharge', async ({ page }) => {
// Capturer la largeur de la barre avant la recharge
const barBefore = await page.locator('.enc-gauge-bar-fill').first().evaluate(
(el: HTMLElement) => parseFloat(el.style.width)
);
// Recharger a 80000
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('80000');
await gaugeInput.press('Enter');
// Attendre la mise a jour
await page.waitForTimeout(500);
// La barre devrait etre plus large maintenant
const barAfter = await page.locator('.enc-gauge-bar-fill').first().evaluate(
(el: HTMLElement) => parseFloat(el.style.width)
);
expect(barAfter).toBeGreaterThan(barBefore);
});
});

View File

@ -0,0 +1,54 @@
/**
* Tests E2E Navigation dans la sidebar
*
* Verifie que chaque element de la sidebar affiche la bonne vue.
*/
import { test, expect } from './electron-app';
test.describe('Navigation sidebar', () => {
test('Cliquer sur "Tableau de bord" affiche le dashboard', async ({ page }) => {
await page.click('.sb-item[data-view="dashboard"]');
// Le dashboard est la vue par defaut, verifions que le contenu est affiche
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
test('Cliquer sur "Statistiques" affiche la vue statistiques', async ({ page }) => {
await page.click('.sb-item[data-view="statistiques"]');
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
test('Cliquer sur un enclos affiche la vue enclos', async ({ page }) => {
// Le premier enclos a un data-view numerique (son id)
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
// La vue enclos contient le conteneur .enclos-view
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
});
test('Cliquer sur "Accouplement" affiche la vue accouplement', async ({ page }) => {
await page.click('.sb-item[data-view="accouplement"]');
await expect(page.locator('.accoup-view')).toBeVisible({ timeout: 5000 });
});
test('Cliquer sur "Reappro" affiche la vue reappro', async ({ page }) => {
await page.click('.sb-item[data-view="appro"]');
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
test('Cliquer sur "Inventaire" affiche la vue inventaire', async ({ page }) => {
await page.click('.sb-item[data-view="inventaire"]');
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
test('Cliquer sur "Workflows" affiche la vue workflows', async ({ page }) => {
await page.click('.sb-item[data-view="workflows"]');
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
test('Cliquer sur "Parametres" affiche la vue parametres', async ({ page }) => {
await page.click('.sb-item[data-view="parametres"]');
await expect(page.locator('#enclos-content')).not.toBeEmpty();
});
});

View File

@ -0,0 +1,107 @@
/**
* Tests E2E Persistance des donnees
*
* Verifie que les donnees (nom de DD, etc.) survivent
* a la fermeture et reouverture de l'application.
*/
import { test as base, expect } from '@playwright/test';
import { _electron as electron } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const MAIN_PATH = path.resolve(__dirname, '../../dist-electron/main.js');
const USER_DATA_DIR = path.resolve(__dirname, '../../.e2e-userdata-persistence');
function cleanDir(dir: string) {
try {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
} catch {
// Ignore EPERM on Windows — le dossier sera ecrase au prochain lancement
}
}
// Fixture speciale qui permet de relancer l'app
const test = base.extend<{}>({});
test.describe('Persistance des donnees', () => {
// Nettoyer les donnees avant le test
test.beforeAll(() => {
cleanDir(USER_DATA_DIR);
});
test.afterAll(() => {
cleanDir(USER_DATA_DIR);
});
test('Le nom d\'une dragodinde persiste apres fermeture et reouverture', async () => {
// --- Session 1 : creer un enclos, nommer la DD ---
const app1 = await electron.launch({
args: [MAIN_PATH],
env: {
...process.env,
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
},
});
const page1 = await app1.firstWindow();
await page1.waitForLoadState('domcontentloaded');
await page1.waitForSelector('.app-shell', { timeout: 15000 });
// L'app demarre sans enclos — en creer un (il contient deja 1 DD)
await page1.click('#add-enclos-btn');
await page1.waitForSelector('.sb-item .sb-dot', { timeout: 5000 });
// Naviguer vers le premier enclos
const firstEnclos1 = page1.locator('.sb-item[data-view]').filter({
has: page1.locator('.sb-dot'),
}).first();
await firstEnclos1.click();
await expect(page1.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// L'enclos contient deja 1 DD (creee avec l'enclos)
await expect(page1.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
// Renommer la DD (trouver l'input de nom dans la carte DD)
const ddNameInput = page1.locator('.dd-card .dd-name').first();
await ddNameInput.click();
await ddNameInput.fill('TestPersist42');
await ddNameInput.press('Enter');
// Attendre que la sauvegarde soit effectuee
await page1.waitForTimeout(1000);
// Fermer l'app
await app1.close();
// --- Session 2 : verifier que la DD est toujours la ---
const app2 = await electron.launch({
args: [MAIN_PATH],
env: {
...process.env,
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
},
});
const page2 = await app2.firstWindow();
await page2.waitForLoadState('domcontentloaded');
await page2.waitForSelector('.app-shell', { timeout: 15000 });
// Naviguer vers le premier enclos
const firstEnclos2 = page2.locator('.sb-item[data-view]').filter({
has: page2.locator('.sb-dot'),
}).first();
await firstEnclos2.click();
await expect(page2.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Verifier que la DD est toujours presente
await expect(page2.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
// Verifier que le nom persiste
const ddNameInput2 = page2.locator('.dd-card .dd-name').first();
await expect(ddNameInput2).toHaveValue('TestPersist42', { timeout: 5000 });
await app2.close();
});
});

View File

@ -0,0 +1,113 @@
/**
* Tests E2E Cycle de vie complet du timer
*
* Navigue vers un enclos, active une jauge, ajoute une DD,
* demarre/pause/reprend/reset le timer.
*/
import { test, expect } from './electron-app';
test.describe('Cycle de vie du timer', () => {
test.beforeEach(async ({ page }) => {
// Naviguer vers le premier enclos via la sidebar
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
});
test('Activer la jauge baffeur et definir son niveau', async ({ page }) => {
// Cliquer sur le bouton baffeur pour l'activer
await page.click('.gauge-btn[data-gid="baffeur"]');
// Verifier que la jauge est active (classe .on)
await expect(page.locator('.gauge-btn[data-gid="baffeur"].on')).toBeVisible();
// Definir le niveau de la jauge a 50000
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('50000');
await gaugeInput.press('Enter');
// Verifier que la valeur est bien 50000
await expect(gaugeInput).toHaveValue('50000');
});
test('Ajouter une dragodinde puis demarrer le timer', async ({ page }) => {
// Activer la jauge baffeur
await page.click('.gauge-btn[data-gid="baffeur"]');
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('50000');
await gaugeInput.press('Enter');
// L'enclos contient deja 1 DD (creee avec l'enclos)
const ddGrid = page.locator('.dd-grid');
await expect(ddGrid.locator('.dd-card')).toHaveCount(1, { timeout: 5000 });
// Demarrer le timer
const startBtn = page.locator('.enc-start-btn');
await startBtn.click();
// Verifier que le bouton est passe en mode PAUSE
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
// Verifier que le temps ecoule n'est plus 00:00:00 (apres un petit delai)
await page.waitForTimeout(1500);
const elapsedText = await page.locator('.enc-elapsed').textContent();
expect(elapsedText).not.toBe('00:00:00');
// Verifier que "Alarme dans" montre un temps (pas --:--:-- ni infini)
const alarmText = await page.locator('.enc-alarm').textContent();
expect(alarmText).not.toBe('--:--:--');
expect(alarmText).not.toBe('\u221e');
});
test('Pause et reprise du timer', async ({ page }) => {
// Setup: activer jauge + DD + demarrer
await page.click('.gauge-btn[data-gid="baffeur"]');
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('50000');
await gaugeInput.press('Enter');
await page.click('button:has-text("Ajouter une Dragodinde")');
await page.click('.enc-start-btn');
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
// Mettre en pause
await page.click('.enc-btn-pause');
// Le bouton devrait afficher REPRENDRE
await expect(page.locator('.enc-start-btn')).toContainText('REPRENDRE', { timeout: 3000 });
// Capturer le temps ecoule apres la pause
const elapsedAfterPause = await page.locator('.enc-elapsed').textContent();
// Attendre un moment pour verifier que le temps ne bouge pas
await page.waitForTimeout(1500);
const elapsedStillPaused = await page.locator('.enc-elapsed').textContent();
expect(elapsedStillPaused).toBe(elapsedAfterPause);
// Reprendre le timer
await page.click('.enc-start-btn');
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
});
test('Reset du timer', async ({ page }) => {
// Setup: activer jauge + DD + demarrer
await page.click('.gauge-btn[data-gid="baffeur"]');
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
await gaugeInput.click();
await gaugeInput.fill('50000');
await gaugeInput.press('Enter');
await page.click('button:has-text("Ajouter une Dragodinde")');
await page.click('.enc-start-btn');
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
// Cliquer sur le bouton reset
await page.click('.enc-reset-btn');
// Verifier que le timer est revenu a 00:00:00
await expect(page.locator('.enc-elapsed')).toHaveText('00:00:00', { timeout: 3000 });
// Le bouton devrait afficher DEMARRER
await expect(page.locator('.enc-start-btn')).toContainText('MARRER', { timeout: 3000 });
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';
import { QueryBus } from '@application/handlers/QueryBus';
import { EventBus } from '@domain/events/EventBus';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createRegisterAccouplementHandler } from '@application/commands/RegisterAccouplement';
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
import { createGetBreedingOptionsHandler } from '@application/queries/GetBreedingOptions';
import { BreedingService } from '@domain/services/BreedingService';
function setup() {
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
const events = new EventBus();
const state: AppState = {
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
const cmdBus = new CommandBus();
const qBus = new QueryBus();
cmdBus.register('register-accouplement', createRegisterAccouplementHandler(state, repo, events));
qBus.register('get-dashboard', createGetDashboardHandler(state));
qBus.register('get-breeding-options', createGetBreedingOptionsHandler());
return { state, cmdBus, qBus };
}
describe('Breeding Workflow', () => {
it('select parents → deduce baby → register → dashboard reflects', () => {
const { cmdBus, qBus } = setup();
const svc = new BreedingService();
// 1. Get compatible partners for Rousse
const options = qBus.execute<any>({ type: 'get-breeding-options', race: 'Rousse' });
expect(options.partners.some((p: any) => p.partner === 'Dorée')).toBe(true);
// 2. Deduce baby
const baby = svc.deduceBaby('Rousse', 'Dorée');
expect(baby).toBe('Dorée et Rousse');
// 3. Register accouplement
cmdBus.execute({
type: 'register-accouplement',
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse',
couples: 10, babiesObtained: 7,
});
// 4. Dashboard shows updated stats
const dash = qBus.execute<any>({ type: 'get-dashboard' });
expect(dash.totalCouples).toBe(10);
expect(dash.totalBabies).toBe(7);
expect(dash.successRate).toBe(70);
expect(dash.raceBreakdown['Dorée et Rousse']).toBe(7);
});
it('multiple accouplements accumulate', () => {
const { cmdBus, qBus } = setup();
cmdBus.execute({
type: 'register-accouplement',
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse',
couples: 5, babiesObtained: 3,
});
cmdBus.execute({
type: 'register-accouplement',
parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée',
couples: 8, babiesObtained: 5,
});
const dash = qBus.execute<any>({ type: 'get-dashboard' });
expect(dash.totalCouples).toBe(13);
expect(dash.totalBabies).toBe(8);
});
});

View File

@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';
import { EventBus } from '@domain/events/EventBus';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
import { createRemoveDragodindeHandler } from '@application/commands/RemoveDragodinde';
import { MAX_ENCLOS, MAX_DD } from '@domain/entities/Enclos';
function setup() {
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
const events = new EventBus();
const state: AppState = {
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
const cmdBus = new CommandBus();
cmdBus.register('create-enclos', createCreateEnclosHandler(state, repo));
cmdBus.register('delete-enclos', createDeleteEnclosHandler(state, repo, events));
cmdBus.register('add-dragodinde', createAddDragodindeHandler(state, repo));
cmdBus.register('remove-dragodinde', createRemoveDragodindeHandler(state, repo));
return { state, cmdBus, events };
}
describe('Enclos Management', () => {
it('create up to MAX_ENCLOS', () => {
const { state, cmdBus } = setup();
for (let i = 0; i < MAX_ENCLOS + 2; i++) {
cmdBus.execute({ type: 'create-enclos' });
}
expect(state.enclos).toHaveLength(MAX_ENCLOS);
});
it('delete switches to dashboard when last enclos removed', () => {
const { state, cmdBus } = setup();
cmdBus.execute({ type: 'create-enclos' });
const id = state.enclos[0]!.id;
state.activeId = id;
cmdBus.execute({ type: 'delete-enclos', enclosId: id });
expect(state.enclos).toHaveLength(0);
expect(state.activeId).toBe('dashboard');
});
it('add DDs up to MAX_DD per enclos', () => {
const { state, cmdBus } = setup();
cmdBus.execute({ type: 'create-enclos' });
const encId = state.enclos[0]!.id;
// Already has 1 DD from creation
for (let i = 0; i < MAX_DD; i++) {
cmdBus.execute({ type: 'add-dragodinde', enclosId: encId });
}
expect(state.enclos[0]!.dragodindes.length).toBeLessThanOrEqual(MAX_DD);
});
it('remove DD from enclos', () => {
const { state, cmdBus } = setup();
cmdBus.execute({ type: 'create-enclos' });
const encId = state.enclos[0]!.id;
const ddId = state.enclos[0]!.dragodindes[0]!.id;
cmdBus.execute({ type: 'remove-dragodinde', enclosId: encId, ddId });
expect(state.enclos[0]!.dragodindes).toHaveLength(0);
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';
import { QueryBus } from '@application/handlers/QueryBus';
import { EventBus } from '@domain/events/EventBus';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { createStartTimerHandler } from '@application/commands/StartTimer';
import { createStopTimerHandler } from '@application/commands/StopTimer';
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
import { createToggleGaugeHandler } from '@application/commands/UpdateGauge';
import { createGetTimerStateHandler } from '@application/queries/GetTimerState';
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
function setup() {
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
const events = new EventBus();
const state: AppState = {
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
const cmdBus = new CommandBus();
const qBus = new QueryBus();
cmdBus.register('create-enclos', createCreateEnclosHandler(state, repo));
cmdBus.register('add-dragodinde', createAddDragodindeHandler(state, repo));
cmdBus.register('toggle-gauge', createToggleGaugeHandler(state, repo));
cmdBus.register('start-timer', createStartTimerHandler(state, repo));
cmdBus.register('stop-timer', createStopTimerHandler(state, repo));
qBus.register('get-timer-state', createGetTimerStateHandler(state));
qBus.register('get-dashboard', createGetDashboardHandler(state));
return { state, repo, events, cmdBus, qBus };
}
describe('Timer Workflow', () => {
it('full lifecycle: create → configure → start → stop', () => {
const { state, cmdBus, qBus } = setup();
// 1. Create enclos (starts with 1 DD automatically)
cmdBus.execute({ type: 'create-enclos' });
expect(state.enclos).toHaveLength(1);
expect(state.enclos[0]!.dragodindes).toHaveLength(1);
const encId = state.enclos[0]!.id;
// 2. Toggle gauge
cmdBus.execute({ type: 'toggle-gauge', enclosId: encId, gaugeId: 'baffeur' });
expect(state.enclos[0]!.activeGauges).toContain('baffeur');
// 3. Set gauge level (modify state directly since we don't have that command wired)
state.enclos[0]!.gaugeLevels.baffeur = 50000;
// 4. Start timer
cmdBus.execute({ type: 'start-timer', enclosId: encId });
const timerState = qBus.execute<any>({ type: 'get-timer-state', enclosId: encId });
expect(timerState.running).toBe(true);
// 5. Stop timer
cmdBus.execute({ type: 'stop-timer', enclosId: encId });
const stopped = qBus.execute<any>({ type: 'get-timer-state', enclosId: encId });
expect(stopped.running).toBe(false);
});
it('dashboard reflects enclos state', () => {
const { cmdBus, qBus } = setup();
cmdBus.execute({ type: 'create-enclos' });
cmdBus.execute({ type: 'create-enclos' });
const dash = qBus.execute<any>({ type: 'get-dashboard' });
expect(dash.enclosSummaries).toHaveLength(2);
});
});

View File

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';
import { BREEDING_RECIPES } from '@domain/value-objects/Race';
import { createAccouplement } from '@domain/entities/Accouplement';
describe('Regression: Breeding deduction', () => {
const svc = new BreedingService();
it('bidirectional parent order produces same baby', () => {
// For ALL recipes, both orderings must work
for (const [baby, [pA, pB]] of Object.entries(BREEDING_RECIPES)) {
const resultAB = svc.deduceBaby(pA, pB);
const resultBA = svc.deduceBaby(pB, pA);
expect(resultAB).toBe(baby);
expect(resultBA).toBe(baby);
}
});
it('babies from breeding are gender-neutral', () => {
// Bug v1.1.4: babies were assigned male/female arbitrarily instead of neutral
// Verify domain doesn't assign gender in Accouplement
const acc = createAccouplement('Rousse', 'Dorée', 'Dorée et Rousse', 2, 5, 3);
// Accouplement doesn't track baby gender — babies are neutral by design
expect(acc.baby).toBe('Dorée et Rousse');
// No gender field on Accouplement — that's correct
});
});

View File

@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { computeGaugeLive, enclosGlobalState } from '@presentation/helpers/gauge-live';
import { createEnclos } from '@domain/entities/Enclos';
import { createDragodinde } from '@domain/entities/Dragodinde';
import type { Enclos } from '@domain/entities/Enclos';
import type { Dragodinde } from '@domain/entities/Dragodinde';
/**
* Régression : après une recharge de jauge en cours de timer,
* le countdown (cntDown) doit être fini (pas Infinity).
*
* Scénario reproduit : la jauge baffeur se vide complètement,
* le joueur la recharge le countdown doit recalculer à partir
* du nouveau niveau de jauge, pas rester bloqué à .
*/
describe('Régression: countdown fini après recharge de jauge vidée', () => {
const NOW = 1_700_000_000_000; // timestamp fixe
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function buildEnclos(dd: Dragodinde, opts: {
startGl: number;
elapsedSec: number;
recharges?: { atSec: number; level: number }[];
currentGl: number;
}): Enclos {
const enc = createEnclos(1, 'Test');
enc.activeGauges = ['baffeur'];
enc.gaugeLevels = { ...enc.gaugeLevels, baffeur: opts.currentGl };
enc.dragodindes = [dd];
enc.timer = {
running: true,
startTime: NOW - opts.elapsedSec * 1000,
pausedAt: null,
pausedMs: 0,
snapGauges: { baffeur: opts.startGl },
snapStats: { [dd.id]: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
gaugeRecharges: opts.recharges
? { baffeur: opts.recharges }
: {},
};
return enc;
}
it('sans recharge et jauge vidée → cntDown est Infinity', () => {
const dd = createDragodinde(1);
// Jauge baffeur départ = 100, elapsed = 200s → largement vidée (100 pts = tier1 10/tick)
// timeToGain(100, 100) = ceil(100/10)*10 = 100s → à 200s la jauge est à 0
const enc = buildEnclos(dd, {
startGl: 100,
elapsedSec: 200,
currentGl: 0,
});
const result = computeGaugeLive(enc, dd, 'baffeur', 200, true);
expect(result.curGl).toBe(0);
expect(result.cntDown).toBe(Infinity);
});
it('après recharge de la jauge vidée → cntDown est fini et > 0', () => {
const dd = createDragodinde(1);
// Jauge baffeur départ = 100, elapsed = 200s
// Recharge à t=150s avec niveau 50000
// À t=200s, la jauge a été rechargée et n'est plus à 0
const enc = buildEnclos(dd, {
startGl: 100,
elapsedSec: 200,
recharges: [{ atSec: 150, level: 50000 }],
currentGl: 50000,
});
const result = computeGaugeLive(enc, dd, 'baffeur', 200, true);
// La jauge rechargée à 50000 a eu 50s pour se vider (200-150)
// Elle doit être > 0 (50000 pts se vide en bien plus de 50s)
expect(result.curGl).toBeGreaterThan(0);
// Le countdown doit être fini (pas Infinity) et positif
expect(isFinite(result.cntDown)).toBe(true);
expect(result.cntDown).toBeGreaterThan(0);
});
it('enclosGlobalState retourne un globalMax fini après recharge', () => {
const dd = createDragodinde(1);
const enc = buildEnclos(dd, {
startGl: 100,
elapsedSec: 200,
recharges: [{ atSec: 150, level: 50000 }],
currentGl: 50000,
});
const state = enclosGlobalState(enc);
expect(state.started).toBe(true);
expect(isFinite(state.globalMax)).toBe(true);
expect(state.globalMax).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import { computeGaugeState } from '@domain/services/GaugeCalculator';
import type { GaugeRecharge } from '@domain/services/GaugeCalculator';
import { gainedIn, timeToGain } from '@domain/services/GaugeCalculator';
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
/**
* Tests de régression pour la fonctionnalité de recharge de jauge en cours de session.
*
* Scénario typique : le joueur recharge la mangeoire pendant le timer car elle s'est vidée.
* Sans recharge prise en compte, le countdown reste bloqué à ou 0 au lieu de recalculer.
*/
describe('Régression: recharge de jauge en cours de timer', () => {
describe('Mangeoire rechargée une fois (scénario XP principal)', () => {
// Contexte :
// - Mangeoire départ = 50 000 (tier 2, rate 20/tick)
// - DD level 1, cible level 50
// - Après 1h (3600 sec), la mangeoire est vide → XP gagnée jusqu'ici
// - Le joueur recharge à 80 000 (tier 3)
// - On simule 1h supplémentaire après recharge
const startGl = 50000;
const xpCible = xpForLevel(50) - xpForLevel(1); // 34 365 XP
it('sans recharge : XP limité par la jauge qui se vide', () => {
// 50 000 pts de jauge → max 50 000 XP disponible (tier 2 → tier 1)
// Mais XP cible = 34 365 → la jauge suffira-t-elle ?
// timeToGain(50000, 34365) :
// tier2 (40000-50000) : 10000 pts, s += ceil(10000/20)*10=5000, rem=24365
// tier1 (0-40000) : 40000 pts, s += ceil(24365/10)*10=24370, rem=0
// total = 29370 sec ≈ 8h 9m
const sec = timeToGain(startGl, xpCible);
expect(sec).toBe(29370);
});
it('avec recharge à t=5000 sec : XP total = seg1 + seg2', () => {
// Seg1 : 50000 (tier2→tier1), 5000 sec → gainedIn(50000, 5000) = ?
// tier2 (40000-50000) : a=10000, m=500, u=500 ticks → 10000 pts, tl=0
// → gained seg1 = 10000
// Recharge à 80000 à t=5000
// Seg2 : 80000 (tier3), 5000 sec → gainedIn(80000, 5000) = ?
// tier3 (70000-80000) : a=10000, m=333, u=333 ticks → 9990, tl=167
// tier2 (40000-70000) : a=30000, m=1500, u=167 ticks → 3340
// → gained seg2 = 13330
// Total = 23330 XP
const recharges: GaugeRecharge[] = [{ atSec: 5000, level: 80000 }];
const { gained } = computeGaugeState(startGl, recharges, Infinity, 10000);
const expectedSeg1 = gainedIn(50000, 5000);
const expectedSeg2 = gainedIn(80000, 5000);
expect(gained).toBe(expectedSeg1 + expectedSeg2);
});
it('la recharge fait redémarrer le countdown vers la cible', () => {
// Après recharge : XP restante = xpCible - gained_so_far
// Le countdown doit utiliser curGl (niveau après recharge et dépletion seg2)
const rechargeEl = 5000; // 5000 sec = quand le joueur recharge
const totalEl = 10000;
const recharges: GaugeRecharge[] = [{ atSec: rechargeEl, level: 80000 }];
const { gained: gainedSoFar, curGl } = computeGaugeState(startGl, recharges, Infinity, totalEl);
const xpRestante = Math.max(0, xpCible - gainedSoFar);
// curGl doit refléter la jauge après seg2 (pas celle d'origine à 50000)
expect(curGl).toBeGreaterThan(0);
expect(curGl).toBeLessThan(80000); // la jauge a déjà décru depuis la recharge
// timeToGain depuis curGl doit être < timeToGain depuis startGl (grâce à la recharge)
const secAvecRecharge = timeToGain(curGl, xpRestante);
const secSansRecharge = timeToGain(gainedIn(startGl, 5000) > 0 ? 0 : startGl, xpRestante);
expect(secAvecRecharge).toBeLessThan(secSansRecharge === Infinity ? Infinity : secSansRecharge + 1);
});
});
describe('Baffeur rechargé (sérenité)', () => {
// Baffeur 70000 (tier2 → cap à -5000 séren.)
// Séren. départ = 0, cap absolu = 5000 pts à gagner
// À t=3000 sec, cap atteint → gel. Recharge à t=4000 → ne change rien (déjà gelé).
const startGl = 70000;
const ptsToAbsCap = 5000; // 0 - (-5000)
it('gel au cap absolu même avec recharge après le gel', () => {
const recharges: GaugeRecharge[] = [{ atSec: 4000, level: 90000 }]; // recharge après gel
const { gained, effectiveEl } = computeGaugeState(startGl, recharges, ptsToAbsCap, 9999);
// Gel = timeToGain(70000, 5000) :
// tier2 (40000-70000) : 5000 pts → ceil(5000/20)*10 = 2500 sec
expect(gained).toBe(5000);
expect(effectiveEl).toBe(2500);
// La recharge à t=4000 est APRÈS le gel à t=2500 → ignorée
});
it('recharge AVANT le gel allonge le temps de traitement', () => {
// Recharge à t=1000 (avant gel naturel à t=2500)
// Seg1 : 70000, 1000 sec → gainedIn(70000, 1000) = 100 ticks
// tier3 (70000-90000): a=0 (70000 non > 70000... wait 70000>70000=false)
// Hmm: 70000 > 70000 = false, donc tier3 skip
// tier2 (40000-70000): a=30000, m=1500, u=min(1500,100)=100, out=2000
// gained seg1 = 2000, jauge = 70000 - 100*20 = 68000
// Recharge à 90000 à t=1000
// Seg2 : 90000, besoin encore 3000 pts, timeToGain(90000,3000) :
// tier3 (70000-90000): 20000 pts, 3000/30=100 ticks=1000 sec
// effectiveEl = 1000 + 1000 = 2000
const recharges: GaugeRecharge[] = [{ atSec: 1000, level: 90000 }];
const { gained, effectiveEl } = computeGaugeState(70000, recharges, 5000, 9999);
expect(gained).toBe(5000);
expect(effectiveEl).toBe(2000); // plus rapide qu'à 2500 grâce au tier3
});
});
describe('Deux recharges successives (mangeoire longue session)', () => {
it('accumule correctement trois segments', () => {
// Seg1 : 30000 (tier1), 500 sec → 50 ticks × 10 = 500 pts
// Recharge 1 à t=500 → 80000
// Seg2 : 80000 (tier2→tier3), 500 sec → gainedIn(80000, 500)
// Recharge 2 à t=1000 → 95000
// Seg3 : 95000 (tier4), 500 sec → gainedIn(95000, 500)
const recharges: GaugeRecharge[] = [
{ atSec: 500, level: 80000 },
{ atSec: 1000, level: 95000 },
];
const { gained } = computeGaugeState(30000, recharges, Infinity, 1500);
const expected = gainedIn(30000, 500) + gainedIn(80000, 500) + gainedIn(95000, 500);
expect(gained).toBe(expected);
});
it('le niveau XP estimé est cohérent avec les points accumulés', () => {
const startXp = 1; // level 1
const startGl = 30000;
const recharges: GaugeRecharge[] = [
{ atSec: 500, level: 80000 },
{ atSec: 1000, level: 95000 },
];
const { gained } = computeGaugeState(startGl, recharges, Infinity, 1500);
const estLevel = levelFromXp(xpForLevel(startXp) + gained);
// Avec 3 segments : ~500 + ~gainedIn(80000,500) + ~gainedIn(95000,500) pts d'XP
// ça devrait faire progresser significativement le niveau
expect(estLevel).toBeGreaterThan(startXp);
});
});
});

View File

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { gainedIn, timeToGain, gaugeAfter } from '@domain/services/GaugeCalculator';
describe('Regression: Gauge tier calculations', () => {
it('gaugeAfter is inverse of gainedIn', () => {
// For a given level and time, gaugeAfter should equal level - gainedIn
const level = 80000;
const sec = 100; // 10 ticks
const gained = gainedIn(level, sec);
const after = gaugeAfter(level, sec);
expect(after).toBe(level - gained);
});
it('timeToGain matches gainedIn for exact amounts', () => {
// If gainedIn(50000, 100) = X, then timeToGain(50000, X) should be <= 100
const level = 50000;
const sec = 100;
const gained = gainedIn(level, sec);
if (gained > 0) {
const time = timeToGain(level, gained);
expect(time).toBeLessThanOrEqual(sec);
}
});
it('boundary: level exactly at tier threshold', () => {
// Level 40000 = tier 1 (<=40000), rate 10
expect(gaugeAfter(40000, 10)).toBe(39990); // 1 tick at rate 10
// Level 40001 = tier 2 (>40000), but only 1 pt above threshold
// 1 pt in tier 2 zone: floor(1/20)=0 ticks consumed there, falls through to tier 1
expect(gaugeAfter(40001, 10)).toBe(39991); // 1 tick at rate 10 in tier 1 zone
});
it('does not produce negative values', () => {
expect(gaugeAfter(10, 100000)).toBe(0);
expect(gainedIn(10, 100000)).toBe(10);
});
});

View File

@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { simulateStock } from '@domain/services/StockSimulator';
import { BREEDING_RECIPES } from '@domain/value-objects/Race';
describe('Régression : simulation inventaire identique au monolithe', () => {
it('8 Rousse (4♂/4♀) + 8 Dorée (4♂/4♀) + 8 Amande (4♂/4♀) → 20 bébés sur 4 générations', () => {
const r = simulateStock({
Rousse: { m: 4, f: 4 },
Dorée: { m: 4, f: 4 },
Amande: { m: 4, f: 4 },
});
// Gen2 : 3 croisements × 4 bébés = 12
const gen2 = r.crossings.filter(c => c.gen === 2);
expect(gen2).toHaveLength(3);
expect(gen2.map(c => c.baby).sort()).toEqual(
['Amande et Dorée', 'Amande et Rousse', 'Dorée et Rousse'],
);
for (const c of gen2) expect(c.count).toBe(4);
// Gen3 : Ebène×2 + Indigo×2 = 4
const gen3 = r.crossings.filter(c => c.gen === 3);
expect(gen3).toHaveLength(2);
expect(gen3.find(c => c.baby === 'Ebène')!.count).toBe(2);
expect(gen3.find(c => c.baby === 'Indigo')!.count).toBe(2);
// Gen4 : Ebène et Indigo×2
const gen4 = r.crossings.filter(c => c.gen === 4);
expect(gen4).toHaveLength(1);
expect(gen4[0]!.baby).toBe('Ebène et Indigo');
expect(gen4[0]!.count).toBe(2);
// Gen5 : Orchidée×2 (Pourpre reçoit 0 car EI alloué à floor(1/2)=0)
const gen5 = r.crossings.filter(c => c.gen === 5);
expect(gen5).toHaveLength(1);
expect(gen5[0]!.baby).toBe('Orchidée');
expect(gen5[0]!.count).toBe(2);
expect(r.crossings.reduce((s, c) => s + c.count, 0)).toBe(20);
});
it('la recette Ebène est Amande et Dorée + Dorée et Rousse (pas Amande et Rousse)', () => {
expect(BREEDING_RECIPES['Ebène']).toEqual(['Amande et Dorée', 'Dorée et Rousse']);
});
it('l\'algorithme glouton aurait donné 8 bébés sur 1 seule race — la version proportionnelle donne 12 sur 3 races', () => {
const r = simulateStock({
Rousse: { m: 4, f: 4 },
Dorée: { m: 4, f: 4 },
Amande: { m: 4, f: 4 },
});
// Vérifier qu'on ne voit PAS un seul croisement avec 8 bébés
for (const c of r.crossings.filter(x => x.gen === 2)) {
expect(c.count).toBeLessThanOrEqual(4);
}
expect(r.crossings.filter(c => c.gen === 2)).toHaveLength(3);
});
});

View File

@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { computeGaugeLive, enclosGlobalState, calcLevelEtaLive } from '@presentation/helpers/gauge-live';
import { xpForLevel } from '@domain/value-objects/XpTable';
import { timeToGain } from '@domain/services/GaugeCalculator';
import { createDragodinde } from '@domain/entities/Dragodinde';
import type { Enclos } from '@domain/entities/Enclos';
function makeEnclos(overrides: Partial<Enclos> = {}): Enclos {
return {
id: 1,
name: 'Test',
activeGauges: ['mangeoire'],
gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 50000 },
dragodindes: [createDragodinde(1)],
nextDdId: 2,
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} },
alerted: {},
...overrides,
} as Enclos;
}
describe('Regression: timer prend en compte levelTarget', () => {
it('computeGaugeLive utilise levelTarget pour le countdown mangeoire', () => {
const enc = makeEnclos();
const dd = { ...enc.dragodindes[0]!, levelTarget: 10 };
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
// Avec levelTarget=10, xpNeeded = xpForLevel(10) - xpForLevel(1) = 809
const xpNeeded = xpForLevel(10) - xpForLevel(1);
expect(xpNeeded).toBe(809);
// Le countdown devrait être timeToGain(50000, 809)
const expectedSec = timeToGain(50000, 809);
expect(r.cntDown).toBe(expectedSec);
expect(r.done).toBe(false);
});
it('computeGaugeLive sans levelTarget utilise 200 par défaut', () => {
const enc = makeEnclos();
const dd = { ...enc.dragodindes[0]!, levelTarget: null };
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
// Avec levelTarget=null → 200, xpNeeded >> gauge level
// cntDown = timeToGain(50000, 50000) = drain time
const drainTime = timeToGain(50000, 50000);
expect(r.cntDown).toBe(drainTime);
});
it('levelTarget=10 donne un countdown beaucoup plus court que levelTarget=null', () => {
const enc = makeEnclos();
const ddWithTarget = { ...enc.dragodindes[0]!, levelTarget: 10 };
const ddWithoutTarget = { ...enc.dragodindes[0]!, levelTarget: null };
const rWith = computeGaugeLive(enc, ddWithTarget, 'mangeoire', 0, false);
const rWithout = computeGaugeLive(enc, ddWithoutTarget, 'mangeoire', 0, false);
// Avec target 10 : ~410 sec. Sans target (200) : ~45000 sec.
expect(rWith.cntDown).toBeLessThan(1000); // < ~16 minutes
expect(rWithout.cntDown).toBeGreaterThan(40000); // > ~11 heures
});
it('enclosGlobalState reflète le levelTarget dans globalMax', () => {
const enc = makeEnclos();
enc.dragodindes[0]!.levelTarget = 10;
const state = enclosGlobalState(enc);
// Avant démarrage, started=false → globalMax devrait refléter le target 10
expect(state.globalMax).toBeLessThan(1000);
});
it('enclosGlobalState sans levelTarget a un globalMax plus élevé', () => {
const enc = makeEnclos();
enc.dragodindes[0]!.levelTarget = null;
const state = enclosGlobalState(enc);
expect(state.globalMax).toBeGreaterThan(40000);
});
it('calcLevelEtaLive utilise levelTarget', () => {
const enc = makeEnclos();
const dd = { ...enc.dragodindes[0]!, levelTarget: 5 };
const eta = calcLevelEtaLive(enc, dd, 0, false);
// Level 1→5 needs 161 XP, timeToGain(50000, 161) = 90 sec
expect(eta).toContain('1m');
});
it('done=true quand estLevel >= levelTarget', () => {
const enc = makeEnclos();
// DD déjà au level 50, target 50
const dd = { ...enc.dragodindes[0]!, stats: { ...enc.dragodindes[0]!.stats, xp: 50 }, levelTarget: 50 };
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
expect(r.done).toBe(true);
expect(r.cntDown).toBe(0);
});
it('done=true quand estLevel > levelTarget', () => {
const enc = makeEnclos();
const dd = { ...enc.dragodindes[0]!, stats: { ...enc.dragodindes[0]!.stats, xp: 100 }, levelTarget: 50 };
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
expect(r.done).toBe(true);
});
it('timer en cours avec levelTarget=10 : countdown reflète le target', () => {
const now = Date.now();
const enc = makeEnclos({
timer: {
running: true,
startTime: now - 100_000, // 100 sec écoulées
pausedAt: null,
pausedMs: 0,
snapGauges: { mangeoire: 50000 },
snapStats: { '1': { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
gaugeRecharges: {},
},
});
const ddWithTarget = { ...enc.dragodindes[0]!, levelTarget: 10 };
const ddNoTarget = { ...enc.dragodindes[0]!, levelTarget: null };
const rWith = computeGaugeLive(enc, ddWithTarget, 'mangeoire', 100, true);
const rNo = computeGaugeLive(enc, ddNoTarget, 'mangeoire', 100, true);
// Avec target 10 : cntDown devrait être < 1000 (beaucoup moins que sans target)
// Sans target (200) : cntDown devrait être > 30000
expect(rWith.cntDown).toBeLessThan(rNo.cntDown);
// Plus spécifiquement, avec target 10 après 100sec, le target devrait presque être atteint
// xpNeeded = 809, et en 100sec (10 ticks) on gagne 10*20 = 200xp (tier 2)
// xpRestante = 809 - 200 = 609
expect(rWith.cntDown).toBeLessThan(500); // < 500 sec restantes
});
it('changer levelTarget pendant le timer met à jour le globalMax', () => {
const now = Date.now();
const enc = makeEnclos({
timer: {
running: true,
startTime: now - 50_000, // 50 sec écoulées
pausedAt: null,
pausedMs: 0,
snapGauges: { mangeoire: 50000 },
snapStats: { '1': { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
gaugeRecharges: {},
},
});
// D'abord sans target (level 200 par défaut)
enc.dragodindes[0]!.levelTarget = null;
const state1 = enclosGlobalState(enc);
// Puis avec target 10
enc.dragodindes[0]!.levelTarget = 10;
const state2 = enclosGlobalState(enc);
// Le globalMax devrait diminuer drastiquement
expect(state2.globalMax).toBeLessThan(state1.globalMax);
expect(state2.globalMax).toBeLessThan(1000);
});
});

View File

@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { EventBus } from '@domain/events/EventBus';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
import { createResetStatsHandler } from '@application/commands/ResetStats';
function makeState(): AppState {
let enc = createEnclos(1);
enc = addDragodinde(enc);
return {
enclos: [enc], activeId: 1, nextEnclosId: 2,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [{ baby: 'Dorée et Rousse', couples: 3, babiesObtained: 2 }],
inventaire: {}, workflows: [],
accouplements: [{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '' }],
};
}
describe('Regression: Stats persistence', () => {
it('deleting enclos preserves global stats (archivedStats + accouplements)', () => {
const state = makeState();
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
const events = new EventBus();
const handler = createDeleteEnclosHandler(state, repo, events);
handler({ type: 'delete-enclos', enclosId: 1 });
// Enclos deleted but stats remain
expect(state.enclos).toHaveLength(0);
expect(state.archivedStats).toHaveLength(1);
expect(state.accouplements).toHaveLength(1);
});
it('reset stats clears everything', () => {
const state = makeState();
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
createResetStatsHandler(state, repo)({ type: 'reset-stats' });
expect(state.archivedStats).toHaveLength(0);
expect(state.accouplements).toHaveLength(0);
});
});

View File

@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';
import { xpForLevel } from '@domain/value-objects/XpTable';
import { timeToGain } from '@domain/services/GaugeCalculator';
describe('Regression: XP timer display (v1.1.4 bug)', () => {
const calc = new XpCalculator();
it('should NOT show ~23h for level 1→5 with tier 2 mangeoire', () => {
const r = calc.computeEta({
currentLevel: 1, target: 5,
gaugeLevels: { mangeoire: 50000 },
activeGauges: ['mangeoire'],
});
// XP needed: xpForLevel(5) - xpForLevel(1) = 161 - 0 = 161
// timeToGain(50000, 161): tier2 zone (10000 pts at rate 20) → ceil(161/20)*10 = 90 sec
expect(r.seconds).toBe(90);
expect(r.seconds).toBeLessThan(3600); // Must be under 1h, NOT 23h
});
it('should NOT show ~23h for level 1→10 with tier 1 mangeoire', () => {
const r = calc.computeEta({
currentLevel: 1, target: 10,
gaugeLevels: { mangeoire: 30000 },
activeGauges: ['mangeoire'],
});
// XP needed: xpForLevel(10) - xpForLevel(1) = 809
// timeToGain(30000, 809): tier1 zone → ceil(809/10)*10 = 810 sec
expect(r.seconds).toBe(810);
expect(r.seconds).toBeLessThan(3600);
});
it('XP model uses depleting gauge (same as other stats)', () => {
// Bug fix: XP uses gainedIn/timeToGain (depleting gauge), NOT a fixed rate.
// The gauge depletes from 70000 → 0 giving decreasing XP per tick.
// Real scenario: gauge 70000, level 1→67, xpNeeded = 67942
const xpNeeded = xpForLevel(67) - xpForLevel(1); // 67942
const sec = timeToGain(70000, xpNeeded);
// Tier2 (70000→40000): 30000pts at rate20 = 15000sec
// Tier1 (40000→2058): 37942pts at rate10 = 37950sec
// Total: 52950sec ≈ 14h42m
expect(sec).toBe(52950);
expect(sec).toBeLessThan(200000);
});
it('returns Infinity when gauge cannot provide enough XP without recharge', () => {
// Gauge 50000 = 50000 total pts. Level 1→100 needs 172668 XP → impossible without recharge.
const xpNeeded = xpForLevel(100) - xpForLevel(1); // 172668
const sec = timeToGain(50000, xpNeeded);
expect(sec).toBe(Infinity);
});
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';
import { QueryBus } from '@application/handlers/QueryBus';
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' })).toThrow('No handler for command: unknown');
});
it('has returns true for registered', () => {
const bus = new CommandBus();
bus.register('test', vi.fn());
expect(bus.has('test')).toBe(true);
expect(bus.has('other')).toBe(false);
});
});
describe('QueryBus', () => {
it('returns value from handler', () => {
const bus = new QueryBus();
bus.register('get-dashboard', () => ({ total: 42 }));
const result = bus.execute<{ total: number }>({ type: 'get-dashboard' });
expect(result.total).toBe(42);
});
it('throws on unknown query', () => {
const bus = new QueryBus();
expect(() => bus.execute({ type: 'unknown' })).toThrow('No handler for query: unknown');
});
it('has returns true for registered', () => {
const bus = new QueryBus();
bus.register('test', () => null);
expect(bus.has('test')).toBe(true);
});
});

View File

@ -0,0 +1,822 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { EventBus } from '@domain/events/EventBus';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import { createStartTimerHandler } from '@application/commands/StartTimer';
import { createStopTimerHandler } from '@application/commands/StopTimer';
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
import { createRemoveDragodindeHandler } from '@application/commands/RemoveDragodinde';
import { createToggleGaugeHandler, createUpdateGaugeLevelHandler } from '@application/commands/UpdateGauge';
import { createRegisterAccouplementHandler } from '@application/commands/RegisterAccouplement';
import { createUpdateSettingsHandler } from '@application/commands/UpdateSettings';
import { createResetStatsHandler } from '@application/commands/ResetStats';
import { createRechargeGaugeHandler } from '@application/commands/RechargeGauge';
import { createResetTimerHandler, createClearEnclosHandler, createRenameEnclosHandler, createNouvelleFourneeHandler } from '@application/commands/EnclosActions';
import { createSaveWorkflowHandler } from '@application/commands/SaveWorkflow';
import { createCompleteTimerHandler } from '@application/commands/CompleteTimer';
import { createDeleteWorkflowHandler } from '@application/commands/DeleteWorkflow';
import { createRenameDragodindeHandler, createUpdateDdStatHandler, createUpdateDdSerenTargetHandler, createUpdateDdLevelTargetHandler, createReorderDragodindeHandler } from '@application/commands/DragodindeActions';
import { createImportWorkflowsHandler } from '@application/commands/ImportWorkflows';
import { createReorderEnclosHandler } from '@application/commands/ReorderEnclos';
import { createUpdateWorkflowHandler } from '@application/commands/UpdateWorkflow';
import type { WorkflowItem } from '@application/queries/GetWorkflows';
function makeState(): AppState {
let enc = createEnclos(1);
enc = addDragodinde(enc);
enc.activeGauges = ['baffeur'];
enc.gaugeLevels.baffeur = 50000;
return {
enclos: [enc], activeId: 1, nextEnclosId: 2,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
}
function makeRepo(): StateRepository {
return { load: vi.fn(async () => null), save: vi.fn() };
}
describe('StartTimer', () => {
it('starts timer on enclos', () => {
const state = makeState();
const repo = makeRepo();
const handler = createStartTimerHandler(state, repo);
handler({ type: 'start-timer', enclosId: 1 });
expect(state.enclos[0]!.timer.running).toBe(true);
expect(state.enclos[0]!.timer.startTime).not.toBeNull();
expect(state.enclos[0]!.timer.snapGauges).toHaveProperty('baffeur', 50000);
expect(repo.save).toHaveBeenCalled();
});
it('ignores if already running', () => {
const state = makeState();
const repo = makeRepo();
const handler = createStartTimerHandler(state, repo);
handler({ type: 'start-timer', enclosId: 1 });
handler({ type: 'start-timer', enclosId: 1 }); // second call
expect(repo.save).toHaveBeenCalledTimes(1);
});
it('ignores if no gauges active', () => {
const state = makeState();
state.enclos[0]!.activeGauges = [];
const repo = makeRepo();
const handler = createStartTimerHandler(state, repo);
handler({ type: 'start-timer', enclosId: 1 });
expect(state.enclos[0]!.timer.running).toBe(false);
});
});
describe('StopTimer', () => {
it('stops running timer', () => {
const state = makeState();
const repo = makeRepo();
createStartTimerHandler(state, repo)({ type: 'start-timer', enclosId: 1 });
createStopTimerHandler(state, repo)({ type: 'stop-timer', enclosId: 1 });
expect(state.enclos[0]!.timer.running).toBe(false);
expect(state.enclos[0]!.timer.pausedAt).not.toBeNull();
});
});
describe('CreateEnclos', () => {
it('creates and activates new enclos', () => {
const state = makeState();
const repo = makeRepo();
createCreateEnclosHandler(state, repo)({ type: 'create-enclos' });
expect(state.enclos).toHaveLength(2);
expect(state.activeId).toBe(2);
expect(state.enclos[1]!.dragodindes).toHaveLength(1); // starts with 1 DD
});
});
describe('DeleteEnclos', () => {
it('deletes and switches active', () => {
const state = makeState();
const repo = makeRepo();
const events = new EventBus();
const handler = vi.fn();
events.on('enclos-deleted', handler);
createDeleteEnclosHandler(state, repo, events)({ type: 'delete-enclos', enclosId: 1 });
expect(state.enclos).toHaveLength(0);
expect(state.activeId).toBe('dashboard');
expect(handler).toHaveBeenCalled();
});
});
describe('AddDragodinde', () => {
it('adds DD to enclos', () => {
const state = makeState();
const repo = makeRepo();
createAddDragodindeHandler(state, repo)({ type: 'add-dragodinde', enclosId: 1 });
expect(state.enclos[0]!.dragodindes).toHaveLength(2);
});
});
describe('RemoveDragodinde', () => {
it('removes DD from enclos', () => {
const state = makeState();
const repo = makeRepo();
createRemoveDragodindeHandler(state, repo)({ type: 'remove-dragodinde', enclosId: 1, ddId: 1 });
expect(state.enclos[0]!.dragodindes).toHaveLength(0);
});
});
describe('ToggleGauge', () => {
it('toggles gauge on', () => {
const state = makeState();
state.enclos[0]!.activeGauges = [];
const repo = makeRepo();
createToggleGaugeHandler(state, repo)({ type: 'toggle-gauge', enclosId: 1, gaugeId: 'foudroyeur' });
expect(state.enclos[0]!.activeGauges).toContain('foudroyeur');
});
it('toggles gauge off', () => {
const state = makeState();
const repo = makeRepo();
createToggleGaugeHandler(state, repo)({ type: 'toggle-gauge', enclosId: 1, gaugeId: 'baffeur' });
expect(state.enclos[0]!.activeGauges).not.toContain('baffeur');
});
});
describe('UpdateGaugeLevel', () => {
it('clamps level between 0 and 100000', () => {
const state = makeState();
const repo = makeRepo();
createUpdateGaugeLevelHandler(state, repo)({ type: 'update-gauge-level', enclosId: 1, gaugeId: 'baffeur', level: 150000 });
expect(state.enclos[0]!.gaugeLevels.baffeur).toBe(100000);
});
});
describe('RegisterAccouplement', () => {
it('adds accouplement to state', () => {
const state = makeState();
const repo = makeRepo();
const events = new EventBus();
createRegisterAccouplementHandler(state, repo, events)({
type: 'register-accouplement',
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', couples: 5, babiesObtained: 3,
});
expect(state.accouplements).toHaveLength(1);
expect(state.accouplements[0]!.baby).toBe('Dorée et Rousse');
});
});
describe('UpdateSettings', () => {
it('updates alarm sound', () => {
const state = makeState();
const repo = makeRepo();
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', alarmSound: 'cloche' });
expect(state.alarmSound).toBe('cloche');
});
it('persists inventaire', () => {
const state = makeState();
const repo = makeRepo();
const inv = { Rousse: { m: 3, f: 2 }, Dorée: { m: 1, f: 4 } };
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', inventaire: inv });
expect(state.inventaire).toEqual(inv);
expect(repo.save).toHaveBeenCalled();
});
it('ne touche pas aux autres champs si seul inventaire est fourni', () => {
const state = makeState();
const repo = makeRepo();
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', inventaire: { Rousse: { m: 1, f: 0 } } });
expect(state.alarmSound).toBe('arpege');
expect(state.notifsEnabled).toBe(true);
});
});
describe('SaveWorkflow', () => {
it('crée un workflow avec matériaux et étapes', () => {
const state = makeState();
const repo = makeRepo();
const handler = createSaveWorkflowHandler(state, repo);
handler({
type: 'save-workflow',
target: 'Dorée et Rousse',
qty: 4,
materials: [{ race: 'Rousse', m: 4, f: 4 }, { race: 'Dorée', m: 4, f: 4 }],
steps: [{ baby: 'Dorée et Rousse', parentA: 'Rousse', parentB: 'Dorée', couples: 4, gen: 2 }],
repro: {},
});
expect(state.workflows).toHaveLength(1);
expect(state.workflows[0]!.target).toBe('Dorée et Rousse');
expect(state.workflows[0]!.qty).toBe(4);
expect(state.workflows[0]!.materials).toHaveLength(2);
expect(state.workflows[0]!.materials[0]!.needed).toBe(8); // m + f
expect(state.workflows[0]!.steps).toHaveLength(1);
expect(state.workflows[0]!.steps[0]!.gen).toBe(2);
expect(state.workflows[0]!.steps[0]!.crossings).toHaveLength(1);
expect(repo.save).toHaveBeenCalled();
});
it('groupe les étapes par génération', () => {
const state = makeState();
const repo = makeRepo();
createSaveWorkflowHandler(state, repo)({
type: 'save-workflow',
target: 'Ebène',
qty: 2,
materials: [],
steps: [
{ baby: 'Dorée et Rousse', parentA: 'Rousse', parentB: 'Dorée', couples: 4, gen: 2 },
{ baby: 'Amande et Dorée', parentA: 'Amande', parentB: 'Dorée', couples: 4, gen: 2 },
{ baby: 'Ebène', parentA: 'Amande et Dorée', parentB: 'Dorée et Rousse', couples: 2, gen: 3 },
],
repro: {},
});
const wf = state.workflows[0]!;
expect(wf.steps).toHaveLength(2); // gen 2 + gen 3
expect(wf.steps[0]!.gen).toBe(2);
expect(wf.steps[0]!.crossings).toHaveLength(2);
expect(wf.steps[1]!.gen).toBe(3);
expect(wf.steps[1]!.crossings).toHaveLength(1);
});
});
describe('ResetStats', () => {
it('clears stats and accouplements', () => {
const state = makeState();
state.archivedStats = [{ a: 1 }];
state.accouplements = [{ parentA: 'A', parentB: 'B', baby: 'C', gen: 2, couples: 1, babiesObtained: 1, date: '' }];
const repo = makeRepo();
createResetStatsHandler(state, repo)({ type: 'reset-stats' });
expect(state.archivedStats).toHaveLength(0);
expect(state.accouplements).toHaveLength(0);
});
});
describe('RechargeGauge', () => {
function makeRunningState(): AppState {
const state = makeState();
// Simuler un timer démarré
state.enclos[0]!.timer = {
running: true,
startTime: Date.now() - 3600_000, // démarré y'a 1h
pausedAt: null,
pausedMs: 0,
snapGauges: { baffeur: 50000 },
snapStats: {},
gaugeRecharges: {},
};
return state;
}
it('enregistre une recharge avec le elapsed courant', () => {
const state = makeRunningState();
const repo = makeRepo();
const handler = createRechargeGaugeHandler(state, repo);
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
const recharges = state.enclos[0]!.timer.gaugeRecharges['baffeur'];
expect(recharges).toHaveLength(1);
expect(recharges![0]!.level).toBe(90000);
// atSec ≈ 3600 sec (1h de timer)
expect(recharges![0]!.atSec).toBeGreaterThan(3500);
expect(recharges![0]!.atSec).toBeLessThan(3700);
});
it('met à jour gaugeLevels pour l\'affichage', () => {
const state = makeRunningState();
const repo = makeRepo();
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 75000 });
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']![0]!.level).toBe(75000);
expect(repo.save).toHaveBeenCalled();
});
it('recharges proches (<2s) sont consolidées (saisie temps réel)', () => {
const state = makeRunningState();
const repo = makeRepo();
const handler = createRechargeGaugeHandler(state, repo);
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 70000 });
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
// Les deux appels sont quasi-simultanés → consolidés en une seule entrée
const arr = state.enclos[0]!.timer.gaugeRecharges['baffeur']!;
expect(arr).toHaveLength(1);
expect(arr[0]!.level).toBe(90000);
});
it('ignore si le timer n\'est pas en cours', () => {
const state = makeState();
// timer non démarré
const repo = makeRepo();
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']).toBeUndefined();
expect(repo.save).not.toHaveBeenCalled();
});
it('reset-timer efface les recharges', () => {
const state = makeRunningState();
const repo = makeRepo();
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']).toHaveLength(1);
createResetTimerHandler(state, repo)({ type: 'reset-timer', enclosId: 1 });
expect(state.enclos[0]!.timer.gaugeRecharges).toEqual({});
});
});
describe('CompleteTimer', () => {
afterEach(() => {
vi.useRealTimers();
});
function makeRunningTimerState(): AppState {
const state = makeState();
const enc = state.enclos[0]!;
enc.activeGauges = ['foudroyeur'];
enc.gaugeLevels.foudroyeur = 80000;
enc.dragodindes[0]!.stats.endurance = 100;
enc.timer = {
running: true,
startTime: Date.now() - 7200_000, // démarré y'a 2h
pausedAt: null,
pausedMs: 0,
snapGauges: { foudroyeur: 80000 },
snapStats: { 1: { endurance: 100 } },
gaugeRecharges: {},
};
enc.alerted = {};
return state;
}
it('arrête le timer et persiste les stats finales', () => {
vi.useFakeTimers();
const now = Date.now();
vi.setSystemTime(now);
const state = makeState();
const enc = state.enclos[0]!;
enc.activeGauges = ['foudroyeur'];
enc.gaugeLevels.foudroyeur = 80000;
enc.dragodindes[0]!.stats.endurance = 100;
enc.timer = {
running: true,
startTime: now - 7200_000,
pausedAt: null,
pausedMs: 0,
snapGauges: { foudroyeur: 80000 },
snapStats: { 1: { endurance: 100 } },
gaugeRecharges: {},
};
enc.alerted = {};
const repo = makeRepo();
const events = new EventBus();
const handler = createCompleteTimerHandler(state, repo, events);
handler({ type: 'complete-timer', enclosId: 1 });
expect(enc.timer.running).toBe(false);
expect(enc.timer.pausedAt).toBe(now);
expect(enc.alerted['__done__']).toBe(true);
// endurance doit avoir augmenté par rapport à la valeur initiale
expect(enc.dragodindes[0]!.stats.endurance).toBeGreaterThan(100);
expect(repo.save).toHaveBeenCalled();
});
it('émet l\'événement timer-completed', () => {
vi.useFakeTimers();
vi.setSystemTime(Date.now());
const state = makeRunningTimerState();
const repo = makeRepo();
const events = new EventBus();
const eventHandler = vi.fn();
events.on('timer-completed', eventHandler);
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({ type: 'timer-completed' }));
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
const events = new EventBus();
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 999 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si le timer n\'est pas en cours', () => {
const state = makeState();
const repo = makeRepo();
const events = new EventBus();
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si déjà complété (__done__)', () => {
const state = makeRunningTimerState();
state.enclos[0]!.alerted['__done__'] = true;
const repo = makeRepo();
const events = new EventBus();
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('DeleteWorkflow', () => {
it('supprime un workflow par id', () => {
const state = makeState();
state.workflows = [{ id: 42, name: 'wf1', target: 'Dorée', qty: 1, createdAt: 0, materials: [], steps: [] }] as WorkflowItem[];
const repo = makeRepo();
createDeleteWorkflowHandler(state, repo)({ type: 'delete-workflow', workflowId: 42 });
expect(state.workflows).toHaveLength(0);
expect(repo.save).toHaveBeenCalled();
});
it('ne fait rien si le workflow n\'existe pas', () => {
const state = makeState();
state.workflows = [{ id: 42, name: 'wf1', target: 'Dorée', qty: 1, createdAt: 0, materials: [], steps: [] }] as WorkflowItem[];
const repo = makeRepo();
createDeleteWorkflowHandler(state, repo)({ type: 'delete-workflow', workflowId: 999 });
expect(state.workflows).toHaveLength(1);
expect(repo.save).toHaveBeenCalled();
});
});
describe('RenameDragodinde', () => {
it('renomme une dragodinde', () => {
const state = makeState();
const repo = makeRepo();
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: 'Flamme' });
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Flamme');
expect(repo.save).toHaveBeenCalled();
});
it('trim le nom', () => {
const state = makeState();
const repo = makeRepo();
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: ' Flamme ' });
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Flamme');
});
it('ignore si le nom est vide ou whitespace', () => {
const state = makeState();
const repo = makeRepo();
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: ' ' });
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Dragodinde 1');
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 999, ddId: 1, name: 'X' });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si la DD n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 999, name: 'X' });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('UpdateDdStat', () => {
it('met à jour une stat de la DD', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 1, ddId: 1, stat: 'endurance', value: 5000 });
expect(state.enclos[0]!.dragodindes[0]!.stats.endurance).toBe(5000);
expect(repo.save).toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 999, ddId: 1, stat: 'endurance', value: 5000 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si la DD n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 1, ddId: 999, stat: 'endurance', value: 5000 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('UpdateDdSerenTarget', () => {
it('définit une cible de sérénité', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 1, target: -2000 });
expect(state.enclos[0]!.dragodindes[0]!.sereniteTarget).toBe(-2000);
expect(repo.save).toHaveBeenCalled();
});
it('accepte null pour retirer la cible', () => {
const state = makeState();
state.enclos[0]!.dragodindes[0]!.sereniteTarget = -2000;
const repo = makeRepo();
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 1, target: null });
expect(state.enclos[0]!.dragodindes[0]!.sereniteTarget).toBeNull();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 999, ddId: 1, target: -2000 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si la DD n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 999, target: -2000 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('UpdateDdLevelTarget', () => {
it('définit une cible de niveau', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 1, ddId: 1, target: 100 });
expect(state.enclos[0]!.dragodindes[0]!.levelTarget).toBe(100);
expect(repo.save).toHaveBeenCalled();
});
it('accepte null pour retirer la cible', () => {
const state = makeState();
state.enclos[0]!.dragodindes[0]!.levelTarget = 100;
const repo = makeRepo();
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 1, ddId: 1, target: null });
expect(state.enclos[0]!.dragodindes[0]!.levelTarget).toBeNull();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 999, ddId: 1, target: 100 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('ReorderDragodinde', () => {
it('réordonne les DDs dans l\'enclos', () => {
const state = makeState();
// Ajouter une 2e DD
state.enclos[0] = addDragodinde(state.enclos[0]!);
const repo = makeRepo();
const dd1Id = state.enclos[0]!.dragodindes[0]!.id;
const dd2Id = state.enclos[0]!.dragodindes[1]!.id;
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 1, fromDdId: dd1Id, toDdId: dd2Id });
expect(state.enclos[0]!.dragodindes[0]!.id).toBe(dd2Id);
expect(state.enclos[0]!.dragodindes[1]!.id).toBe(dd1Id);
expect(repo.save).toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 999, fromDdId: 1, toDdId: 2 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si un des ddId n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 1, fromDdId: 1, toDdId: 999 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('ImportWorkflows', () => {
it('importe des workflows dans la liste existante', () => {
const state = makeState();
const repo = makeRepo();
const wf: WorkflowItem = { id: 10, name: 'Import1', target: 'Dorée', qty: 2, createdAt: 0, materials: [], steps: [] };
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [wf] });
expect(state.workflows).toHaveLength(1);
expect((state.workflows[0] as WorkflowItem).target).toBe('Dorée');
expect(repo.save).toHaveBeenCalled();
});
it('réattribue un id si doublon', () => {
const state = makeState();
const existingWf: WorkflowItem = { id: 10, name: 'Existant', target: 'Rousse', qty: 1, createdAt: 0, materials: [], steps: [] };
state.workflows = [existingWf] as any;
const repo = makeRepo();
const importWf: WorkflowItem = { id: 10, name: 'Import1', target: 'Dorée', qty: 2, createdAt: 0, materials: [], steps: [] };
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [importWf] });
expect(state.workflows).toHaveLength(2);
// L'id du workflow importé doit avoir changé (plus 10)
expect((state.workflows[1] as WorkflowItem).id).not.toBe(10);
expect((state.workflows[1] as WorkflowItem).target).toBe('Dorée');
});
it('importe plusieurs workflows sans conflit d\'id', () => {
const state = makeState();
const repo = makeRepo();
const wf1: WorkflowItem = { id: 1, name: 'A', target: 'A', qty: 1, createdAt: 0, materials: [], steps: [] };
const wf2: WorkflowItem = { id: 2, name: 'B', target: 'B', qty: 1, createdAt: 0, materials: [], steps: [] };
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [wf1, wf2] });
expect(state.workflows).toHaveLength(2);
});
});
describe('ReorderEnclos', () => {
it('réordonne les enclos', () => {
const state = makeState();
// Ajouter un 2e enclos
const enc2 = createEnclos(2, 'Enclos 2');
state.enclos.push(enc2);
const repo = makeRepo();
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: 1 });
expect(state.enclos[0]!.id).toBe(2);
expect(state.enclos[1]!.id).toBe(1);
expect(repo.save).toHaveBeenCalled();
});
it('ignore si fromIndex est négatif', () => {
const state = makeState();
const repo = makeRepo();
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: -1, toIndex: 0 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si toIndex est négatif', () => {
const state = makeState();
const repo = makeRepo();
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: -1 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si fromIndex dépasse la taille du tableau', () => {
const state = makeState();
const repo = makeRepo();
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 5, toIndex: 0 });
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si toIndex dépasse la taille du tableau', () => {
const state = makeState();
const repo = makeRepo();
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: 5 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('UpdateWorkflow', () => {
function makeWorkflowState(): AppState {
const state = makeState();
const wf: WorkflowItem = {
id: 1, name: 'Workflow 1', target: 'Dorée', qty: 2, createdAt: 0,
materials: [{ name: 'Rousse', needed: 4, done: 0 }, { name: 'Dorée', needed: 4, done: 0 }],
steps: [{
gen: 2,
crossings: [{ race: 'Dorée et Rousse', needed: 2, parentA: 'Rousse', parentB: 'Dorée', couples: 2, repro: 0, done: 0 }],
}],
};
state.workflows = [wf] as any;
return state;
}
it('met à jour le done d\'un matériau', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 0, done: 3 });
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(3);
expect(repo.save).toHaveBeenCalled();
});
it('met à jour le done d\'un crossing', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 0, crossingIdx: 0, done: 1 });
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(1);
expect(repo.save).toHaveBeenCalled();
});
it('clamp done à 0 minimum pour matériau', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 0, done: -5 });
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
});
it('clamp done à 0 minimum pour crossing', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 0, crossingIdx: 0, done: -3 });
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(0);
});
it('ignore si le workflow n\'existe pas', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 999, materialIdx: 0, done: 3 });
// L'état ne change pas mais save est quand même appelé (pas de guard sur save)
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
});
it('ignore si materialIdx hors limites', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 99, done: 3 });
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
});
it('ignore si stepIdx ou crossingIdx hors limites', () => {
const state = makeWorkflowState();
const repo = makeRepo();
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 99, crossingIdx: 0, done: 1 });
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(0);
});
});
describe('ClearEnclos', () => {
it('vide les DDs, jauges et timer de l\'enclos', () => {
const state = makeState();
const repo = makeRepo();
createClearEnclosHandler(state, repo)({ type: 'clear-enclos', enclosId: 1 });
const enc = state.enclos[0]!;
expect(enc.dragodindes).toHaveLength(0);
expect(enc.nextDdId).toBe(1);
expect(enc.activeGauges).toHaveLength(0);
expect(enc.gaugeLevels.baffeur).toBe(0);
expect(enc.timer.running).toBe(false);
expect(enc.timer.snapGauges).toEqual({});
expect(enc.alerted).toEqual({});
expect(repo.save).toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createClearEnclosHandler(state, repo)({ type: 'clear-enclos', enclosId: 999 });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('RenameEnclos', () => {
it('renomme un enclos', () => {
const state = makeState();
const repo = makeRepo();
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: 'Mon enclos' });
expect(state.enclos[0]!.name).toBe('Mon enclos');
expect(repo.save).toHaveBeenCalled();
});
it('trim le nom', () => {
const state = makeState();
const repo = makeRepo();
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: ' Paddock ' });
expect(state.enclos[0]!.name).toBe('Paddock');
});
it('ignore si le nom est vide ou whitespace', () => {
const state = makeState();
const repo = makeRepo();
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: ' ' });
expect(state.enclos[0]!.name).toBe('Enclos 1');
expect(repo.save).not.toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 999, name: 'Test' });
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('NouvelleFournee', () => {
it('réinitialise l\'enclos avec une DD fraîche et jauges à 0', () => {
const state = makeState();
const enc = state.enclos[0]!;
// Simuler un état "usé"
enc.gaugeLevels.baffeur = 80000;
enc.alerted['foudroyeur'] = true;
enc.timer = {
running: true, startTime: Date.now() - 1000, pausedAt: null, pausedMs: 0,
snapGauges: { baffeur: 80000 }, snapStats: {}, gaugeRecharges: {},
};
const repo = makeRepo();
createNouvelleFourneeHandler(state, repo)({ type: 'nouvelle-fournee', enclosId: 1 });
expect(enc.timer.running).toBe(false);
expect(enc.timer.startTime).toBeNull();
expect(enc.timer.gaugeRecharges).toEqual({});
expect(enc.alerted).toEqual({});
expect(enc.gaugeLevels.baffeur).toBe(0);
expect(enc.gaugeLevels.foudroyeur).toBe(0);
expect(enc.dragodindes).toHaveLength(1);
expect(enc.dragodindes[0]!.id).toBe(1);
expect(enc.dragodindes[0]!.stats.endurance).toBe(0);
expect(enc.nextDdId).toBe(2);
expect(repo.save).toHaveBeenCalled();
});
it('ignore si l\'enclos n\'existe pas', () => {
const state = makeState();
const repo = makeRepo();
createNouvelleFourneeHandler(state, repo)({ type: 'nouvelle-fournee', enclosId: 999 });
expect(repo.save).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,673 @@
import { describe, it, expect } from 'vitest';
import type { AppState } from '@domain/ports/StateRepository';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
import { createGetEnclosDetailHandler } from '@application/queries/GetEnclosDetail';
import { createGetTimerStateHandler } from '@application/queries/GetTimerState';
import { createGetBreedingOptionsHandler } from '@application/queries/GetBreedingOptions';
import { createGetReapproTreeHandler } from '@application/queries/GetReapproTree';
import { createGetInventaireHandler } from '@application/queries/GetInventaire';
import { createGetSettingsHandler } from '@application/queries/GetSettings';
import { createGetStatisticsHandler, TOTAL_RACES } from '@application/queries/GetStatistics';
import { createGetWorkflowsHandler } from '@application/queries/GetWorkflows';
function makeState(): AppState {
let enc = createEnclos(1);
enc = addDragodinde(enc);
return {
enclos: [enc], activeId: 1, 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' },
],
};
}
describe('GetDashboard', () => {
it('aggregates stats', () => {
const state = makeState();
const handler = createGetDashboardHandler(state);
const r = handler({ type: 'get-dashboard' });
expect(r.totalCouples).toBe(5);
expect(r.totalBabies).toBe(3);
expect(r.enclosSummaries).toHaveLength(1);
expect(r.successRate).toBe(60);
expect(r.raceBreakdown['Dorée et Rousse']).toBe(3);
});
it('handles empty state', () => {
const state = makeState();
state.accouplements = [];
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
expect(r.totalCouples).toBe(0);
expect(r.successRate).toBe(0);
});
});
describe('GetEnclosDetail', () => {
it('returns enclos by id', () => {
const state = makeState();
const handler = createGetEnclosDetailHandler(state);
const r = handler({ type: 'get-enclos-detail', enclosId: 1 });
expect(r).not.toBeNull();
expect(r!.id).toBe(1);
});
it('returns null for unknown id', () => {
const state = makeState();
const r = createGetEnclosDetailHandler(state)({ type: 'get-enclos-detail', enclosId: 999 });
expect(r).toBeNull();
});
});
describe('GetTimerState', () => {
it('returns timer state', () => {
const state = makeState();
const r = createGetTimerStateHandler(state)({ type: 'get-timer-state', enclosId: 1 });
expect(r).not.toBeNull();
expect(r!.running).toBe(false);
});
it('returns null for unknown enclos', () => {
const state = makeState();
const r = createGetTimerStateHandler(state)({ type: 'get-timer-state', enclosId: 999 });
expect(r).toBeNull();
});
});
describe('GetBreedingOptions', () => {
it('returns partners for Rousse', () => {
const handler = createGetBreedingOptionsHandler();
const r = handler({ type: 'get-breeding-options', race: 'Rousse' });
expect(r.partners.length).toBeGreaterThan(0);
expect(r.partners.some(p => p.partner === 'Dorée')).toBe(true);
});
});
describe('GetReapproTree', () => {
it('computes breeding plan', () => {
const handler = createGetReapproTreeHandler();
const r = handler({ type: 'get-reappro-tree', target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: {} });
expect(r.steps).toHaveLength(1);
expect(r.totalGen1).toBe(8);
});
});
describe('GetInventaire', () => {
it('retourne le stock brut de l\'inventaire', () => {
const state = makeState();
state.inventaire = { Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } };
const handler = createGetInventaireHandler(state);
const r = handler({ type: 'get-inventaire' });
expect(r).toEqual({ Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } });
});
it('inventaire vide retourne objet vide', () => {
const state = makeState();
const r = createGetInventaireHandler(state)({ type: 'get-inventaire' });
expect(r).toEqual({});
});
it('reflète les changements de state.inventaire en temps réel', () => {
const state = makeState();
const handler = createGetInventaireHandler(state);
expect(handler({ type: 'get-inventaire' })).toEqual({});
state.inventaire = { Amande: { m: 3, f: 2 } };
expect(handler({ type: 'get-inventaire' })).toEqual({ Amande: { m: 3, f: 2 } });
});
});
// ── Helpers pour les dates relatives ──────────────────────────────
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString().slice(0, 10);
}
function dayOfWeekName(dateStr: string): string {
const names = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
const d = new Date(dateStr + 'T12:00:00');
return names[d.getDay()];
}
// ══════════════════════════════════════════════════════════════════
// GetDashboard — archivedStats path (lignes 44-51)
// ══════════════════════════════════════════════════════════════════
describe('GetDashboard — archivedStats', () => {
it('agrège accouplements + archivedStats', () => {
const state = makeState();
state.archivedStats = [
{ baby: 'Ebène', couples: 10, babiesObtained: 4 },
{ baby: 'Dorée et Rousse', couples: 3, babiesObtained: 1 },
];
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
// accouplements: 5 couples, 3 babies + archived: 13 couples, 5 babies
expect(r.totalCouples).toBe(18);
expect(r.totalBabies).toBe(8);
expect(r.raceBreakdown['Ebène']).toBe(4);
expect(r.raceBreakdown['Dorée et Rousse']).toBe(4); // 3 + 1
expect(r.successRate).toBe(44); // Math.round(8/18*100)
});
it('ignore les entrées archivedStats sans baby', () => {
const state = makeState();
state.archivedStats = [
{ couples: 10, babiesObtained: 4 }, // pas de baby
];
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
expect(r.totalCouples).toBe(5);
expect(r.totalBabies).toBe(3);
});
it('gère les champs manquants dans archivedStats', () => {
const state = makeState();
state.accouplements = [];
state.archivedStats = [
{ baby: 'Indigo' }, // couples et babiesObtained absents → default 0
];
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
expect(r.totalCouples).toBe(0);
expect(r.totalBabies).toBe(0);
expect(r.raceBreakdown['Indigo']).toBe(0);
expect(r.successRate).toBe(0);
});
});
// ══════════════════════════════════════════════════════════════════
// GetSettings
// ══════════════════════════════════════════════════════════════════
describe('GetSettings', () => {
it('retourne les paramètres courants', () => {
const state = makeState();
const r = createGetSettingsHandler(state)({ type: 'get-settings' });
expect(r.alarmSound).toBe('arpege');
expect(r.notifsEnabled).toBe(true);
expect(r.ntfyTopic).toBe('');
});
it('reflète les changements de paramètres en temps réel', () => {
const state = makeState();
const handler = createGetSettingsHandler(state);
state.alarmSound = 'gong';
state.notifsEnabled = false;
state.ntfyTopic = 'mon-topic';
const r = handler({ type: 'get-settings' });
expect(r.alarmSound).toBe('gong');
expect(r.notifsEnabled).toBe(false);
expect(r.ntfyTopic).toBe('mon-topic');
});
});
// ══════════════════════════════════════════════════════════════════
// GetWorkflows
// ══════════════════════════════════════════════════════════════════
describe('GetWorkflows', () => {
it('retourne la liste des workflows', () => {
const state = makeState();
const wf = {
id: 1, name: 'Test WF', target: 'Ebène', qty: 2, createdAt: Date.now(),
materials: [{ name: 'Rousse', needed: 4, done: 2 }],
steps: [{ gen: 2, crossings: [{ race: 'Dorée et Rousse', needed: 2, parentA: 'Dorée', parentB: 'Rousse', couples: 2, repro: 1, done: 1 }] }],
};
state.workflows = [wf];
const r = createGetWorkflowsHandler(state)({ type: 'get-workflows' });
expect(r).toHaveLength(1);
expect(r[0]).toBe(wf);
});
it('retourne un tableau vide quand aucun workflow', () => {
const state = makeState();
const r = createGetWorkflowsHandler(state)({ type: 'get-workflows' });
expect(r).toEqual([]);
});
it('reflète les changements en temps réel', () => {
const state = makeState();
const handler = createGetWorkflowsHandler(state);
expect(handler({ type: 'get-workflows' })).toEqual([]);
state.workflows = [{ id: 42, name: 'WF2', target: 'Indigo', qty: 1, createdAt: 0, materials: [], steps: [] }];
expect(handler({ type: 'get-workflows' })).toHaveLength(1);
});
});
// ══════════════════════════════════════════════════════════════════
// GetStatistics
// ══════════════════════════════════════════════════════════════════
describe('GetStatistics', () => {
// ── Historique vide ────────────────────────────────────────────
it('historique vide retourne des KPI à zéro', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalBabies.value).toBe(0);
expect(r.totalCouples.value).toBe(0);
expect(r.successRate.value).toBe(0);
expect(r.racesCount.value).toBe(0);
expect(r.raceShares).toEqual([]);
expect(r.raceSuccessRates).toEqual([]);
expect(r.bestCouples).toEqual([]);
expect(r.genBreakdown).toEqual([]);
expect(r.weekdayActivity).toHaveLength(7);
expect(r.weekdayActivity.every(w => w.count === 0)).toBe(true);
});
// ── days=0 → tout l'historique, delta=null ──────────────────
it('days=0 retourne tout l\'historique sans delta', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 6, date: '2025-06-15' },
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 3, date: '2025-01-10' },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.days).toBe(0);
expect(r.totalBabies).toEqual({ value: 9, delta: null });
expect(r.totalCouples).toEqual({ value: 18, delta: null });
expect(r.successRate).toEqual({ value: 50, delta: null });
expect(r.racesCount).toEqual({ value: 2, delta: null });
});
// ── Exclusion Gen 1 ────────────────────────────────────────────
it('exclut les entrées Gen 1 (Rousse, Dorée, Amande)', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'X', parentB: 'Y', baby: 'Rousse', gen: 1, couples: 100, babiesObtained: 50, date: daysAgo(1) },
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 4, babiesObtained: 2, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalCouples.value).toBe(4);
expect(r.totalBabies.value).toBe(2);
});
it('exclut les entrées dont le baby est Gen 1 même si gen != 1', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'A', parentB: 'B', baby: 'Amande', gen: 0, couples: 20, babiesObtained: 10, date: daysAgo(1) },
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalCouples.value).toBe(5);
expect(r.totalBabies.value).toBe(3);
});
// ── Filtrage par période avec delta ─────────────────────────
it('days=7 filtre la période courante et calcule le delta', () => {
const state = makeState();
state.accouplements = [
// Période courante (7 derniers jours)
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 6, date: daysAgo(2) },
// Période précédente (7 à 14 jours)
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 2, date: daysAgo(10) },
// Trop ancien (hors des deux fenêtres)
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 100, babiesObtained: 50, date: '2020-01-01' },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
expect(r.days).toBe(7);
expect(r.totalBabies.value).toBe(6);
expect(r.totalBabies.delta).toBe(6 - 2); // cur - prev
expect(r.totalCouples.value).toBe(10);
expect(r.totalCouples.delta).toBe(10 - 8);
});
// ── Default days=30 ─────────────────────────────────────────
it('days par défaut est 30', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics' });
expect(r.days).toBe(30);
});
// ── archivedStats agrégation ──────────────────────────────────
it('inclut les archivedStats dans les statistiques', () => {
const state = makeState();
state.accouplements = [];
state.archivedStats = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 7, babiesObtained: 4, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalCouples.value).toBe(7);
expect(r.totalBabies.value).toBe(4);
});
it('exclut les archivedStats Gen 1', () => {
const state = makeState();
state.accouplements = [];
state.archivedStats = [
{ baby: 'Rousse', gen: 1, couples: 50, babiesObtained: 25, date: daysAgo(1) },
{ baby: 'Dorée et Rousse', gen: 2, couples: 3, babiesObtained: 1, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalCouples.value).toBe(3);
expect(r.totalBabies.value).toBe(1);
});
it('archivedStats avec champs manquants utilise des défauts', () => {
const state = makeState();
state.accouplements = [];
state.archivedStats = [
{ baby: 'Ebène' }, // gen, couples, babiesObtained, date, parentA, parentB absents
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
// Ebène est Gen 3, ne sera pas exclu
expect(r.totalCouples.value).toBe(0);
expect(r.totalBabies.value).toBe(0);
});
it('ignore les archivedStats sans baby', () => {
const state = makeState();
state.accouplements = [];
state.archivedStats = [
{ couples: 10, babiesObtained: 5 },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.totalCouples.value).toBe(0);
expect(r.totalBabies.value).toBe(0);
});
// ── Répartition des races (raceShares) ─────────────────────
it('calcule la répartition des races triée par count décroissant', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 7, date: daysAgo(1) },
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.raceShares).toHaveLength(2);
expect(r.raceShares[0].race).toBe('Dorée et Rousse');
expect(r.raceShares[0].count).toBe(7);
expect(r.raceShares[0].pct).toBe(70); // 7/10*100
expect(r.raceShares[1].race).toBe('Amande et Dorée');
expect(r.raceShares[1].count).toBe(3);
expect(r.raceShares[1].pct).toBe(30);
});
// ── Taux de réussite par race ──────────────────────────────
it('calcule le taux de réussite par race', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 8, date: daysAgo(1) },
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.raceSuccessRates).toHaveLength(2);
// Trié par rate décroissant
expect(r.raceSuccessRates[0].race).toBe('Dorée et Rousse');
expect(r.raceSuccessRates[0].rate).toBe(80);
expect(r.raceSuccessRates[1].race).toBe('Amande et Dorée');
expect(r.raceSuccessRates[1].rate).toBe(30);
});
// ── Meilleurs couples ──────────────────────────────────────
it('calcule les meilleurs couples et normalise l\'ordre A|B', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 4, date: daysAgo(1) },
{ parentA: 'Dorée', parentB: 'Rousse', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(2) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
// Les deux entrées doivent être fusionnées (clé normalisée)
expect(r.bestCouples).toHaveLength(1);
expect(r.bestCouples[0].couples).toBe(10);
expect(r.bestCouples[0].babies).toBe(7);
expect(r.bestCouples[0].rate).toBe(70);
});
it('ignore les couples sans parentA ou parentB', () => {
const state = makeState();
state.accouplements = [
{ parentA: '', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.bestCouples).toEqual([]);
});
it('limite les meilleurs couples à 10', () => {
const state = makeState();
state.accouplements = [];
// Créer 12 couples distincts
const races = [
'Dorée et Rousse', 'Amande et Rousse', 'Amande et Dorée',
'Ebène', 'Indigo',
'Indigo et Rousse', 'Ebène et Rousse', 'Amande et Indigo',
'Amande et Ebène', 'Dorée et Indigo', 'Dorée et Ebène',
'Ebène et Indigo',
];
for (let i = 0; i < 12; i++) {
state.accouplements.push({
parentA: `ParentA${i}`, parentB: `ParentB${i}`, baby: races[i],
gen: i < 3 ? 2 : i < 5 ? 3 : 4,
couples: 5, babiesObtained: 3, date: daysAgo(1),
});
}
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.bestCouples).toHaveLength(10);
});
// ── Répartition par génération ──────────────────────────────
it('calcule la répartition par génération', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 3, date: daysAgo(1) },
{ parentA: 'Amande et Dorée', parentB: 'Dorée et Rousse', baby: 'Ebène', gen: 3, couples: 6, babiesObtained: 2, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.genBreakdown).toHaveLength(2);
const gen2 = r.genBreakdown.find(g => g.gen === 2)!;
const gen3 = r.genBreakdown.find(g => g.gen === 3)!;
expect(gen2.babies).toBe(8);
expect(gen2.couples).toBe(18);
expect(gen2.races).toBe(2);
expect(gen3.babies).toBe(2);
expect(gen3.couples).toBe(6);
expect(gen3.races).toBe(1);
});
it('genBreakdown trié par gen croissant', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'A', parentB: 'B', baby: 'Ebène', gen: 3, couples: 5, babiesObtained: 2, date: daysAgo(1) },
{ parentA: 'C', parentB: 'D', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.genBreakdown[0].gen).toBe(2);
expect(r.genBreakdown[1].gen).toBe(3);
});
// ── Races manquantes ────────────────────────────────────────
it('calcule les races manquantes (exclut Gen 1 et races obtenues)', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
// Dorée et Rousse obtenue → ne doit pas apparaître
expect(r.missingRaces.find(m => m.name === 'Dorée et Rousse')).toBeUndefined();
// Les Gen 1 ne doivent pas apparaître
expect(r.missingRaces.find(m => m.name === 'Rousse')).toBeUndefined();
expect(r.missingRaces.find(m => m.name === 'Dorée')).toBeUndefined();
expect(r.missingRaces.find(m => m.name === 'Amande')).toBeUndefined();
// Races non obtenues doivent apparaître
expect(r.missingRaces.find(m => m.name === 'Amande et Rousse')).toBeDefined();
expect(r.missingRaces.length).toBe(TOTAL_RACES - 1); // 63 - 1 obtenue
});
it('races manquantes utilise all (pas seulement la période courante)', () => {
const state = makeState();
state.accouplements = [
// Très ancien mais obtenu
{ parentA: 'A', parentB: 'B', baby: 'Ebène', gen: 3, couples: 5, babiesObtained: 1, date: '2020-01-01' },
// Récent
{ parentA: 'C', parentB: 'D', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 2, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
// Ebène obtenue même si hors période courante → exclue des manquantes
expect(r.missingRaces.find(m => m.name === 'Ebène')).toBeUndefined();
expect(r.missingRaces.length).toBe(TOTAL_RACES - 2);
});
it('races manquantes triées par gen croissant puis nom', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
// Vérifier le tri
for (let i = 1; i < r.missingRaces.length; i++) {
const prev = r.missingRaces[i - 1];
const cur = r.missingRaces[i];
if (prev.gen === cur.gen) {
expect(prev.name.localeCompare(cur.name)).toBeLessThanOrEqual(0);
} else {
expect(prev.gen).toBeLessThan(cur.gen);
}
}
});
// ── Naissances par jour (dailyBirths) ──────────────────────
it('génère 30 jours de naissances quand days=0', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.dailyBirths).toHaveLength(30);
});
it('génère N jours de naissances quand days=N', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
expect(r.dailyBirths).toHaveLength(7);
});
it('dailyBirths contient les naissances du jour', () => {
const today = daysAgo(0);
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: today },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
const todayEntry = r.dailyBirths.find(d => d.date === today);
expect(todayEntry).toBeDefined();
expect(todayEntry!.count).toBe(3);
});
it('dailyBirths remplit 0 pour les jours sans naissance', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
expect(r.dailyBirths.every(d => d.count === 0)).toBe(true);
});
it('dailyBirths format label DD/MM', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
for (const d of r.dailyBirths) {
expect(d.label).toMatch(/^\d{2}\/\d{2}$/);
}
});
// ── Activité par jour de la semaine ─────────────────────────
it('activité par jour de la semaine commence lundi et finit dimanche', () => {
const state = makeState();
state.accouplements = [];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.weekdayActivity).toHaveLength(7);
expect(r.weekdayActivity[0].day).toBe('Lundi');
expect(r.weekdayActivity[6].day).toBe('Dimanche');
});
it('activité par jour de la semaine comptabilise les naissances', () => {
const today = daysAgo(0);
const todayName = dayOfWeekName(today);
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 7, date: today },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
const dayEntry = r.weekdayActivity.find(w => w.day === todayName);
expect(dayEntry).toBeDefined();
expect(dayEntry!.count).toBe(7);
});
// ── Entrées sans date valide ────────────────────────────────
it('entrées sans date ne cassent pas le weekdayActivity ni dailyBirths', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '' },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
// Pas de crash, les compteurs weekday sont à 0
expect(r.weekdayActivity).toHaveLength(7);
expect(r.totalBabies.value).toBe(3); // toujours compté dans le total
});
// ── racesCount ne compte que les races avec babiesObtained > 0 ──
it('racesCount ne compte que les races avec au moins un bébé', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 0, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
expect(r.racesCount.value).toBe(1); // Seule Dorée et Rousse a des bébés
});
// ── raceSuccessRates avec couples=0 ─────────────────────────
it('raceSuccessRates rate=0 quand couples=0', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 0, babiesObtained: 0, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
const dr = r.raceSuccessRates.find(rs => rs.race === 'Dorée et Rousse');
expect(dr).toBeDefined();
expect(dr!.rate).toBe(0);
});
// ── genBreakdown ne compte les races que si babiesObtained > 0 ──
it('genBreakdown.races ne compte que les races avec bébés', () => {
const state = makeState();
state.accouplements = [
{ parentA: 'A', parentB: 'B', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
{ parentA: 'C', parentB: 'D', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 0, date: daysAgo(1) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
const gen2 = r.genBreakdown.find(g => g.gen === 2)!;
expect(gen2.races).toBe(1); // Seule Dorée et Rousse
});
// ── Delta successRate ──────────────────────────────────────
it('delta successRate calcule la différence entre périodes', () => {
const state = makeState();
state.accouplements = [
// Période courante : 10 couples, 8 bébés → 80%
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 8, date: daysAgo(2) },
// Période précédente : 10 couples, 5 bébés → 50%
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(10) },
];
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
expect(r.successRate.value).toBe(80);
expect(r.successRate.delta).toBe(30); // 80 - 50
});
});

View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';
describe('BreedingService', () => {
const svc = new BreedingService();
it('deduces baby from two parents (order A)', () => {
expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
});
it('deduces baby from two parents (order B — bidirectional)', () => {
expect(svc.deduceBaby('Dorée', 'Rousse')).toBe('Dorée et Rousse');
});
it('returns null for incompatible parents', () => {
expect(svc.deduceBaby('Rousse', 'Rousse')).toBeNull();
});
it('returns null for unknown parents', () => {
expect(svc.deduceBaby('Unknown', 'Dorée')).toBeNull();
});
it('lists compatible partners for Rousse', () => {
const partners = svc.getCompatiblePartners('Rousse');
expect(partners.length).toBeGreaterThan(0);
expect(partners.some(p => p.partner === 'Dorée')).toBe(true);
expect(partners.some(p => p.partner === 'Amande')).toBe(true);
});
it('returns empty array for unknown race', () => {
expect(svc.getCompatiblePartners('Unknown')).toEqual([]);
});
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();
});
it('gets generation of a race', () => {
expect(svc.getGeneration('Rousse')).toBe(1);
expect(svc.getGeneration('Dorée et Rousse')).toBe(2);
});
it('all recipes are bidirectionally deducible', () => {
// For every known recipe, both orderings should work
const parents = svc.getParents('Amande et Dorée');
expect(parents).not.toBeNull();
if (parents) {
expect(svc.deduceBaby(parents[0], parents[1])).toBe('Amande et Dorée');
expect(svc.deduceBaby(parents[1], parents[0])).toBe('Amande et Dorée');
}
});
});

View File

@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { createEnclos, addDragodinde, removeDragodinde, MAX_DD, MAX_ENCLOS, MAX_GAUGES } from '@domain/entities/Enclos';
import { createDragodinde } from '@domain/entities/Dragodinde';
import { createAccouplement } from '@domain/entities/Accouplement';
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);
expect(enc.gaugeLevels.baffeur).toBe(0);
});
it('adds a dragodinde', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
expect(enc.dragodindes).toHaveLength(1);
expect(enc.dragodindes[0]!.name).toBe('Dragodinde 1');
expect(enc.nextDdId).toBe(2);
});
it('increments DD id', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
enc = addDragodinde(enc);
expect(enc.dragodindes[1]!.id).toBe(2);
expect(enc.dragodindes[1]!.name).toBe('Dragodinde 2');
});
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);
});
it('remove non-existent DD is no-op', () => {
let enc = createEnclos(1);
enc = addDragodinde(enc);
enc = removeDragodinde(enc, 999);
expect(enc.dragodindes).toHaveLength(1);
});
it('constants are correct', () => {
expect(MAX_DD).toBe(10);
expect(MAX_ENCLOS).toBe(6);
expect(MAX_GAUGES).toBe(2);
});
});
describe('Dragodinde', () => {
it('creates with defaults', () => {
const dd = createDragodinde(1);
expect(dd.id).toBe(1);
expect(dd.name).toBe('Dragodinde 1');
expect(dd.gender).toBe('n');
expect(dd.stats.serenite).toBe(0);
expect(dd.stats.xp).toBe(1);
expect(dd.targets.baffeur).toBe(-5000);
expect(dd.sereniteTarget).toBeNull();
expect(dd.levelTarget).toBeNull();
});
});
describe('Accouplement', () => {
it('creates with all fields', () => {
const a = createAccouplement('Rousse', 'Dorée', 'Dorée et Rousse', 2, 5, 3);
expect(a.parentA).toBe('Rousse');
expect(a.parentB).toBe('Dorée');
expect(a.baby).toBe('Dorée et Rousse');
expect(a.gen).toBe(2);
expect(a.couples).toBe(5);
expect(a.babiesObtained).toBe(3);
expect(a.date).toBeTruthy();
});
});

View File

@ -0,0 +1,45 @@
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 event types', () => {
const bus = new EventBus();
const handler = vi.fn();
bus.on('timer-completed', handler);
bus.emit({ type: 'accouplement-registered' });
expect(handler).not.toHaveBeenCalled();
});
it('supports multiple handlers for same event', () => {
const bus = new EventBus();
const h1 = vi.fn();
const h2 = vi.fn();
bus.on('timer-completed', h1);
bus.on('timer-completed', h2);
bus.emit({ type: 'timer-completed' });
expect(h1).toHaveBeenCalled();
expect(h2).toHaveBeenCalled();
});
it('off removes handler', () => {
const bus = new EventBus();
const handler = vi.fn();
bus.on('timer-completed', handler);
bus.off('timer-completed', handler);
bus.emit({ type: 'timer-completed' });
expect(handler).not.toHaveBeenCalled();
});
it('emit with no handlers does not throw', () => {
const bus = new EventBus();
expect(() => bus.emit({ type: 'timer-completed' })).not.toThrow();
});
});

View File

@ -0,0 +1,224 @@
import { describe, it, expect } from 'vitest';
import { gainedIn, timeToGain, gaugeAfter, elapsed, computeGaugeState, TimerState } from '@domain/services/GaugeCalculator';
import type { GaugeRecharge } from '@domain/services/GaugeCalculator';
describe('GaugeCalculator', () => {
describe('gainedIn', () => {
it('tier 1 only: 100 pts in 100 sec', () => {
// Level 100, 100 sec = 10 ticks, rate 10 → gain 100
expect(gainedIn(100, 100)).toBe(100);
});
it('zero seconds = zero gain', () => {
expect(gainedIn(50000, 0)).toBe(0);
});
it('level 0 = zero gain', () => {
expect(gainedIn(0, 100)).toBe(0);
});
it('crosses tier boundary 2→1', () => {
// Level 40010, 10 sec = 1 tick. In tier 2 (40001-70000), rate 20.
// But only 10 pts above 40000, so 10/20 = 0 full ticks at rate 20 → 0 gained from tier 2
// Actually: a = 40010-40000 = 10, m = floor(10/20) = 0, u = 0. Move to tier 1.
// Tier 1: g=40010 > 0, a=40010-0=40010... wait g is now 40010 still since u=0
// Let me recalculate: g=40010, tier {lo:40000, r:20}: a=10, m=0, u=0. No drain.
// Tier {lo:0, r:10}: g=40010 > 0, a=40010, m=4001, u=min(4001,1)=1, out=10
// Actually g should still be 40010 since we didn't drain. Hmm but the loop says if g<=lo continue
// g=40010 > 40000 → process. a=10, m=floor(10/20)=0, u=0.
// g=40010 > 0 → process. a=40010, m=floor(40010/10)=4001, u=min(4001,1)=1, out=10.
expect(gainedIn(40010, 10)).toBe(10);
});
it('full tier 4 drain', () => {
// Level 100000, 250 ticks needed to drain tier 4 (10000/40=250)
// 250 ticks = 2500 sec
expect(gainedIn(100000, 2500)).toBe(10000);
});
it('large gauge drains through multiple tiers', () => {
// Level 100000, 5000 sec = 500 ticks
// Tier 4: 100000→90000 = 10000/40 = 250 ticks → gain 10000, tl=250
// Tier 3: 90000→70000 = 20000/30 = 666 ticks, but only 250 left → 250*30 = 7500
// Total = 17500
expect(gainedIn(100000, 5000)).toBe(17500);
});
it('negative level clamped to 0', () => {
expect(gainedIn(-100, 100)).toBe(0);
});
it('level above 100000 clamped', () => {
expect(gainedIn(150000, 10)).toBe(40); // tier 4 rate
});
});
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 needs 100 sec', () => {
// Level 100, need 100 pts at rate 10 → 10 ticks → 100 sec
expect(timeToGain(100, 100)).toBe(100);
});
it('tier 2: 1000 pts from level 50000', () => {
// Level 50000, tier 2 rate 20. 1000/20 = 50 ticks → 500 sec
expect(timeToGain(50000, 1000)).toBe(500);
});
it('returns Infinity if level is 0', () => {
expect(timeToGain(0, 100)).toBe(Infinity);
});
});
describe('gaugeAfter', () => {
it('no time = same level', () => {
expect(gaugeAfter(50000, 0)).toBe(50000);
});
it('tier 1 full drain', () => {
// Level 100, 100 sec = 10 ticks. 100/10=10 ticks needed. Exactly drains to 0.
expect(gaugeAfter(100, 100)).toBe(0);
});
it('does not go below 0', () => {
expect(gaugeAfter(50, 1000)).toBe(0);
});
it('partial drain in tier 2', () => {
// Level 50000, 10 sec = 1 tick. Rate 20 at tier 2. 50000-20 = 49980
expect(gaugeAfter(50000, 10)).toBe(49980);
});
});
describe('computeGaugeState', () => {
const NO_RECHARGES: GaugeRecharge[] = [];
it('sans recharge ni cap : identique à gainedIn', () => {
const el = 1000;
const startGl = 50000;
const { gained, curGl, effectiveEl } = computeGaugeState(startGl, NO_RECHARGES, Infinity, el);
expect(gained).toBe(gainedIn(startGl, el));
expect(curGl).toBe(gaugeAfter(startGl, el));
expect(effectiveEl).toBe(el);
});
it('cap à 0 dès le départ (stat déjà au max) → gel immédiat', () => {
const { gained, effectiveEl } = computeGaugeState(90000, NO_RECHARGES, 0, 5000);
expect(gained).toBe(0);
expect(effectiveEl).toBe(0);
});
it('cap atteint en cours de segment → gel précis', () => {
// Tier 2 (50000), rate 20/tick. On veut 200 pts → 10 ticks → 100 sec.
const { gained, effectiveEl } = computeGaugeState(50000, NO_RECHARGES, 200, 5000);
expect(gained).toBe(200);
expect(effectiveEl).toBe(100); // timeToGain(50000, 200) = 100 sec
});
it('une recharge avant la fin, pas de cap → somme des deux segments', () => {
// Seg1 : 50000, 100 sec → gainedIn(50000, 100) = 10 ticks × 20 = 200 pts
// Recharge à 80000 à t=100
// Seg2 : 80000, 100 sec → gainedIn(80000, 100) = 10 ticks × 30 = 300 pts
// Total = 500 pts
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
const { gained, effectiveEl } = computeGaugeState(50000, recharges, Infinity, 200);
expect(gained).toBe(500);
expect(effectiveEl).toBe(200);
});
it('cap atteint dans le premier segment (avant la recharge)', () => {
// Seg1 : 50000, cap=100 pts → gel à timeToGain(50000,100)=50 sec
// Recharge à t=100 ne doit pas être prise en compte
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 90000 }];
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 100, 500);
expect(gained).toBe(100);
expect(effectiveEl).toBe(50); // gel avant la recharge
});
it('cap atteint dans le deuxième segment (après une recharge)', () => {
// Seg1 : 50000, 100 sec → 200 pts, cap=500 → pas encore atteint
// Recharge à 80000 à t=100
// Seg2 : 80000, cap restant=300 pts → timeToGain(80000,300)=100 sec → effectiveEl=100+100=200
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 500, 9999);
expect(gained).toBe(500);
expect(effectiveEl).toBe(200);
});
it('plusieurs recharges successives', () => {
// Seg1 : 10000 (tier1), 50 sec → 5 ticks × 10 = 50 pts
// Recharge à 50000 à t=50
// Seg2 : 50000 (tier2), 50 sec → 5 ticks × 20 = 100 pts
// Recharge à 90000 à t=100
// Seg3 : 90000 (tier3), 50 sec → 5 ticks × 30 = 150 pts
// Total = 300 pts
const recharges: GaugeRecharge[] = [
{ atSec: 50, level: 50000 },
{ atSec: 100, level: 90000 },
];
const { gained } = computeGaugeState(10000, recharges, Infinity, 150);
expect(gained).toBe(300);
});
it('recharge après le cap → le cap prime, recharge ignorée', () => {
// Cap à 50 pts → gel à 50 sec (tier2, rate20, 50/20=2.5→3 ticks=30sec... wait)
// timeToGain(50000, 50) = ceil(50/20)*10 = 3*10 = 30 sec
// Recharge à t=100 → ignorée car gel à t=30
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 90000 }];
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 50, 9999);
expect(gained).toBe(50);
expect(effectiveEl).toBe(30);
});
it('curGl reflète le niveau après le gel', () => {
// Tier2 (50000), cap=200 pts → 10 ticks → 100 sec, gauge = 50000-10*20=49800
const { curGl } = computeGaugeState(50000, NO_RECHARGES, 200, 9999);
expect(curGl).toBe(49800);
});
it('curGl reflète la recharge dans le deuxième segment', () => {
// Seg1: 50000, 100sec → curGl après seg1 = gaugeAfter(50000,100)=49800 mais recharge à 80000
// Seg2: 80000, no cap → curGl = gaugeAfter(80000, 100sec) = 80000-10*30=79700
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
const { curGl } = computeGaugeState(50000, recharges, Infinity, 200);
expect(curGl).toBe(gaugeAfter(80000, 100));
});
});
describe('elapsed', () => {
it('no start time = 0', () => {
const t: TimerState = { startTime: null, running: false, pausedAt: null, pausedMs: 0 };
expect(elapsed(t)).toBe(0);
});
it('running timer', () => {
const now = Date.now();
const t: TimerState = { startTime: now - 10000, running: true, pausedAt: null, pausedMs: 0 };
const el = elapsed(t);
expect(el).toBeGreaterThanOrEqual(9.9);
expect(el).toBeLessThanOrEqual(10.5);
});
it('paused timer', () => {
const now = Date.now();
const t: TimerState = { startTime: now - 10000, running: false, pausedAt: now - 5000, pausedMs: 0 };
const el = elapsed(t);
expect(el).toBeCloseTo(5, 0);
});
it('paused timer with accumulated pause', () => {
const now = Date.now();
const t: TimerState = { startTime: now - 20000, running: false, pausedAt: now - 5000, pausedMs: 5000 };
// elapsed = (pausedAt - startTime - pausedMs) / 1000 = (15000 - 5000) / 1000 = 10
expect(elapsed(t)).toBeCloseTo(10, 0);
});
});
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { GaugeType, GAUGE_DEFS, STAT_DEFS, DEFAULT_TARGETS, targetRange } 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');
});
it('STAT_DEFS has 5 stats', () => {
expect(Object.keys(STAT_DEFS)).toHaveLength(5);
});
it('targetRange for baffeur returns min:-5000 max:0', () => {
expect(targetRange('baffeur')).toEqual({ min: -5000, max: 0 });
});
it('targetRange for caresseur returns min:0 max:5000', () => {
expect(targetRange('caresseur')).toEqual({ min: 0, max: 5000 });
});
it('targetRange for mangeoire returns min:1 max:200', () => {
expect(targetRange('mangeoire')).toEqual({ min: 1, max: 200 });
});
it('targetRange for foudroyeur returns min:0 max:20000', () => {
expect(targetRange('foudroyeur')).toEqual({ min: 0, max: 20000 });
});
});

View File

@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { InventaireCalculator } from '@domain/services/InventaireCalculator';
describe('InventaireCalculator', () => {
const calc = new InventaireCalculator();
it('empty inventory produces nothing', () => {
const r = calc.compute({});
expect(r.generations).toHaveLength(0);
});
it('zero stock produces nothing', () => {
const r = calc.compute({ 'Rousse': { m: 0, f: 0 } });
expect(r.generations).toHaveLength(0);
});
it('\u2642 Rousse + \u2640 Dor\u00e9e produces Dor\u00e9e et Rousse', () => {
const r = calc.compute({
'Rousse': { m: 1, f: 0 },
'Dor\u00e9e': { m: 0, f: 1 },
});
expect(r.generations.length).toBeGreaterThanOrEqual(1);
const gen2 = r.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
expect(gen2!.crossings.some(c => c.name === 'Dor\u00e9e et Rousse')).toBe(true);
});
it('\u2640 Rousse + \u2642 Dor\u00e9e also produces (reversed genders)', () => {
const r = calc.compute({
'Rousse': { m: 0, f: 1 },
'Dor\u00e9e': { m: 1, f: 0 },
});
const gen2 = r.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
expect(gen2!.crossings.some(c => c.name === 'Dor\u00e9e et Rousse')).toBe(true);
});
it('same gender cannot breed (\u2642+\u2642)', () => {
const r = calc.compute({
'Rousse': { m: 2, f: 0 },
'Dor\u00e9e': { m: 2, f: 0 },
});
expect(r.generations).toHaveLength(0);
});
it('same gender cannot breed (\u2640+\u2640)', () => {
const r = calc.compute({
'Rousse': { m: 0, f: 2 },
'Dor\u00e9e': { m: 0, f: 2 },
});
expect(r.generations).toHaveLength(0);
});
it('babies cascade to next generation', () => {
const r = calc.compute({
'Rousse': { m: 2, f: 2 },
'Amande': { m: 2, f: 2 },
'Dor\u00e9e': { m: 2, f: 2 },
});
const gen2 = r.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
expect(gen2!.crossings.length).toBeGreaterThan(0);
// Check if gen 3 exists (cascade from gen 2 babies)
// This depends on whether gen 2 babies can breed with each other
});
it('multiple pairs produce multiple babies', () => {
const r = calc.compute({
'Rousse': { m: 3, f: 3 },
'Dor\u00e9e': { m: 3, f: 3 },
});
const gen2 = r.generations.find(g => g.gen === 2);
expect(gen2).toBeDefined();
const dr = gen2!.crossings.find(c => c.name === 'Dor\u00e9e et Rousse');
expect(dr).toBeDefined();
// With 3\u2642+3\u2640 of each, we can make up to 3 pairs (limited by one side)
expect(dr!.qty).toBeGreaterThanOrEqual(3);
});
});

View File

@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { RACE_GEN, GEN_COLORS, BREEDING_RECIPES, RACES_DATA, isBaseRace, generationOf, raceColor } 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(generationOf('Amande et Dorée')).toBe(2);
expect(generationOf('Amande 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);
});
it('BREEDING_RECIPES maps baby to parents', () => {
expect(BREEDING_RECIPES['Dorée et Rousse']).toEqual(['Rousse', 'Dorée']);
expect(BREEDING_RECIPES['Amande et Dorée']).toEqual(['Amande', 'Dorée']);
});
it('Ebène = Amande et Dorée + Dorée et Rousse (pas Amande et Rousse)', () => {
expect(BREEDING_RECIPES['Ebène']).toEqual(['Amande et Dorée', 'Dorée et Rousse']);
});
it('RACES_DATA has generations 2-10', () => {
for (let g = 2; g <= 10; g++) {
expect(RACES_DATA[g]).toBeDefined();
expect(RACES_DATA[g]!.length).toBeGreaterThan(0);
}
});
it('raceColor returns a color string', () => {
expect(raceColor('Rousse')).toBe('#c8622a');
expect(raceColor('Dorée')).toBe('#e8b820');
expect(raceColor('Unknown')).toBe('#888');
});
it('all breeding recipes have valid parents in RACE_GEN', () => {
for (const [baby, [pA, pB]] of Object.entries(BREEDING_RECIPES)) {
expect(RACE_GEN[baby]).toBeDefined();
expect(RACE_GEN[pA]).toBeDefined();
expect(RACE_GEN[pB]).toBeDefined();
}
});
});
describe('Gender', () => {
it('has male, female, neutral', () => {
const values: Gender[] = ['m', 'f', 'n'];
expect(values).toHaveLength(3);
});
});

View File

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { ReapproCalculator } from '@domain/services/ReapproCalculator';
describe('ReapproCalculator', () => {
const calc = new ReapproCalculator();
it('empty target returns empty', () => {
const r = calc.compute({ target: '', qty: 1, repro: {}, inverted: {} });
expect(r.steps).toHaveLength(0);
expect(r.gen1Needs).toHaveLength(0);
});
it('unknown target returns empty', () => {
const r = calc.compute({ target: 'Unknown', qty: 1, repro: {}, inverted: {} });
expect(r.steps).toHaveLength(0);
});
it('gen2 race needs 2 gen1 parents', () => {
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: {} });
expect(r.steps).toHaveLength(1);
expect(r.steps[0]!.parentA).toBe('Rousse');
expect(r.steps[0]!.parentB).toBe('Dorée');
expect(r.steps[0]!.couples).toBe(4);
expect(r.gen1Needs).toHaveLength(2);
expect(r.totalGen1).toBe(8); // 4 Rousse + 4 Dorée
});
it('gender inversion swaps parents', () => {
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: { 'Dorée et Rousse': true } });
expect(r.steps[0]!.parentA).toBe('Dorée');
expect(r.steps[0]!.parentB).toBe('Rousse');
});
it('breeders reduce couple count', () => {
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: { 'Dorée et Rousse': 2 }, inverted: {} });
expect(r.steps[0]!.couples).toBe(2);
});
it('gen1 needs include male/female split', () => {
const r = calc.compute({ target: 'Dorée et Rousse', qty: 2, repro: {}, inverted: {} });
const rousse = r.gen1Needs.find(n => n.name === 'Rousse');
const doree = r.gen1Needs.find(n => n.name === 'Dorée');
expect(rousse).toBeDefined();
expect(doree).toBeDefined();
// Default: parentA (Rousse) = male, parentB (Dorée) = female
expect(rousse!.m).toBe(2);
expect(doree!.f).toBe(2);
});
});

View File

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { SerenityCalculator } from '@domain/services/SerenityCalculator';
describe('SerenityCalculator', () => {
const calc = new SerenityCalculator();
it('null target → not done, 0 seconds', () => {
const r = calc.computeEta({ currentSerenite: 0, target: null, activeGauges: [], gaugeLevels: {} });
expect(r.done).toBe(false);
expect(r.seconds).toBe(0);
});
it('target already reached → done', () => {
const r = calc.computeEta({ currentSerenite: -5000, target: -5000, activeGauges: ['baffeur'], gaugeLevels: { baffeur: 50000 } });
expect(r.done).toBe(true);
});
it('needs baffeur to decrease serenite', () => {
const r = calc.computeEta({ currentSerenite: 0, target: -5000, activeGauges: [], gaugeLevels: {} });
expect(r.needsGauge).toBe('baffeur');
expect(r.seconds).toBe(Infinity);
});
it('needs caresseur to increase serenite', () => {
const r = calc.computeEta({ currentSerenite: 0, target: 40, activeGauges: [], gaugeLevels: {} });
expect(r.needsGauge).toBe('caresseur');
});
it('computes time with baffeur active', () => {
const r = calc.computeEta({ currentSerenite: 0, target: -100, activeGauges: ['baffeur'], gaugeLevels: { baffeur: 50000 } });
expect(r.done).toBe(false);
expect(r.seconds).toBeGreaterThan(0);
expect(r.seconds).toBeLessThan(Infinity);
});
it('computes time with caresseur active', () => {
const r = calc.computeEta({ currentSerenite: 0, target: 100, activeGauges: ['caresseur'], gaugeLevels: { caresseur: 50000 } });
expect(r.done).toBe(false);
expect(r.seconds).toBeGreaterThan(0);
expect(r.seconds).toBeLessThan(Infinity);
});
});

View File

@ -0,0 +1,163 @@
import { describe, it, expect } from 'vitest';
import { simulateStock } from '@domain/services/StockSimulator';
describe('simulateStock', () => {
it('stock vide → aucun croisement', () => {
const r = simulateStock({});
expect(r.crossings).toHaveLength(0);
expect(r.unusedStock).toHaveLength(0);
});
it('stock à zéro → aucun croisement', () => {
const r = simulateStock({ Rousse: { m: 0, f: 0 } });
expect(r.crossings).toHaveLength(0);
});
it('♂Rousse + ♀Dorée → Dorée et Rousse ×1 (config1)', () => {
const r = simulateStock({ Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } });
expect(r.crossings).toHaveLength(1);
expect(r.crossings[0]!.baby).toBe('Dorée et Rousse');
expect(r.crossings[0]!.count).toBe(1);
expect(r.crossings[0]!.pAMale).toBe(1); // ♂Rousse
expect(r.crossings[0]!.pAFemale).toBe(0);
expect(r.crossings[0]!.pBFemale).toBe(1); // ♀Dorée
expect(r.crossings[0]!.pBMale).toBe(0);
});
it('♀Rousse + ♂Dorée → Dorée et Rousse ×1 (config2)', () => {
const r = simulateStock({ Rousse: { m: 0, f: 1 }, Dorée: { m: 1, f: 0 } });
expect(r.crossings).toHaveLength(1);
expect(r.crossings[0]!.baby).toBe('Dorée et Rousse');
expect(r.crossings[0]!.count).toBe(1);
expect(r.crossings[0]!.pAMale).toBe(0);
expect(r.crossings[0]!.pAFemale).toBe(1); // ♀Rousse config2
});
it('♂♂Rousse + ♀♀Dorée (même sexe d\'un côté) → 2 bébés config1 seulement', () => {
const r = simulateStock({ Rousse: { m: 2, f: 0 }, Dorée: { m: 0, f: 2 } });
expect(r.crossings).toHaveLength(1);
expect(r.crossings[0]!.count).toBe(2);
expect(r.crossings[0]!.pAMale).toBe(2); // 2× ♂Rousse × ♀Dorée
expect(r.crossings[0]!.pAFemale).toBe(0); // pas de config2
});
it('les deux configs simultanées : 2♂/2♀ × 2♂/2♀ → 4 bébés', () => {
const r = simulateStock({ Rousse: { m: 2, f: 2 }, Dorée: { m: 2, f: 2 } });
const c = r.crossings.find(x => x.baby === 'Dorée et Rousse');
expect(c).toBeDefined();
expect(c!.count).toBe(4);
expect(c!.pAMale).toBe(2); // c1 = min(♂R=2, ♀D=2)
expect(c!.pAFemale).toBe(2); // c2 = min(♀R=2, ♂D=2)
});
it('distribution proportionnelle : 4♂/4♀ × 3 races → 4 bébés par croisement Gen2', () => {
const r = simulateStock({
Rousse: { m: 4, f: 4 },
Dorée: { m: 4, f: 4 },
Amande: { m: 4, f: 4 },
});
const gen2 = r.crossings.filter(c => c.gen === 2);
expect(gen2).toHaveLength(3);
for (const c of gen2) {
expect(c.count).toBe(4);
}
});
it('cascade complète Gen2→Gen3→Gen4→Gen5 avec 4♂/4♀ × 3 races', () => {
const r = simulateStock({
Rousse: { m: 4, f: 4 },
Dorée: { m: 4, f: 4 },
Amande: { m: 4, f: 4 },
});
// Gen2 : AmR×4, DorR×4, AmD×4 = 12
const gen2 = r.crossings.filter(c => c.gen === 2);
expect(gen2.reduce((s, c) => s + c.count, 0)).toBe(12);
// Gen3 : Ebène×2, Indigo×2 = 4
const gen3 = r.crossings.filter(c => c.gen === 3);
expect(gen3).toHaveLength(2);
expect(gen3.find(c => c.baby === 'Ebène')!.count).toBe(2);
expect(gen3.find(c => c.baby === 'Indigo')!.count).toBe(2);
// Gen4 : Ebène et Indigo×2
const gen4 = r.crossings.filter(c => c.gen === 4);
expect(gen4).toHaveLength(1);
expect(gen4[0]!.baby).toBe('Ebène et Indigo');
expect(gen4[0]!.count).toBe(2);
// Gen5 : Orchidée×2 (Pourpre ignoré car EI allocation = 0)
const gen5 = r.crossings.filter(c => c.gen === 5);
expect(gen5).toHaveLength(1);
expect(gen5[0]!.baby).toBe('Orchidée');
expect(gen5[0]!.count).toBe(2);
// Total : 12 + 4 + 2 + 2 = 20
const total = r.crossings.reduce((s, c) => s + c.count, 0);
expect(total).toBe(20);
});
it('allocation proportionnelle donne la priorité au second croisement quand le premier reçoit 0', () => {
// EI participe à Pourpre (1er) et Orchidée (2nd) en Gen5
// Avec 1♂/1♀ EI, alloc = floor(1/2) = 0 pour Pourpre → 0 bébés
// Orchidée : EI n'a plus qu'1 croisement restant → alloc = 1 → 2 bébés
const r = simulateStock({
'Ebène et Indigo': { m: 1, f: 1 },
'Amande et Rousse': { m: 1, f: 1 },
'Dorée et Rousse': { m: 1, f: 1 },
});
expect(r.crossings.find(c => c.baby === 'Pourpre')).toBeUndefined();
expect(r.crossings.find(c => c.baby === 'Orchidée')?.count).toBe(2);
});
it('race sans partenaire → stock inutilisé', () => {
const r = simulateStock({ Rousse: { m: 5, f: 5 } });
expect(r.crossings).toHaveLength(0);
expect(r.unusedStock).toHaveLength(1);
expect(r.unusedStock[0]!.race).toBe('Rousse');
expect(r.unusedStock[0]!.m).toBe(5);
expect(r.unusedStock[0]!.f).toBe(5);
});
it('stock restant après simulation correctement identifié', () => {
// 1♂ Rousse + 1♀ Dorée → 1 DorR. Rousse ♀ = 3 restantes
const r = simulateStock({ Rousse: { m: 1, f: 3 }, Dorée: { m: 0, f: 1 } });
expect(r.crossings).toHaveLength(1);
expect(r.crossings[0]!.count).toBe(1);
const leftR = r.unusedStock.find(u => u.race === 'Rousse');
expect(leftR).toBeDefined();
expect(leftR!.f).toBe(3);
});
it('bébés répartis ♂/♀ pour les générations suivantes', () => {
// 1♂/1♀ Rousse + 1♂/1♀ Dorée → 2 DorR (1♂, 1♀)
// 1♂/1♀ Amande + 1♂/1♀ Rousse_new? Non, Amande n'a pas de stock
const r = simulateStock({ Rousse: { m: 1, f: 1 }, Dorée: { m: 1, f: 1 } });
expect(r.crossings).toHaveLength(1);
expect(r.crossings[0]!.count).toBe(2);
// Bébés DorR dans unused : 1♂ + 1♀
const baby = r.unusedStock.find(u => u.race === 'Dorée et Rousse');
expect(baby).toBeDefined();
expect(baby!.m).toBe(1);
expect(baby!.f).toBe(1);
});
it('ne modifie pas l\'inventaire d\'entrée (immutabilité)', () => {
const inv = { Rousse: { m: 2, f: 2 }, Dorée: { m: 2, f: 2 } };
simulateStock(inv);
expect(inv.Rousse.m).toBe(2);
expect(inv.Rousse.f).toBe(2);
expect(inv.Dorée.m).toBe(2);
expect(inv.Dorée.f).toBe(2);
});
it('stock asymétrique : 3♂/1♀ Rousse + 1♂/3♀ Dorée', () => {
const r = simulateStock({ Rousse: { m: 3, f: 1 }, Dorée: { m: 1, f: 3 } });
const c = r.crossings.find(x => x.baby === 'Dorée et Rousse');
expect(c).toBeDefined();
// c1 = min(♂R=3, ♀D=3) = 3, c2 = min(♀R=1, ♂D=1) = 1 → bred = 4
expect(c!.count).toBe(4);
expect(c!.pAMale).toBe(3);
expect(c!.pAFemale).toBe(1);
});
});

View File

@ -0,0 +1,34 @@
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);
expect(tierNum(40000)).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);
});
it('TIER_THRESHOLDS has 4 entries', () => {
expect(TIER_THRESHOLDS).toHaveLength(4);
});
});

View File

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';
describe('XpCalculator', () => {
const calc = new XpCalculator();
it('null target → not done, 0 seconds', () => {
const r = calc.computeEta({ currentLevel: 1, target: null, gaugeLevels: {}, activeGauges: [] });
expect(r.done).toBe(false);
expect(r.seconds).toBe(0);
});
it('level already reached → done', () => {
const r = calc.computeEta({ currentLevel: 100, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(r.done).toBe(true);
});
it('level above target → done', () => {
const r = calc.computeEta({ currentLevel: 150, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(r.done).toBe(true);
});
it('needs mangeoire gauge', () => {
const r = calc.computeEta({ currentLevel: 1, target: 100, gaugeLevels: {}, activeGauges: [] });
expect(r.needsGauge).toBe('mangeoire');
expect(r.seconds).toBe(Infinity);
});
it('computes seconds to level up', () => {
const r = calc.computeEta({ currentLevel: 1, target: 10, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(r.done).toBe(false);
expect(r.seconds).toBeGreaterThan(0);
expect(r.seconds).toBeLessThan(Infinity);
});
it('regression: should NOT show ~23h for short XP gain', () => {
// Bug v1.1.4: timer displayed ~23h instead of real time
const r = calc.computeEta({ currentLevel: 1, target: 5, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
expect(r.seconds).toBeLessThan(3600); // Must be under 1h
});
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { XP_RAW, xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
describe('XpTable', () => {
it('has 200 entries', () => {
expect(Object.keys(XP_RAW)).toHaveLength(200);
});
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);
}
});
it('XP values are monotonically increasing', () => {
for (let lvl = 2; lvl <= 200; lvl++) {
expect(XP_RAW[lvl]!).toBeGreaterThan(XP_RAW[lvl - 1]!);
}
});
});

View File

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('smoke test', () => {
it('should run', () => {
expect(1 + 1).toBe(2);
});
});

View File

@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalStorageRepository } from '@infrastructure/persistence/LocalStorageRepository';
// Mock localStorage
const store: Record<string, string> = {};
const mockLocalStorage = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, val: string) => { store[key] = val; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
};
beforeEach(() => {
Object.keys(store).forEach(k => delete store[k]);
vi.stubGlobal('localStorage', mockLocalStorage);
// No electronAPI in test
if (typeof window !== 'undefined') {
delete (window as any).electronAPI;
}
});
describe('LocalStorageRepository', () => {
it('load returns null if no stored data', async () => {
const repo = new LocalStorageRepository();
const result = await repo.load();
expect(result).toBeNull();
});
it('save then load round-trips', async () => {
const repo = new LocalStorageRepository();
const state = {
enclos: [], activeId: null, nextEnclosId: 1,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
repo.save(state as any);
const loaded = await repo.load();
expect(loaded).not.toBeNull();
expect(loaded!.alarmSound).toBe('arpege');
expect(loaded!.accouplements).toEqual([]);
});
it('save resets timer running state', async () => {
const repo = new LocalStorageRepository();
const state = {
enclos: [{
id: 1, name: 'E1', activeGauges: [], gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
dragodindes: [], nextDdId: 1,
timer: { running: true, startTime: Date.now(), pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
alerted: {},
}],
activeId: 1, nextEnclosId: 2,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
repo.save(state as any);
const loaded = await repo.load();
expect(loaded!.enclos[0]!.timer.running).toBe(false);
});
it('deserialize applies default targets migration', async () => {
const repo = new LocalStorageRepository();
const raw = JSON.stringify({
enclos: [{
id: 1, name: 'E1', activeGauges: [], gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
dragodindes: [{ id: 1, name: 'DD1', stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0 }, targets: {}, race: '', gender: 'n', reproducteur: 0 }],
nextDdId: 2,
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
alerted: {},
}],
activeId: 1, nextEnclosId: 2, alarmSound: 'arpege', notifsEnabled: true,
});
store['dd3v3'] = raw;
const loaded = await repo.load();
expect(loaded!.enclos[0]!.dragodindes[0]!.targets.baffeur).toBe(-5000);
expect(loaded!.enclos[0]!.dragodindes[0]!.stats.xp).toBe(1);
});
});