diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..90af3e5 --- /dev/null +++ b/playwright.config.ts @@ -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', + }, +}); diff --git a/tests/e2e/breeding.spec.ts b/tests/e2e/breeding.spec.ts new file mode 100644 index 0000000..b829b21 --- /dev/null +++ b/tests/e2e/breeding.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/electron-app.ts b/tests/e2e/electron-app.ts new file mode 100644 index 0000000..489c1e2 --- /dev/null +++ b/tests/e2e/electron-app.ts @@ -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({ + // 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'; diff --git a/tests/e2e/gauge-recharge.spec.ts b/tests/e2e/gauge-recharge.spec.ts new file mode 100644 index 0000000..9a3033d --- /dev/null +++ b/tests/e2e/gauge-recharge.spec.ts @@ -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); + }); +}); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts new file mode 100644 index 0000000..2c17af4 --- /dev/null +++ b/tests/e2e/navigation.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/persistence.spec.ts b/tests/e2e/persistence.spec.ts new file mode 100644 index 0000000..4efad79 --- /dev/null +++ b/tests/e2e/persistence.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/timer-workflow.spec.ts b/tests/e2e/timer-workflow.spec.ts new file mode 100644 index 0000000..38a52a9 --- /dev/null +++ b/tests/e2e/timer-workflow.spec.ts @@ -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 }); + }); +}); diff --git a/tests/functional/breeding-workflow.test.ts b/tests/functional/breeding-workflow.test.ts new file mode 100644 index 0000000..770e55c --- /dev/null +++ b/tests/functional/breeding-workflow.test.ts @@ -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({ 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({ 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({ type: 'get-dashboard' }); + expect(dash.totalCouples).toBe(13); + expect(dash.totalBabies).toBe(8); + }); +}); diff --git a/tests/functional/enclos-management.test.ts b/tests/functional/enclos-management.test.ts new file mode 100644 index 0000000..d231bd9 --- /dev/null +++ b/tests/functional/enclos-management.test.ts @@ -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); + }); +}); diff --git a/tests/functional/timer-workflow.test.ts b/tests/functional/timer-workflow.test.ts new file mode 100644 index 0000000..662bebc --- /dev/null +++ b/tests/functional/timer-workflow.test.ts @@ -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({ 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({ 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({ type: 'get-dashboard' }); + expect(dash.enclosSummaries).toHaveLength(2); + }); +}); diff --git a/tests/regression/breeding-deduction.test.ts b/tests/regression/breeding-deduction.test.ts new file mode 100644 index 0000000..b1e8c6b --- /dev/null +++ b/tests/regression/breeding-deduction.test.ts @@ -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 + }); +}); diff --git a/tests/regression/gauge-recharge-countdown.test.ts b/tests/regression/gauge-recharge-countdown.test.ts new file mode 100644 index 0000000..9017253 --- /dev/null +++ b/tests/regression/gauge-recharge-countdown.test.ts @@ -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); + }); +}); diff --git a/tests/regression/gauge-recharge.test.ts b/tests/regression/gauge-recharge.test.ts new file mode 100644 index 0000000..97bfa5e --- /dev/null +++ b/tests/regression/gauge-recharge.test.ts @@ -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); + }); + }); +}); diff --git a/tests/regression/gauge-tier-calculation.test.ts b/tests/regression/gauge-tier-calculation.test.ts new file mode 100644 index 0000000..4105fdb --- /dev/null +++ b/tests/regression/gauge-tier-calculation.test.ts @@ -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); + }); +}); diff --git a/tests/regression/inventaire-simulation.test.ts b/tests/regression/inventaire-simulation.test.ts new file mode 100644 index 0000000..95c1366 --- /dev/null +++ b/tests/regression/inventaire-simulation.test.ts @@ -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); + }); +}); diff --git a/tests/regression/level-target-timer.test.ts b/tests/regression/level-target-timer.test.ts new file mode 100644 index 0000000..d992f62 --- /dev/null +++ b/tests/regression/level-target-timer.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/regression/stats-persistence.test.ts b/tests/regression/stats-persistence.test.ts new file mode 100644 index 0000000..9daf647 --- /dev/null +++ b/tests/regression/stats-persistence.test.ts @@ -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); + }); +}); diff --git a/tests/regression/xp-timer-display.test.ts b/tests/regression/xp-timer-display.test.ts new file mode 100644 index 0000000..784c21f --- /dev/null +++ b/tests/regression/xp-timer-display.test.ts @@ -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); + }); +}); diff --git a/tests/unit/application/CommandBus.test.ts b/tests/unit/application/CommandBus.test.ts new file mode 100644 index 0000000..e4c6ac0 --- /dev/null +++ b/tests/unit/application/CommandBus.test.ts @@ -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); + }); +}); diff --git a/tests/unit/application/commands.test.ts b/tests/unit/application/commands.test.ts new file mode 100644 index 0000000..1e30ebd --- /dev/null +++ b/tests/unit/application/commands.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/application/queries.test.ts b/tests/unit/application/queries.test.ts new file mode 100644 index 0000000..481d937 --- /dev/null +++ b/tests/unit/application/queries.test.ts @@ -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 + }); +}); diff --git a/tests/unit/domain/BreedingService.test.ts b/tests/unit/domain/BreedingService.test.ts new file mode 100644 index 0000000..fa877fe --- /dev/null +++ b/tests/unit/domain/BreedingService.test.ts @@ -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'); + } + }); +}); diff --git a/tests/unit/domain/Enclos.test.ts b/tests/unit/domain/Enclos.test.ts new file mode 100644 index 0000000..a4f3306 --- /dev/null +++ b/tests/unit/domain/Enclos.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/domain/EventBus.test.ts b/tests/unit/domain/EventBus.test.ts new file mode 100644 index 0000000..a126abb --- /dev/null +++ b/tests/unit/domain/EventBus.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/domain/GaugeCalculator.test.ts b/tests/unit/domain/GaugeCalculator.test.ts new file mode 100644 index 0000000..e1547fc --- /dev/null +++ b/tests/unit/domain/GaugeCalculator.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/domain/GaugeType.test.ts b/tests/unit/domain/GaugeType.test.ts new file mode 100644 index 0000000..bea192f --- /dev/null +++ b/tests/unit/domain/GaugeType.test.ts @@ -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 }); + }); +}); diff --git a/tests/unit/domain/InventaireCalculator.test.ts b/tests/unit/domain/InventaireCalculator.test.ts new file mode 100644 index 0000000..ac60850 --- /dev/null +++ b/tests/unit/domain/InventaireCalculator.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/Race.test.ts b/tests/unit/domain/Race.test.ts new file mode 100644 index 0000000..5f3b93a --- /dev/null +++ b/tests/unit/domain/Race.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/ReapproCalculator.test.ts b/tests/unit/domain/ReapproCalculator.test.ts new file mode 100644 index 0000000..b27bb2a --- /dev/null +++ b/tests/unit/domain/ReapproCalculator.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/SerenityCalculator.test.ts b/tests/unit/domain/SerenityCalculator.test.ts new file mode 100644 index 0000000..3a90679 --- /dev/null +++ b/tests/unit/domain/SerenityCalculator.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/StockSimulator.test.ts b/tests/unit/domain/StockSimulator.test.ts new file mode 100644 index 0000000..e359fdd --- /dev/null +++ b/tests/unit/domain/StockSimulator.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/Tier.test.ts b/tests/unit/domain/Tier.test.ts new file mode 100644 index 0000000..b9599ea --- /dev/null +++ b/tests/unit/domain/Tier.test.ts @@ -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); + }); +}); diff --git a/tests/unit/domain/XpCalculator.test.ts b/tests/unit/domain/XpCalculator.test.ts new file mode 100644 index 0000000..260a024 --- /dev/null +++ b/tests/unit/domain/XpCalculator.test.ts @@ -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 + }); +}); diff --git a/tests/unit/domain/XpTable.test.ts b/tests/unit/domain/XpTable.test.ts new file mode 100644 index 0000000..a8cf3cf --- /dev/null +++ b/tests/unit/domain/XpTable.test.ts @@ -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]!); + } + }); +}); diff --git a/tests/unit/domain/smoke.test.ts b/tests/unit/domain/smoke.test.ts new file mode 100644 index 0000000..cff60d9 --- /dev/null +++ b/tests/unit/domain/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('smoke test', () => { + it('should run', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/tests/unit/infrastructure/LocalStorageRepository.test.ts b/tests/unit/infrastructure/LocalStorageRepository.test.ts new file mode 100644 index 0000000..f9a7218 --- /dev/null +++ b/tests/unit/infrastructure/LocalStorageRepository.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LocalStorageRepository } from '@infrastructure/persistence/LocalStorageRepository'; + +// Mock localStorage +const store: Record = {}; +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); + }); +});